Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: neo4j-cli
kind: Minor
body: 'New commands for managing projects and tags in a local Neo4j Desktop 2 install: `desktop project list/create/update/delete` and `desktop tag list/create/update/delete` manage the Desktop project and tag catalogs. Create and update print the changed entry, failing with an error if Desktop''s returned catalog state lacks it; delete prints a minimal confirmation envelope (`id` and `deleted: true`). The new `desktop dbms update` command renames a DBMS, edits its description, or replaces its project/tag assignments. `desktop connection update` gains matching `--project`/`--tags` flags; both take project/tag names or UUIDs (repeatable/comma-separated) with replace semantics, and an empty value clears the assignments. In `desktop` JSON output, the `project` field on DBMS and connection objects is renamed to `projects` and is now an array instead of a single string. On Desktop versions too old to have the projects/tags endpoints, these commands fail with a clear message telling the user to upgrade Neo4j Desktop 2.'
time: 2026-10-05T16:08:45.005645+02:00
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Strict one-file-per-leaf layout under `internal/commands/aura/<resource>/` and `
- Colocated `<action>_test.go`; shared helpers in `<resource>_helpers_test.go`.
- New leaf = `<action>.go` + `<action>_test.go` + one `AddCommand` line.
- Documented exception: `admin privilege` grant/deny/revoke generate their 7 category leaves (property/entity/graph/label/load/database/dbms) from a single `categoryMeta` table via `newCategoryCmd` in `privilege/helpers.go` — no per-leaf file.
- Documented exception: `desktop project`/`desktop tag` generate their 4 catalog leaves (list/create/update/delete) from a `Spec` via `catalogcmd.Mount` (`internal/commands/desktop/internal/catalogcmd/`) — no per-leaf files; each package's spec data lives in `spec.go`.

## Build System

Expand Down
37 changes: 1 addition & 36 deletions internal/commands/desktop/connection/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
package connection

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"

Expand All @@ -15,7 +13,6 @@ import (
"github.com/neo4j/cli/internal/clievents"
"github.com/neo4j/cli/internal/desktopclient"
"github.com/neo4j/cli/internal/output"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/term"
)
Expand All @@ -24,38 +21,6 @@ import (
// JSON output emits the full Connection wire payload.
var connectionCreateFields = []string{"id", "name", "connection_uri"}

// newDesktopClientFn is the test seam for desktop client construction.
var newDesktopClientFn = newDesktopClient

func newDesktopClient(ctx context.Context, fs afero.Fs, port int) (*desktopclient.Client, error) {
// Discover runs first so its origin can be threaded into ResolveDataDir.
probe, err := desktopclient.Discover(ctx, port)
if err != nil {
if errors.Is(err, desktopclient.ErrNoDesktop) {
return nil, desktopclient.UnreachableError()
}
return nil, clierr.NewFatalError("desktop: probe failed: %s", err.Error())
}
dataDir, err := desktopclient.ResolveDataDir(ctx, fs, probe)
if err != nil {
return nil, clierr.NewFatalError("desktop: could not resolve relate data dir: %s", err.Error())
}
salt, err := desktopclient.LoadSalt(fs, dataDir)
if err != nil {
// Missing/unreadable salt = Desktop has not finished first-run auth
// setup. Route to the same canonical hint as a probe miss.
return nil, desktopclient.UnreachableError()
}
return desktopclient.NewClient(probe, salt)
}

// SetNewDesktopClientFnForTest overrides the client constructor for tests and returns a restore func.
func SetNewDesktopClientFnForTest(fn func(context.Context, afero.Fs, int) (*desktopclient.Client, error)) func() {
prev := newDesktopClientFn
newDesktopClientFn = fn
return func() { newDesktopClientFn = prev }
}

// stdinIsTTYFn is the test seam for stdin TTY detection.
var stdinIsTTYFn = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }

Expand Down Expand Up @@ -177,7 +142,7 @@ neo4j-cli desktop connection create --name aura-dev --uri neo4j+s://xyz789.datab
password = pw
}

client, err := newDesktopClientFn(ctx, fs, port)
client, err := desktopclient.Connect(ctx, fs, port)
if err != nil {
return err
}
Expand Down
10 changes: 5 additions & 5 deletions internal/commands/desktop/connection/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
)

// createHelper wires `desktop.NewCmd` against an in-memory FS, with the
// connection subtree's `newDesktopClientFn` seam pinned to a desktopclient.
// connection subtree's `desktopclient.Connect` seam pinned to a desktopclient.
// Client backed by an httptest server. Mirrors the createHelper pattern in
// the parent `desktop` package's tests so the connection leaves get the same
// hermetic end-to-end coverage.
Expand Down Expand Up @@ -74,7 +74,7 @@ func (h *createHelper) withHandler(handler http.HandlerFunc) *httptest.Server {
srv := httptest.NewServer(handler)
h.t.Cleanup(srv.Close)

h.t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
h.t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
return desktopclient.NewClient(desktopclient.ProbeResult{Origin: srv.URL}, salt)
}))
return srv
Expand Down Expand Up @@ -145,7 +145,7 @@ func TestCreate_SuccessfulCreate(t *testing.T) {
h.withHandler(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/fastify/api/connections" {
capturedBody = readPostBody(t, r)
_, _ = w.Write([]byte(`{"id":"f4e2f3c0-1111-2222-3333-444455556666","name":"aura-prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","project":"proj-1"}`))
_, _ = w.Write([]byte(`{"id":"f4e2f3c0-1111-2222-3333-444455556666","name":"aura-prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","projects":["proj-1"]}`))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
Expand Down Expand Up @@ -343,7 +343,7 @@ func TestCreate_Example_FlushLeft(t *testing.T) {
func TestCreate_PortFlagPropagates(t *testing.T) {
h := newCreateHelper(t)
var gotPort int
t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) {
t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) {
gotPort = port
return nil, desktopclient.UnreachableError()
}))
Expand All @@ -358,7 +358,7 @@ func TestCreate_PortFlagPropagates(t *testing.T) {
// canonical error mapping when Desktop is not running.
func TestCreate_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) {
h := newCreateHelper(t)
t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
return nil, desktopclient.UnreachableError()
}))

Expand Down
3 changes: 2 additions & 1 deletion internal/commands/desktop/connection/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/neo4j/cli/internal/clicfg"
"github.com/neo4j/cli/internal/clierr"
"github.com/neo4j/cli/internal/confirm"
"github.com/neo4j/cli/internal/desktopclient"
"github.com/neo4j/cli/internal/output"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -53,7 +54,7 @@ neo4j-cli desktop connection delete f4e2f3c0-1111-2222-3333-444455556666 --yes -
fs := cfg.Fs()
port, _ := cmd.Flags().GetInt("port")

client, err := newDesktopClientFn(ctx, fs, port)
client, err := desktopclient.Connect(ctx, fs, port)
if err != nil {
return err
}
Expand Down
9 changes: 4 additions & 5 deletions internal/commands/desktop/connection/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"github.com/google/shlex"
"github.com/neo4j/cli/internal/clicfg"
"github.com/neo4j/cli/internal/commands/desktop"
"github.com/neo4j/cli/internal/commands/desktop/connection"
"github.com/neo4j/cli/internal/confirm"
"github.com/neo4j/cli/internal/confirm/confirmtest"
"github.com/neo4j/cli/internal/desktopclient"
Expand Down Expand Up @@ -67,7 +66,7 @@ func (h *deleteHelper) withHandler(handler http.HandlerFunc) *httptest.Server {
h.t.Cleanup(desktopclient.SetNowFnForTest(func() time.Time { return time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) }))
srv := httptest.NewServer(handler)
h.t.Cleanup(srv.Close)
h.t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
h.t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
return desktopclient.NewClient(desktopclient.ProbeResult{Origin: srv.URL}, salt)
}))
return srv
Expand All @@ -93,7 +92,7 @@ func (h *deleteHelper) run(command string) error {
// fail with a usage error pointing at `desktop list` BEFORE any HTTP call.
func TestDelete_RejectsNonUUID(t *testing.T) {
h := newDeleteHelper(t)
t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
t.Fatalf("must not construct client when id is not a UUID")
return nil, nil
}))
Expand Down Expand Up @@ -442,7 +441,7 @@ func TestDelete_PortFlagPropagates(t *testing.T) {
t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false }))

var gotPort int
t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) {
t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) {
gotPort = port
return nil, desktopclient.UnreachableError()
}))
Expand All @@ -458,7 +457,7 @@ func TestDelete_PortFlagPropagates(t *testing.T) {
func TestDelete_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) {
h := newDeleteHelper(t)
t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false }))
t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
return nil, desktopclient.UnreachableError()
}))

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/desktop/connection/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ neo4j-cli desktop connection list --port 44225`,
fs := cfg.Fs()
port, _ := cmd.Flags().GetInt("port")

client, err := newDesktopClientFn(ctx, fs, port)
client, err := desktopclient.Connect(ctx, fs, port)
if err != nil {
return err
}
Expand Down
5 changes: 2 additions & 3 deletions internal/commands/desktop/connection/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/google/shlex"
"github.com/neo4j/cli/internal/clicfg"
"github.com/neo4j/cli/internal/commands/desktop"
"github.com/neo4j/cli/internal/commands/desktop/connection"
"github.com/neo4j/cli/internal/desktopclient"
"github.com/neo4j/cli/internal/flags"
"github.com/neo4j/cli/internal/testutil/testfs"
Expand Down Expand Up @@ -57,7 +56,7 @@ func (h *listHelper) withHandler(handler http.HandlerFunc) *httptest.Server {
h.t.Cleanup(desktopclient.SetNowFnForTest(func() time.Time { return time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) }))
srv := httptest.NewServer(handler)
h.t.Cleanup(srv.Close)
h.t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
h.t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) {
return desktopclient.NewClient(desktopclient.ProbeResult{Origin: srv.URL}, salt)
}))
return srv
Expand Down Expand Up @@ -158,7 +157,7 @@ func TestConnectionList_5xx_SurfacesError(t *testing.T) {
func TestConnectionList_PortFlagPropagatesToClientConstructor(t *testing.T) {
h := newListHelper(t)
var seenPort int
t.Cleanup(connection.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) {
t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) {
seenPort = port
return nil, errors.New("stop here; we already captured the port")
}))
Expand Down
39 changes: 34 additions & 5 deletions internal/commands/desktop/connection/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func newUpdateCmd(cfg *clicfg.Config) *cobra.Command {
username string
password string
description string
projects []string
tags []string
)

const (
Expand All @@ -33,15 +35,18 @@ func newUpdateCmd(cfg *clicfg.Config) *cobra.Command {
usernameFlag = "username"
passwordFlag = "password"
descriptionFlag = "description"
projectFlag = "project"
tagsFlag = "tags"
)

cmd := &cobra.Command{
Use: "update <id>",
Short: "Update a saved remote DB connection registered with Neo4j Desktop 2",
Long: "Update a saved remote DB connection profile by id. " +
"Talks to Desktop's local relate API on http://localhost:<port>/fastify/api — Desktop must be running. " +
"At least one of `--name --uri --username --password --description` must be supplied; the PATCH body contains ONLY the keys you set, so empty-string is a legitimate update for `--description`. " +
"At least one of `--name --uri --username --password --description --project --tags` must be supplied; the PATCH body contains ONLY the keys you set, so empty-string is a legitimate update for `--description`. " +
"`--password` with an empty value prompts interactively (no echo) on a TTY and fails with a usage error on a non-TTY, mirroring `desktop connection create`. " +
"`--project`/`--tags` have REPLACE semantics: each element is a project/tag name or UUID resolved against the Desktop catalog, and the resolved arrays become the connection's entire project/tag assignments (pass an empty value to clear them). " +
"Find connection ids with `neo4j-cli desktop list`.",
Example: `# Rename a saved connection
neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --name aura-prod-renamed --rw
Expand All @@ -50,7 +55,10 @@ neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --name
neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --uri neo4j+s://new-host.databases.neo4j.io --password new-secret --rw

# Clear the description by sending an empty string and emit the updated Connection as JSON
neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --description "" --format json --rw`,
neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --description "" --format json --rw

# Reassign the connection to a project and replace its tags (names or UUIDs)
neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --project Prod --tags backend,oncall --rw`,
Annotations: map[string]string{"write": "true"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -70,9 +78,11 @@ neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --descr
usernameSet := cmd.Flag(usernameFlag).Changed
passwordSet := cmd.Flag(passwordFlag).Changed
descriptionSet := cmd.Flag(descriptionFlag).Changed
if !nameSet && !uriSet && !usernameSet && !passwordSet && !descriptionSet {
projectSet := cmd.Flag(projectFlag).Changed
tagsSet := cmd.Flag(tagsFlag).Changed
if !nameSet && !uriSet && !usernameSet && !passwordSet && !descriptionSet && !projectSet && !tagsSet {
return clierr.NewUsageError(
"at least one of --name, --uri, --username, --password, --description must be supplied")
"at least one of --name, --uri, --username, --password, --description, --project, --tags must be supplied")
}

ctx := cmd.Context()
Expand All @@ -90,7 +100,7 @@ neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --descr
password = pw
}

client, err := newDesktopClientFn(ctx, fs, port)
client, err := desktopclient.Connect(ctx, fs, port)
if err != nil {
return err
}
Expand All @@ -115,6 +125,23 @@ neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --descr
if descriptionSet {
updateArgs.Description = &description
}
if projectSet {
// Replace semantics: the resolved IDs become the connection's
// ENTIRE project assignment. Resolution errors are already
// usage errors — propagate as-is.
resolved, err := client.ResolveProjectIDs(ctx, desktopclient.NonEmptyStrings(projects))
if err != nil {
return err
}
updateArgs.Projects = &resolved
}
if tagsSet {
resolved, err := client.ResolveTagIDs(ctx, desktopclient.NonEmptyStrings(tags))
if err != nil {
return err
}
updateArgs.Tags = &resolved
}

updated, err := client.UpdateConnection(ctx, id, updateArgs)
if err != nil {
Expand All @@ -131,6 +158,8 @@ neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --descr
cmd.Flags().StringVar(&username, usernameFlag, "", "New username used to authenticate against the remote DB")
cmd.Flags().StringVar(&password, passwordFlag, "", "New password for the remote DB. Pass an empty value on a TTY to be prompted (no echo); fails on non-TTY")
cmd.Flags().StringVar(&description, descriptionFlag, "", "New description for the saved connection. Pass an empty string to clear the existing description")
cmd.Flags().StringSliceVar(&projects, projectFlag, nil, "Replace the connection's project assignments; each element is a project name or UUID (repeatable/comma-separated). Pass an empty value to clear all project assignments")
cmd.Flags().StringSliceVar(&tags, tagsFlag, nil, "Replace the connection's tag assignments; each element is a tag name or UUID (repeatable/comma-separated). Pass an empty value to clear all tag assignments")

return cmd
}
Loading
Loading