From e05c87e76bc766944555a878569a5ad24dc90f12 Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Tue, 6 Oct 2026 10:30:14 +0200 Subject: [PATCH 1/3] Add project and tag management to neo4j-cli desktop (CLI-239) --- .../neo4j-cli-Minor-20261005-160845.yaml | 4 + .../commands/desktop/connection/create.go | 37 +- .../desktop/connection/create_test.go | 10 +- .../commands/desktop/connection/delete.go | 3 +- .../desktop/connection/delete_test.go | 9 +- internal/commands/desktop/connection/list.go | 2 +- .../commands/desktop/connection/list_test.go | 5 +- .../commands/desktop/connection/update.go | 39 +- .../desktop/connection/update_test.go | 144 ++++- internal/commands/desktop/dbms/create.go | 2 +- internal/commands/desktop/dbms/create_test.go | 10 +- internal/commands/desktop/dbms/dbms.go | 5 +- internal/commands/desktop/dbms/delete.go | 3 +- internal/commands/desktop/dbms/delete_test.go | 6 +- internal/commands/desktop/dbms/helpers.go | 38 -- internal/commands/desktop/dbms/list.go | 2 +- internal/commands/desktop/dbms/list_test.go | 5 +- internal/commands/desktop/dbms/load.go | 4 +- internal/commands/desktop/dbms/load_test.go | 2 +- .../commands/desktop/dbms/plugin/available.go | 2 +- .../desktop/dbms/plugin/available_test.go | 3 +- .../commands/desktop/dbms/plugin/helpers.go | 35 -- .../commands/desktop/dbms/plugin/install.go | 2 +- .../desktop/dbms/plugin/install_test.go | 4 +- internal/commands/desktop/dbms/plugin/list.go | 2 +- .../commands/desktop/dbms/plugin/list_test.go | 5 +- .../commands/desktop/dbms/plugin/uninstall.go | 2 +- .../desktop/dbms/plugin/uninstall_test.go | 2 +- internal/commands/desktop/dbms/start.go | 2 +- internal/commands/desktop/dbms/start_test.go | 8 +- internal/commands/desktop/dbms/stop.go | 2 +- internal/commands/desktop/dbms/stop_test.go | 6 +- internal/commands/desktop/dbms/update.go | 150 +++++ internal/commands/desktop/dbms/update_test.go | 301 +++++++++ internal/commands/desktop/dbms/upgrade.go | 2 +- .../commands/desktop/dbms/upgrade_test.go | 4 +- internal/commands/desktop/debug_test.go | 4 +- internal/commands/desktop/desktop.go | 8 +- internal/commands/desktop/helpers.go | 49 -- .../desktop/internal/catalogcmd/color.go | 31 + .../desktop/internal/catalogcmd/create.go | 69 +++ .../internal/catalogcmd/create_test.go | 124 ++++ .../desktop/internal/catalogcmd/delete.go | 80 +++ .../internal/catalogcmd/delete_test.go | 158 +++++ .../internal/catalogcmd/helpers_test.go | 239 ++++++++ .../desktop/internal/catalogcmd/list.go | 50 ++ .../desktop/internal/catalogcmd/list_test.go | 152 +++++ .../desktop/internal/catalogcmd/render.go | 100 +++ .../desktop/internal/catalogcmd/spec.go | 107 ++++ .../desktop/internal/catalogcmd/spec_test.go | 124 ++++ .../desktop/internal/catalogcmd/update.go | 79 +++ .../internal/catalogcmd/update_test.go | 133 ++++ internal/commands/desktop/list.go | 2 +- internal/commands/desktop/list_test.go | 29 +- .../commands/desktop/project/create_test.go | 164 +++++ .../commands/desktop/project/delete_test.go | 252 ++++++++ .../commands/desktop/project/helpers_test.go | 148 +++++ .../commands/desktop/project/list_test.go | 134 ++++ internal/commands/desktop/project/output.go | 34 ++ internal/commands/desktop/project/project.go | 112 ++++ .../commands/desktop/project/update_test.go | 190 ++++++ internal/commands/desktop/tag/create_test.go | 232 +++++++ internal/commands/desktop/tag/delete_test.go | 262 ++++++++ internal/commands/desktop/tag/helpers_test.go | 89 +++ internal/commands/desktop/tag/list_test.go | 128 ++++ internal/commands/desktop/tag/tag.go | 124 ++++ internal/commands/desktop/tag/update_test.go | 181 ++++++ .../mcp/server/testdata/policy.golden | 11 + internal/desktopclient/client.go | 36 ++ internal/desktopclient/connect.go | 55 ++ internal/desktopclient/connect_test.go | 150 +++++ internal/desktopclient/finders.go | 104 ++++ internal/desktopclient/finders_test.go | 244 ++++++++ internal/desktopclient/output.go | 4 +- internal/desktopclient/projects_tags.go | 226 +++++++ internal/desktopclient/projects_tags_test.go | 574 ++++++++++++++++++ internal/desktopclient/types.go | 61 +- .../neo4jcli/bundle/references/desktop.md | 263 +++++++- test/e2e/desktop_fixture/scenario.go | 4 +- test/e2e/desktop_fixture/state.go | 10 +- 80 files changed, 5931 insertions(+), 261 deletions(-) create mode 100644 .changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml create mode 100644 internal/commands/desktop/dbms/update.go create mode 100644 internal/commands/desktop/dbms/update_test.go delete mode 100644 internal/commands/desktop/helpers.go create mode 100644 internal/commands/desktop/internal/catalogcmd/color.go create mode 100644 internal/commands/desktop/internal/catalogcmd/create.go create mode 100644 internal/commands/desktop/internal/catalogcmd/create_test.go create mode 100644 internal/commands/desktop/internal/catalogcmd/delete.go create mode 100644 internal/commands/desktop/internal/catalogcmd/delete_test.go create mode 100644 internal/commands/desktop/internal/catalogcmd/helpers_test.go create mode 100644 internal/commands/desktop/internal/catalogcmd/list.go create mode 100644 internal/commands/desktop/internal/catalogcmd/list_test.go create mode 100644 internal/commands/desktop/internal/catalogcmd/render.go create mode 100644 internal/commands/desktop/internal/catalogcmd/spec.go create mode 100644 internal/commands/desktop/internal/catalogcmd/spec_test.go create mode 100644 internal/commands/desktop/internal/catalogcmd/update.go create mode 100644 internal/commands/desktop/internal/catalogcmd/update_test.go create mode 100644 internal/commands/desktop/project/create_test.go create mode 100644 internal/commands/desktop/project/delete_test.go create mode 100644 internal/commands/desktop/project/helpers_test.go create mode 100644 internal/commands/desktop/project/list_test.go create mode 100644 internal/commands/desktop/project/output.go create mode 100644 internal/commands/desktop/project/project.go create mode 100644 internal/commands/desktop/project/update_test.go create mode 100644 internal/commands/desktop/tag/create_test.go create mode 100644 internal/commands/desktop/tag/delete_test.go create mode 100644 internal/commands/desktop/tag/helpers_test.go create mode 100644 internal/commands/desktop/tag/list_test.go create mode 100644 internal/commands/desktop/tag/tag.go create mode 100644 internal/commands/desktop/tag/update_test.go create mode 100644 internal/desktopclient/connect.go create mode 100644 internal/desktopclient/connect_test.go create mode 100644 internal/desktopclient/finders.go create mode 100644 internal/desktopclient/finders_test.go create mode 100644 internal/desktopclient/projects_tags.go create mode 100644 internal/desktopclient/projects_tags_test.go diff --git a/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml b/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml new file mode 100644 index 000000000..12fe6a93c --- /dev/null +++ b/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml @@ -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.' +time: 2026-10-05T16:08:45.005645+02:00 diff --git a/internal/commands/desktop/connection/create.go b/internal/commands/desktop/connection/create.go index e0c185144..124ae849a 100644 --- a/internal/commands/desktop/connection/create.go +++ b/internal/commands/desktop/connection/create.go @@ -4,9 +4,7 @@ package connection import ( - "context" "encoding/json" - "errors" "fmt" "os" @@ -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" ) @@ -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())) } @@ -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 } diff --git a/internal/commands/desktop/connection/create_test.go b/internal/commands/desktop/connection/create_test.go index 123f05f30..153a38398 100644 --- a/internal/commands/desktop/connection/create_test.go +++ b/internal/commands/desktop/connection/create_test.go @@ -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. @@ -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 @@ -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) @@ -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() })) @@ -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() })) diff --git a/internal/commands/desktop/connection/delete.go b/internal/commands/desktop/connection/delete.go index a10baf46a..4b9c82bb2 100644 --- a/internal/commands/desktop/connection/delete.go +++ b/internal/commands/desktop/connection/delete.go @@ -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" ) @@ -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 } diff --git a/internal/commands/desktop/connection/delete_test.go b/internal/commands/desktop/connection/delete_test.go index 26955dd0d..b08532718 100644 --- a/internal/commands/desktop/connection/delete_test.go +++ b/internal/commands/desktop/connection/delete_test.go @@ -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" @@ -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 @@ -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 })) @@ -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() })) @@ -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() })) diff --git a/internal/commands/desktop/connection/list.go b/internal/commands/desktop/connection/list.go index dac259e04..0341b6af3 100644 --- a/internal/commands/desktop/connection/list.go +++ b/internal/commands/desktop/connection/list.go @@ -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 } diff --git a/internal/commands/desktop/connection/list_test.go b/internal/commands/desktop/connection/list_test.go index 23f2cec0e..f99677b81 100644 --- a/internal/commands/desktop/connection/list_test.go +++ b/internal/commands/desktop/connection/list_test.go @@ -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" @@ -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 @@ -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") })) diff --git a/internal/commands/desktop/connection/update.go b/internal/commands/desktop/connection/update.go index 18ef13e00..b6b266f5b 100644 --- a/internal/commands/desktop/connection/update.go +++ b/internal/commands/desktop/connection/update.go @@ -25,6 +25,8 @@ func newUpdateCmd(cfg *clicfg.Config) *cobra.Command { username string password string description string + projects []string + tags []string ) const ( @@ -33,6 +35,8 @@ func newUpdateCmd(cfg *clicfg.Config) *cobra.Command { usernameFlag = "username" passwordFlag = "password" descriptionFlag = "description" + projectFlag = "project" + tagsFlag = "tags" ) cmd := &cobra.Command{ @@ -40,8 +44,9 @@ func newUpdateCmd(cfg *clicfg.Config) *cobra.Command { 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:/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 @@ -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 { @@ -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() @@ -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 } @@ -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 { @@ -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 } diff --git a/internal/commands/desktop/connection/update_test.go b/internal/commands/desktop/connection/update_test.go index 3fc8cf938..481dd5a2a 100644 --- a/internal/commands/desktop/connection/update_test.go +++ b/internal/commands/desktop/connection/update_test.go @@ -61,7 +61,7 @@ func (h *updateHelper) 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 @@ -89,7 +89,7 @@ func TestUpdate_RejectsNonUUID(t *testing.T) { h := newUpdateHelper(t) // Pin the client constructor to a noisy stub so a stray HTTP call would // fail the test instead of silently succeeding against a real probe. - 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 })) @@ -111,7 +111,7 @@ func TestUpdate_RejectsNonUUID(t *testing.T) { // HTTP call. func TestUpdate_NoMutatingFlags_Errors(t *testing.T) { h := newUpdateHelper(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 no mutating flags supplied") return nil, nil })) @@ -261,7 +261,7 @@ func TestUpdate_EmptyPassword_PromptsOnTTY(t *testing.T) { func TestUpdate_EmptyPassword_NonTTYErrors(t *testing.T) { h := newUpdateHelper(t) t.Cleanup(connection.SetStdinIsTTYFnForTest(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) { t.Fatalf("must not construct client when --password is empty on non-TTY") return nil, nil })) @@ -281,7 +281,7 @@ func TestUpdate_SuccessRendersConnection(t *testing.T) { h := newUpdateHelper(t) h.withHandler(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPatch { - _, _ = w.Write([]byte(`{"id":"` + validUpdateID + `","name":"renamed","connectionUri":"neo4j+s://abc","project":"proj-1"}`)) + _, _ = w.Write([]byte(`{"id":"` + validUpdateID + `","name":"renamed","connectionUri":"neo4j+s://abc","projects":["proj-1"]}`)) return } t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) @@ -302,6 +302,140 @@ func TestUpdate_SuccessRendersConnection(t *testing.T) { } } +// TestUpdate_ProjectTags_FlagToPatchBody covers the flag→PATCH-body mapping: +// --project/--tags elements resolve against the Desktop catalogs (names → +// IDs, UUIDs verbatim) and the resolved arrays land in the body under +// `projects`/`tags`. +func TestUpdate_ProjectTags_FlagToPatchBody(t *testing.T) { + h := newUpdateHelper(t) + const ( + prodProjectID = "11111111-2222-3333-4444-555555555555" + tagID = "66666666-7777-8888-9999-aaaaaaaaaaaa" + ) + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"` + prodProjectID + `","name":"Prod"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + _, _ = w.Write([]byte(`{"tags":[{"id":"` + tagID + `","name":"backend"}]}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/connections/"+validUpdateID: + capturedBody = readPostBody(t, r) + _, _ = w.Write([]byte(`{"id":"` + validUpdateID + `","name":"n"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`connection update ` + validUpdateID + ` --project Prod --tags backend --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if len(capturedBody) != 2 { + t.Fatalf("expected PATCH body to contain exactly projects+tags; got %d keys: %v", len(capturedBody), capturedBody) + } + projects, ok := capturedBody["projects"].([]any) + if !ok || len(projects) != 1 || projects[0] != prodProjectID { + t.Fatalf("expected projects=[%s] in body, got %v", prodProjectID, capturedBody["projects"]) + } + tags, ok := capturedBody["tags"].([]any) + if !ok || len(tags) != 1 || tags[0] != tagID { + t.Fatalf("expected tags=[%s] in body, got %v", tagID, capturedBody["tags"]) + } +} + +// TestUpdate_ProjectTags_UUIDPassthrough covers the no-catalog-fetch path: +// all-UUID --project/--tags input must reach the PATCH body verbatim without +// any GET against the project/tag catalogs. +func TestUpdate_ProjectTags_UUIDPassthrough(t *testing.T) { + h := newUpdateHelper(t) + const ( + projectID = "11111111-2222-3333-4444-555555555555" + tagID = "66666666-7777-8888-9999-aaaaaaaaaaaa" + ) + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + t.Errorf("unexpected catalog fetch %s %s — all-UUID input must not fetch catalogs", r.Method, r.URL.Path) + return + } + if r.Method == http.MethodPatch { + capturedBody = readPostBody(t, r) + _, _ = w.Write([]byte(`{"id":"` + validUpdateID + `","name":"n"}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + if err := h.run(`connection update ` + validUpdateID + ` --project ` + projectID + ` --tags ` + tagID + ` --format json`); err != nil { + t.Fatalf("run: %v", err) + } + projects, ok := capturedBody["projects"].([]any) + if !ok || len(projects) != 1 || projects[0] != projectID { + t.Fatalf("expected projects=[%s] in body, got %v", projectID, capturedBody["projects"]) + } + tags, ok := capturedBody["tags"].([]any) + if !ok || len(tags) != 1 || tags[0] != tagID { + t.Fatalf("expected tags=[%s] in body, got %v", tagID, capturedBody["tags"]) + } +} + +// TestUpdate_ProjectTags_EmptyValueClears covers the clearing form: +// `--tags ""` resolves to a non-nil empty slice which must serialize as [] +// so Desktop clears the connection's tag assignments. +func TestUpdate_ProjectTags_EmptyValueClears(t *testing.T) { + h := newUpdateHelper(t) + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + capturedBody = readPostBody(t, r) + _, _ = w.Write([]byte(`{"id":"` + validUpdateID + `","name":"n"}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + if err := h.run(`connection update ` + validUpdateID + ` --tags "" --format json`); err != nil { + t.Fatalf("run: %v", err) + } + tags, has := capturedBody["tags"].([]any) + if !has { + t.Fatalf("expected tags key to be present when --tags \"\" supplied; got %v", capturedBody) + } + if len(tags) != 0 { + t.Fatalf("expected tags=[] in body, got %v", capturedBody["tags"]) + } +} + +// TestUpdate_ProjectTags_ResolutionFailure covers the resolver failure path: +// an unknown project name must surface as a usage error BEFORE the PATCH is +// sent. +func TestUpdate_ProjectTags_ResolutionFailure(t *testing.T) { + h := newUpdateHelper(t) + patchSent := false + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"11111111-2222-3333-4444-555555555555","name":"Prod"}]}`)) + case r.Method == http.MethodPatch: + patchSent = true + _, _ = w.Write([]byte(`{"id":"` + validUpdateID + `","name":"n"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + err := h.run(`connection update ` + validUpdateID + ` --project Nope --format json`) + if err == nil { + t.Fatalf("expected usage error for unknown project name") + } + if !strings.Contains(err.Error(), `unknown project "Nope"`) { + t.Fatalf("expected 'unknown project' in error, got: %v", err) + } + if patchSent { + t.Fatalf("PATCH must not be sent when project resolution fails") + } +} + // TestUpdate_Annotated_Write covers the cobra annotation: the update leaf // must be tagged write=true so the root --rw enforcement fires. func TestUpdate_Annotated_Write(t *testing.T) { diff --git a/internal/commands/desktop/dbms/create.go b/internal/commands/desktop/dbms/create.go index dad929d2f..f950eb422 100644 --- a/internal/commands/desktop/dbms/create.go +++ b/internal/commands/desktop/dbms/create.go @@ -184,7 +184,7 @@ neo4j-cli desktop dbms create --name my-dbms --version 5.21.0 --password superse fs := cfg.Fs() port, _ := cmd.Flags().GetInt(portFlag) - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/create_test.go b/internal/commands/desktop/dbms/create_test.go index aac0b27a8..a65a37e17 100644 --- a/internal/commands/desktop/dbms/create_test.go +++ b/internal/commands/desktop/dbms/create_test.go @@ -29,7 +29,7 @@ import ( ) // createHelper wires dbms.NewCmd against an in-memory FS, with the shared -// `newDesktopClientFn` seam pinned to a desktopclient.Client backed by an +// `desktopclient.Connect` seam pinned to a desktopclient.Client backed by an // httptest server. End-to-end: cobra flag parse → leaf RunE → desktopclient → // httptest handler. Poll sleeps are pinned to no-op so the 30s timeout doesn't // burn wall-clock time on the failure paths. @@ -68,7 +68,7 @@ func newCreateHelper(t *testing.T) *createHelper { } } -// withHandler swaps the `newDesktopClientFn` seam to a closure that returns +// withHandler swaps the `desktopclient.Connect` seam to a closure that returns // a desktopclient.Client wired to the supplied httptest handler. The handler // receives every request the leaf sends. func (h *createHelper) withHandler(handler http.HandlerFunc) *httptest.Server { @@ -82,7 +82,7 @@ func (h *createHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(dbms.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 @@ -439,7 +439,7 @@ func TestCreate_NoEditionFlag(t *testing.T) { func TestCreate_PortFlagPropagatesToClientConstructor(t *testing.T) { h := newCreateHelper(t) var gotPort int - t.Cleanup(dbms.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, errors.New("stop here; we already captured the port") })) @@ -452,7 +452,7 @@ func TestCreate_PortFlagPropagatesToClientConstructor(t *testing.T) { func TestCreate_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) { h := newCreateHelper(t) - t.Cleanup(dbms.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() })) diff --git a/internal/commands/desktop/dbms/dbms.go b/internal/commands/desktop/dbms/dbms.go index 707e8688c..404b5b614 100644 --- a/internal/commands/desktop/dbms/dbms.go +++ b/internal/commands/desktop/dbms/dbms.go @@ -21,8 +21,8 @@ func NewCmd(cfg *clicfg.Config) *cobra.Command { cmd := &cobra.Command{ Use: "dbms", Short: "Manage local DBMSes under a Neo4j Desktop 2 install", - Long: "Manage local Neo4j DBMSes running under a Neo4j Desktop 2 install — list, create, delete, start, stop, upgrade. " + - "Write commands (`create`, `delete`, `start`, `stop`, `upgrade`) require `--rw`. " + + Long: "Manage local Neo4j DBMSes running under a Neo4j Desktop 2 install — list, create, delete, start, stop, upgrade, update. " + + "Write commands (`create`, `delete`, `start`, `stop`, `upgrade`, `update`) require `--rw`. " + "For a composed view of DBMSes plus saved remote connections see `neo4j-cli desktop list`.", } @@ -35,6 +35,7 @@ func NewCmd(cfg *clicfg.Config) *cobra.Command { cmd.AddCommand(newStartCmd(cfg)) cmd.AddCommand(newStopCmd(cfg)) cmd.AddCommand(newUpgradeCmd(cfg)) + cmd.AddCommand(newUpdateCmd(cfg)) cmd.AddCommand(plugin.NewCmd(cfg)) return cmd diff --git a/internal/commands/desktop/dbms/delete.go b/internal/commands/desktop/dbms/delete.go index cd7c6e488..29bc520a2 100644 --- a/internal/commands/desktop/dbms/delete.go +++ b/internal/commands/desktop/dbms/delete.go @@ -9,6 +9,7 @@ import ( "github.com/neo4j/cli/internal/clicfg" "github.com/neo4j/cli/internal/confirm" + "github.com/neo4j/cli/internal/desktopclient" "github.com/neo4j/cli/internal/output" "github.com/spf13/cobra" ) @@ -39,7 +40,7 @@ neo4j-cli desktop dbms delete my-dbms-id --yes --force --format json --rw`, port, _ := cmd.Flags().GetInt(portFlag) id := args[0] - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/delete_test.go b/internal/commands/desktop/dbms/delete_test.go index f7c4bebbf..22a98d883 100644 --- a/internal/commands/desktop/dbms/delete_test.go +++ b/internal/commands/desktop/dbms/delete_test.go @@ -66,7 +66,7 @@ func (h *deleteHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(dbms.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 @@ -506,7 +506,7 @@ func TestDelete_PortFlagPropagatesToClientConstructor(t *testing.T) { t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) var gotPort int - t.Cleanup(dbms.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() })) @@ -520,7 +520,7 @@ func TestDelete_PortFlagPropagatesToClientConstructor(t *testing.T) { func TestDelete_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) { h := newDeleteHelper(t) t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) - t.Cleanup(dbms.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() })) diff --git a/internal/commands/desktop/dbms/helpers.go b/internal/commands/desktop/dbms/helpers.go index a021b203c..3f2c3f74b 100644 --- a/internal/commands/desktop/dbms/helpers.go +++ b/internal/commands/desktop/dbms/helpers.go @@ -6,7 +6,6 @@ package dbms import ( "bytes" "context" - "errors" "fmt" "io" "sync" @@ -14,46 +13,9 @@ import ( "github.com/neo4j/cli/internal/clierr" "github.com/neo4j/cli/internal/desktopclient" - "github.com/spf13/afero" "golang.org/x/sync/errgroup" ) -// newDesktopClient probes the port range, resolves the data dir, loads the -// salt, and signs the JWT. A probe miss or missing/unreadable salt (Desktop -// hasn't finished first-run auth setup) both surface as the canonical -// "Desktop unreachable" hint. -var newDesktopClientFn = newDesktopClient - -func newDesktopClient(ctx context.Context, fs afero.Fs, port int) (*desktopclient.Client, error) { - // Discover runs first so its origin can feed ResolveDataDir's /info/app - // discovery step. - 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 hasn't finished first-run auth - // setup; route through the same "unreachable" hint as a probe miss. - return nil, desktopclient.UnreachableError() - } - return desktopclient.NewClient(probe, salt) -} - -// SetNewDesktopClientFnForTest overrides the shared client constructor for tests. -func SetNewDesktopClientFnForTest(fn func(context.Context, afero.Fs, int) (*desktopclient.Client, error)) func() { - prev := newDesktopClientFn - newDesktopClientFn = fn - return func() { newDesktopClientFn = prev } -} - type statusPoller interface { GetDbms(ctx context.Context, id string) (*desktopclient.DbmsInfo, error) } diff --git a/internal/commands/desktop/dbms/list.go b/internal/commands/desktop/dbms/list.go index 7b7842691..d0cb181fd 100644 --- a/internal/commands/desktop/dbms/list.go +++ b/internal/commands/desktop/dbms/list.go @@ -53,7 +53,7 @@ neo4j-cli desktop dbms list --port 44225`, fs := cfg.Fs() port, _ := cmd.Flags().GetInt(portFlag) - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/list_test.go b/internal/commands/desktop/dbms/list_test.go index 08e3d3e72..b523ab761 100644 --- a/internal/commands/desktop/dbms/list_test.go +++ b/internal/commands/desktop/dbms/list_test.go @@ -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/dbms" "github.com/neo4j/cli/internal/desktopclient" "github.com/neo4j/cli/internal/flags" "github.com/neo4j/cli/internal/testutil/testfs" @@ -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(dbms.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 @@ -172,7 +171,7 @@ func TestDbmsList_5xx_SurfacesError(t *testing.T) { func TestDbmsList_PortFlagPropagatesToClientConstructor(t *testing.T) { h := newListHelper(t) var seenPort int - t.Cleanup(dbms.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") })) diff --git a/internal/commands/desktop/dbms/load.go b/internal/commands/desktop/dbms/load.go index a3bad8af6..42f99fb0e 100644 --- a/internal/commands/desktop/dbms/load.go +++ b/internal/commands/desktop/dbms/load.go @@ -126,7 +126,7 @@ neo4j-cli desktop dbms load neo4j-graph-examples/recommendations --name recs --p ) } cmd.SilenceUsage = true - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } @@ -141,7 +141,7 @@ neo4j-cli desktop dbms load neo4j-graph-examples/recommendations --name recs --p password = pw } cmd.SilenceUsage = true - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/load_test.go b/internal/commands/desktop/dbms/load_test.go index c0cc45e21..f0b9907ec 100644 --- a/internal/commands/desktop/dbms/load_test.go +++ b/internal/commands/desktop/dbms/load_test.go @@ -66,7 +66,7 @@ func (h *loadHelper) withHandler(handler http.HandlerFunc) *httptest.Server { h.t.Cleanup(desktopclient.SetNowFnForTest(func() time.Time { return time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC) })) srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(dbms.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 diff --git a/internal/commands/desktop/dbms/plugin/available.go b/internal/commands/desktop/dbms/plugin/available.go index d12f95756..9321e9ed6 100644 --- a/internal/commands/desktop/dbms/plugin/available.go +++ b/internal/commands/desktop/dbms/plugin/available.go @@ -37,7 +37,7 @@ neo4j-cli desktop dbms plugin available my-dbms-id --port 44225`, port, _ := cmd.Flags().GetInt(portFlag) dbmsID := args[0] - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/plugin/available_test.go b/internal/commands/desktop/dbms/plugin/available_test.go index b52c8d0e5..f94835cc1 100644 --- a/internal/commands/desktop/dbms/plugin/available_test.go +++ b/internal/commands/desktop/dbms/plugin/available_test.go @@ -16,7 +16,6 @@ import ( "github.com/google/shlex" "github.com/neo4j/cli/internal/clicfg" "github.com/neo4j/cli/internal/commands/desktop/dbms" - "github.com/neo4j/cli/internal/commands/desktop/dbms/plugin" "github.com/neo4j/cli/internal/desktopclient" "github.com/neo4j/cli/internal/flags" "github.com/neo4j/cli/internal/testutil/testfs" @@ -61,7 +60,7 @@ func (h *availableHelper) withHandler(handler http.HandlerFunc) *httptest.Server srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(plugin.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 diff --git a/internal/commands/desktop/dbms/plugin/helpers.go b/internal/commands/desktop/dbms/plugin/helpers.go index 372351b35..3426b187c 100644 --- a/internal/commands/desktop/dbms/plugin/helpers.go +++ b/internal/commands/desktop/dbms/plugin/helpers.go @@ -5,49 +5,14 @@ package plugin import ( "context" - "errors" "fmt" "io" "time" "github.com/neo4j/cli/internal/clierr" "github.com/neo4j/cli/internal/desktopclient" - "github.com/spf13/afero" ) -// newDesktopClientFn is the per-subtree test seam. Each leaf-subtree under `desktop` -// keeps its own so a test pinning one can't accidentally override another's. -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 newDesktopClientFn for a test; 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 } -} - // Mirrors the live Status strings Desktop reports — do NOT reintroduce // `online`/`offline` aliases. Duplicated here because importing the sibling // `dbms` package would create a cycle (it mounts `plugin.NewCmd`). diff --git a/internal/commands/desktop/dbms/plugin/install.go b/internal/commands/desktop/dbms/plugin/install.go index 3749a2d87..9bb8ba1ce 100644 --- a/internal/commands/desktop/dbms/plugin/install.go +++ b/internal/commands/desktop/dbms/plugin/install.go @@ -84,7 +84,7 @@ neo4j-cli desktop dbms plugin install my-dbms-id --plugin apoc --no-restart --rw return clierr.NewUsageError("--%s is required", pluginFlag) } - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/plugin/install_test.go b/internal/commands/desktop/dbms/plugin/install_test.go index aa841b19d..e458e7436 100644 --- a/internal/commands/desktop/dbms/plugin/install_test.go +++ b/internal/commands/desktop/dbms/plugin/install_test.go @@ -27,7 +27,7 @@ import ( ) // installHelper wires `dbms.NewCmd` (which mounts the plugin subtree) against -// an in-memory FS, with the plugin package's `newDesktopClientFn` seam pinned +// an in-memory FS, with the plugin package's `desktopclient.Connect` seam pinned // to a desktopclient.Client backed by an httptest server. Mirrors the // listHelper / availableHelper shape — each leaf-subtree owns its own test // surface so the seams stay isolated and re-runs don't pollute each other. @@ -68,7 +68,7 @@ func (h *installHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(plugin.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 diff --git a/internal/commands/desktop/dbms/plugin/list.go b/internal/commands/desktop/dbms/plugin/list.go index ae2abc1c4..14ee26da4 100644 --- a/internal/commands/desktop/dbms/plugin/list.go +++ b/internal/commands/desktop/dbms/plugin/list.go @@ -58,7 +58,7 @@ neo4j-cli desktop dbms plugin list my-dbms-id --port 44225`, port, _ := cmd.Flags().GetInt(portFlag) dbmsID := args[0] - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/plugin/list_test.go b/internal/commands/desktop/dbms/plugin/list_test.go index 774efc99c..6b9c4dd73 100644 --- a/internal/commands/desktop/dbms/plugin/list_test.go +++ b/internal/commands/desktop/dbms/plugin/list_test.go @@ -16,7 +16,6 @@ import ( "github.com/google/shlex" "github.com/neo4j/cli/internal/clicfg" "github.com/neo4j/cli/internal/commands/desktop/dbms" - "github.com/neo4j/cli/internal/commands/desktop/dbms/plugin" "github.com/neo4j/cli/internal/desktopclient" "github.com/neo4j/cli/internal/flags" "github.com/neo4j/cli/internal/testutil/testfs" @@ -25,7 +24,7 @@ import ( ) // listHelper wires dbms.NewCmd (which mounts the plugin subtree) against an -// in-memory FS, with the plugin package's `newDesktopClientFn` seam pinned to +// in-memory FS, with the plugin package's `desktopclient.Connect` seam pinned to // a desktopclient.Client backed by an httptest server. Mirrors the // startHelper / connectionListHelper shape — each leaf-subtree owns its own // test surface so the seams stay isolated. @@ -62,7 +61,7 @@ func (h *listHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(plugin.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 diff --git a/internal/commands/desktop/dbms/plugin/uninstall.go b/internal/commands/desktop/dbms/plugin/uninstall.go index 5edda2865..1cd21342c 100644 --- a/internal/commands/desktop/dbms/plugin/uninstall.go +++ b/internal/commands/desktop/dbms/plugin/uninstall.go @@ -57,7 +57,7 @@ neo4j-cli desktop dbms plugin uninstall my-dbms-id --plugin apoc --format json - return clierr.NewUsageError("--%s is required", pluginFlag) } - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/plugin/uninstall_test.go b/internal/commands/desktop/dbms/plugin/uninstall_test.go index b71dbe952..429220cfb 100644 --- a/internal/commands/desktop/dbms/plugin/uninstall_test.go +++ b/internal/commands/desktop/dbms/plugin/uninstall_test.go @@ -65,7 +65,7 @@ func (h *uninstallHelper) withHandler(handler http.HandlerFunc) *httptest.Server srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(plugin.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 diff --git a/internal/commands/desktop/dbms/start.go b/internal/commands/desktop/dbms/start.go index de9bbf655..613430e68 100644 --- a/internal/commands/desktop/dbms/start.go +++ b/internal/commands/desktop/dbms/start.go @@ -85,7 +85,7 @@ neo4j-cli desktop dbms start my-dbms-id --wait --format json --rw`, port, _ := cmd.Flags().GetInt(portFlag) id := args[0] - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/start_test.go b/internal/commands/desktop/dbms/start_test.go index 749417c6a..19ba11115 100644 --- a/internal/commands/desktop/dbms/start_test.go +++ b/internal/commands/desktop/dbms/start_test.go @@ -28,7 +28,7 @@ import ( ) // startHelper wires dbms.NewCmd against an in-memory FS, with the shared -// `newDesktopClientFn` seam pinned to a desktopclient.Client backed by an +// `desktopclient.Connect` seam pinned to a desktopclient.Client backed by an // httptest server. Same shape as createHelper; kept colocated with the // start leaf so each leaf's test surface stays self-contained. type startHelper struct { @@ -66,7 +66,7 @@ func (h *startHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(dbms.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 @@ -338,7 +338,7 @@ func TestStart_Annotated_Write(t *testing.T) { func TestStart_PortFlagPropagatesToClientConstructor(t *testing.T) { h := newStartHelper(t) var gotPort int - t.Cleanup(dbms.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, errors.New("stop here; we already captured the port") })) @@ -645,7 +645,7 @@ func TestStart_Force_StopPollTimeoutAbortsStart(t *testing.T) { func TestStart_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) { h := newStartHelper(t) - t.Cleanup(dbms.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() })) diff --git a/internal/commands/desktop/dbms/stop.go b/internal/commands/desktop/dbms/stop.go index 10739d3ec..c6ebd6dd2 100644 --- a/internal/commands/desktop/dbms/stop.go +++ b/internal/commands/desktop/dbms/stop.go @@ -75,7 +75,7 @@ neo4j-cli desktop dbms stop my-dbms-id --wait --format json --rw`, port, _ := cmd.Flags().GetInt(portFlag) id := args[0] - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/stop_test.go b/internal/commands/desktop/dbms/stop_test.go index 6bb05f248..b0e051adc 100644 --- a/internal/commands/desktop/dbms/stop_test.go +++ b/internal/commands/desktop/dbms/stop_test.go @@ -64,7 +64,7 @@ func (h *stopHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(dbms.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 @@ -309,7 +309,7 @@ func TestStop_Annotated_Write(t *testing.T) { func TestStop_PortFlagPropagatesToClientConstructor(t *testing.T) { h := newStopHelper(t) var gotPort int - t.Cleanup(dbms.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, errors.New("stop here; we already captured the port") })) @@ -322,7 +322,7 @@ func TestStop_PortFlagPropagatesToClientConstructor(t *testing.T) { func TestStop_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) { h := newStopHelper(t) - t.Cleanup(dbms.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() })) diff --git a/internal/commands/desktop/dbms/update.go b/internal/commands/desktop/dbms/update.go new file mode 100644 index 000000000..95d8a154d --- /dev/null +++ b/internal/commands/desktop/dbms/update.go @@ -0,0 +1,150 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package dbms + +import ( + "encoding/json" + + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/clierr" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/neo4j/cli/internal/output" + "github.com/spf13/cobra" +) + +var dbmsUpdateFields = []string{"id", "name", "version", "status", "connection_uri", "projects", "tags"} + +// dbmsUpdateResult adapts the updated `*DbmsInfo` to output.ResponseData. +type dbmsUpdateResult struct { + Item *desktopclient.DbmsInfo +} + +func (r dbmsUpdateResult) AsArray() []map[string]any { + if r.Item == nil { + return nil + } + return []map[string]any{ + { + "id": r.Item.ID, + "name": r.Item.Name, + "version": r.Item.Version, + "status": r.Item.Status, + "connection_uri": r.Item.ConnectionURI, + "projects": r.Item.Projects, + "tags": r.Item.Tags, + }, + } +} + +// MarshalJSON emits the snake_case DbmsInfo projection so `--format json` matches `desktop dbms list`. +func (r dbmsUpdateResult) MarshalJSON() ([]byte, error) { + if r.Item == nil { + return []byte("null"), nil + } + return json.Marshal(r.Item.ToOutput()) +} + +// newUpdateCmd builds the `desktop dbms update ` leaf. At least one +// mutating flag must be supplied (an empty PATCH would surface as a confusing +// no-op). `--project`/`--tags` have REPLACE semantics — the supplied values +// become the entire set — and each value is resolved against Desktop's +// project/tag catalogs (a UUID passes through verbatim) BEFORE the PATCH. +func newUpdateCmd(cfg *clicfg.Config) *cobra.Command { + var ( + name string + description string + projects []string + tags []string + ) + + const ( + nameFlag = "name" + descriptionFlag = "description" + projectFlag = "project" + tagsFlag = "tags" + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a DBMS managed by the local Neo4j Desktop 2 install", + Long: "Update a DBMS managed by the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "At least one of `--name --description --project --tags` must be supplied; the PATCH body contains ONLY the keys you set, so empty-string is a legitimate update for `--description`. " + + "`--project` and `--tags` have REPLACE semantics: the values you pass become the entire set (re-run with the full list when adding one); pass an empty value (`--tags \"\"`) to clear the set. " + + "Each value is a Desktop catalog name or ID — names are resolved against Desktop's project/tag catalogs before the PATCH, a UUID passes through verbatim. " + + "Find DBMS ids with `neo4j-cli desktop list`.", + Example: `# Rename a DBMS +neo4j-cli desktop dbms update my-dbms-id --name my-renamed-dbms --rw + +# Replace the tag set (names resolve against Desktop's tag catalog) +neo4j-cli desktop dbms update my-dbms-id --tags prod,eu --rw + +# Move a DBMS into a project and emit the updated DbmsInfo as JSON +neo4j-cli desktop dbms update my-dbms-id --project "Customer 360" --format json --rw + +# Clear all tags from a DBMS +neo4j-cli desktop dbms update my-dbms-id --tags "" --rw`, + Annotations: map[string]string{"write": "true"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + id := args[0] + nameSet := cmd.Flag(nameFlag).Changed + descriptionSet := cmd.Flag(descriptionFlag).Changed + projectSet := cmd.Flag(projectFlag).Changed + tagsSet := cmd.Flag(tagsFlag).Changed + if !nameSet && !descriptionSet && !projectSet && !tagsSet { + return clierr.NewUsageError( + "at least one of --name, --description, --project, --tags must be supplied") + } + + ctx := cmd.Context() + fs := cfg.Fs() + port, _ := cmd.Flags().GetInt(portFlag) + + client, err := desktopclient.Connect(ctx, fs, port) + if err != nil { + return err + } + + updateArgs := desktopclient.DbmsUpdateArgs{} + if nameSet { + updateArgs.Name = &name + } + if descriptionSet { + updateArgs.Description = &description + } + if projectSet { + ids, err := client.ResolveProjectIDs(ctx, desktopclient.NonEmptyStrings(projects)) + if err != nil { + return err + } + updateArgs.Projects = &ids + } + if tagsSet { + ids, err := client.ResolveTagIDs(ctx, desktopclient.NonEmptyStrings(tags)) + if err != nil { + return err + } + updateArgs.Tags = &ids + } + + updated, err := client.UpdateDbms(ctx, id, updateArgs) + if err != nil { + return err + } + + output.PrintBodyMap(cmd, cfg, dbmsUpdateResult{Item: updated}, dbmsUpdateFields) + return nil + }, + } + + cmd.Flags().StringVar(&name, nameFlag, "", "New human-readable name for the DBMS") + cmd.Flags().StringVar(&description, descriptionFlag, "", "New description for the DBMS. Pass an empty string to clear the existing description") + cmd.Flags().StringSliceVar(&projects, projectFlag, nil, "Replace the DBMS's project set with the given Desktop project names or IDs (comma-separated or repeated). Pass an empty value to clear.") + cmd.Flags().StringSliceVar(&tags, tagsFlag, nil, "Replace the DBMS's tag set with the given Desktop tag names or IDs (comma-separated or repeated). Pass an empty value to clear.") + + return cmd +} diff --git a/internal/commands/desktop/dbms/update_test.go b/internal/commands/desktop/dbms/update_test.go new file mode 100644 index 000000000..66fd8bb75 --- /dev/null +++ b/internal/commands/desktop/dbms/update_test.go @@ -0,0 +1,301 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package dbms_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/shlex" + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/commands/desktop/dbms" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/neo4j/cli/internal/flags" + "github.com/neo4j/cli/internal/testutil/testfs" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// updateHelper mirrors upgradeHelper: dbms.NewCmd wired against an in-memory +// FS, with `desktopclient.Connect` pinned to a desktopclient.Client backed by an +// httptest server. +type updateHelper struct { + t *testing.T + out *bytes.Buffer + err *bytes.Buffer + fs afero.Fs +} + +func newUpdateHelper(t *testing.T) *updateHelper { + t.Helper() + cobra.EnableTraverseRunHooks = true + fs, err := testfs.GetTestFs(`{"format":"json"}`, "{}") + if err != nil { + t.Fatalf("GetTestFs: %v", err) + } + return &updateHelper{ + t: t, + out: &bytes.Buffer{}, + err: &bytes.Buffer{}, + fs: fs, + } +} + +func (h *updateHelper) withHandler(handler http.HandlerFunc) *httptest.Server { + h.t.Helper() + const ( + salt = "salt-update" + clientID = "cid-update" + ) + h.t.Cleanup(desktopclient.SetUUIDFnForTest(func() string { return clientID })) + h.t.Cleanup(desktopclient.SetNowFnForTest(func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) })) + srv := httptest.NewServer(handler) + h.t.Cleanup(srv.Close) + + 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 +} + +func (h *updateHelper) run(command string) error { + h.t.Helper() + args, err := shlex.Split(command) + if err != nil { + h.t.Fatalf("shlex: %v", err) + } + cfg := clicfg.NewConfig(h.fs, "test") + cmd := dbms.NewCmd(cfg) + flags.RegisterOutputFlag(cmd, cfg) + cmd.SetArgs(args) + cmd.SetOut(h.out) + cmd.SetErr(h.err) + return cmd.Execute() +} + +// readUpdateBody decodes the PATCH /dbmss/:id JSON body. +func readUpdateBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("body json: %v (raw: %s)", err, string(b)) + } + return out +} + +func TestUpdate_RequiresID(t *testing.T) { + h := newUpdateHelper(t) + if err := h.run("update"); err == nil { + t.Fatalf("expected error when is missing") + } +} + +// TestUpdate_NoFlags_UsageError asserts the no-flag gate: a usage error is +// returned and NO Desktop round-trip happens (an empty PATCH would be a +// silent no-op server-side). +func TestUpdate_NoFlags_UsageError(t *testing.T) { + h := newUpdateHelper(t) + var anyCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + anyCalls.Add(1) + t.Errorf("no Desktop call expected without update flags; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run("update abc --format json") + if err == nil { + t.Fatalf("expected usage error when no update flag is supplied") + } + if !strings.Contains(err.Error(), "--name") || !strings.Contains(err.Error(), "--tags") { + t.Fatalf("expected error to list the update flags, got: %v", err) + } + if anyCalls.Load() != 0 { + t.Fatalf("expected 0 Desktop calls, got %d", anyCalls.Load()) + } +} + +// TestUpdate_NameAndDescription asserts the PATCH body contains ONLY the +// supplied keys and the rendered JSON carries the full snake_case projection +// (including projects/tags). +func TestUpdate_NameAndDescription(t *testing.T) { + h := newUpdateHelper(t) + var captured map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/dbmss/abc": + captured = readUpdateBody(t, r) + _, _ = w.Write([]byte(`{"id":"abc","name":"renamed","description":"d","version":"5.26.1","status":"stopped","connectionUri":"neo4j://localhost:7687","projects":["p1"],"tags":["t1","t2"]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run("update abc --name renamed --description d --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if captured["name"] != "renamed" || captured["description"] != "d" { + t.Fatalf("expected name+description in body, got %+v", captured) + } + if _, ok := captured["tags"]; ok { + t.Fatalf("tags must NOT be sent when --tags is absent, got %+v", captured) + } + if _, ok := captured["projects"]; ok { + t.Fatalf("projects must NOT be sent when --project is absent, got %+v", captured) + } + + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "abc" || got["name"] != "renamed" || got["connection_uri"] != "neo4j://localhost:7687" { + t.Fatalf("unexpected rendered row: %+v", got) + } + projects, ok := got["projects"].([]any) + if !ok || len(projects) != 1 || projects[0] != "p1" { + t.Fatalf("expected projects=[p1] in rendered row, got %+v", got["projects"]) + } + tags, ok := got["tags"].([]any) + if !ok || len(tags) != 2 || tags[0] != "t1" || tags[1] != "t2" { + t.Fatalf("expected tags=[t1 t2] in rendered row, got %+v", got["tags"]) + } +} + +// TestUpdate_ReplaceSemantics_NameResolution asserts `--project`/`--tags` +// values become the ENTIRE array in the PATCH body, with names resolved +// against Desktop's catalogs (GET /projects, GET /tags) before the PATCH. +func TestUpdate_ReplaceSemantics_NameResolution(t *testing.T) { + h := newUpdateHelper(t) + var ( + captured map[string]any + projectsCalls atomic.Int32 + tagsCalls atomic.Int32 + ) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + projectsCalls.Add(1) + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"Customer 360"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + tagsCalls.Add(1) + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"prod"},{"id":"t2","name":"eu"}]}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/dbmss/abc": + captured = readUpdateBody(t, r) + _, _ = w.Write([]byte(`{"id":"abc","name":"my-dbms","projects":["p1"],"tags":["t1","t2"]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`update abc --project "Customer 360" --tags prod,eu --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if projectsCalls.Load() != 1 || tagsCalls.Load() != 1 { + t.Fatalf("expected 1 GET /projects and 1 GET /tags, got %d/%d", projectsCalls.Load(), tagsCalls.Load()) + } + projects, ok := captured["projects"].([]any) + if !ok || len(projects) != 1 || projects[0] != "p1" { + t.Fatalf("expected body projects=[p1] (name resolved), got %+v", captured["projects"]) + } + tags, ok := captured["tags"].([]any) + if !ok || len(tags) != 2 || tags[0] != "t1" || tags[1] != "t2" { + t.Fatalf("expected body tags=[t1 t2] (names resolved), got %+v", captured["tags"]) + } +} + +// TestUpdate_UUIDPassthrough asserts UUID values skip catalog resolution +// entirely and land verbatim in the PATCH body. +func TestUpdate_UUIDPassthrough(t *testing.T) { + h := newUpdateHelper(t) + const ( + projectUUID = "f4e2f3c0-1111-2222-3333-444455556666" + tagUUID = "a1b2c3d4-5555-6666-7777-888899990000" + ) + var ( + captured map[string]any + catalogCalls atomic.Int32 + ) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && (r.URL.Path == "/fastify/api/projects" || r.URL.Path == "/fastify/api/tags"): + catalogCalls.Add(1) + t.Errorf("catalog fetch must NOT happen for all-UUID input; got %s %s", r.Method, r.URL.Path) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/dbmss/abc": + captured = readUpdateBody(t, r) + _, _ = w.Write([]byte(`{"id":"abc","name":"my-dbms"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run("update abc --project " + projectUUID + " --tags " + tagUUID + " --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if catalogCalls.Load() != 0 { + t.Fatalf("expected 0 catalog fetches, got %d", catalogCalls.Load()) + } + projects, ok := captured["projects"].([]any) + if !ok || len(projects) != 1 || projects[0] != projectUUID { + t.Fatalf("expected body projects=[%s] verbatim, got %+v", projectUUID, captured["projects"]) + } + tags, ok := captured["tags"].([]any) + if !ok || len(tags) != 1 || tags[0] != tagUUID { + t.Fatalf("expected body tags=[%s] verbatim, got %+v", tagUUID, captured["tags"]) + } +} + +// TestUpdate_ClearTags asserts `--tags ""` collapses to an empty array in the +// PATCH body (replace semantics: clearing the set). +func TestUpdate_ClearTags(t *testing.T) { + h := newUpdateHelper(t) + var captured map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/dbmss/abc": + captured = readUpdateBody(t, r) + _, _ = w.Write([]byte(`{"id":"abc","name":"my-dbms"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`update abc --tags "" --format json`); err != nil { + t.Fatalf("run: %v", err) + } + tags, ok := captured["tags"].([]any) + if !ok { + t.Fatalf("expected body tags to be an array, got %+v", captured["tags"]) + } + if len(tags) != 0 { + t.Fatalf("expected body tags=[] (cleared), got %+v", tags) + } +} + +func TestUpdate_Annotated_Write(t *testing.T) { + cfg := clicfg.NewConfig(mustTestFs(t), "test") + parent := dbms.NewCmd(cfg) + var updateCmd *cobra.Command + for _, c := range parent.Commands() { + if c.Name() == "update" { + updateCmd = c + break + } + } + if updateCmd == nil { + t.Fatalf("update command not registered under dbms") + } + if updateCmd.Annotations["write"] != "true" { + t.Fatalf("update must be annotated write=true; got %v", updateCmd.Annotations) + } +} diff --git a/internal/commands/desktop/dbms/upgrade.go b/internal/commands/desktop/dbms/upgrade.go index e92d26bc5..2aef4a263 100644 --- a/internal/commands/desktop/dbms/upgrade.go +++ b/internal/commands/desktop/dbms/upgrade.go @@ -78,7 +78,7 @@ neo4j-cli desktop dbms upgrade my-dbms-id --version 5.26.1 --format json --rw`, port, _ := cmd.Flags().GetInt(portFlag) id := args[0] - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/dbms/upgrade_test.go b/internal/commands/desktop/dbms/upgrade_test.go index cfff4462c..e90f2eee6 100644 --- a/internal/commands/desktop/dbms/upgrade_test.go +++ b/internal/commands/desktop/dbms/upgrade_test.go @@ -27,7 +27,7 @@ import ( ) // upgradeHelper mirrors createHelper/startHelper: dbms.NewCmd wired against an -// in-memory FS, with `newDesktopClientFn` pinned to a desktopclient.Client +// in-memory FS, with `desktopclient.Connect` pinned to a desktopclient.Client // backed by an httptest server. Poll sleeps are no-ops so the force/stop poll // runs instantly. type upgradeHelper struct { @@ -64,7 +64,7 @@ func (h *upgradeHelper) withHandler(handler http.HandlerFunc) *httptest.Server { srv := httptest.NewServer(handler) h.t.Cleanup(srv.Close) - h.t.Cleanup(dbms.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 diff --git a/internal/commands/desktop/debug_test.go b/internal/commands/desktop/debug_test.go index f68bdd5be..09bf439f7 100644 --- a/internal/commands/desktop/debug_test.go +++ b/internal/commands/desktop/debug_test.go @@ -49,14 +49,14 @@ func mountDesktopUnderRoot(t *testing.T, cfg *clicfg.Config) *cobra.Command { return root } -// pinDesktopClient backs the dbms-leaf newDesktopClientFn seam with a real +// pinDesktopClient backs the shared `desktopclient.Connect` seam with a real // desktopclient.Client wired to the supplied httptest server, so the leaf // exercises the production wire-tracing path in doRaw. func pinDesktopClient(t *testing.T, srvURL string) { t.Helper() t.Cleanup(desktopclient.SetUUIDFnForTest(func() string { return debugClientID })) t.Cleanup(desktopclient.SetNowFnForTest(func() time.Time { return time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) })) - t.Cleanup(dbms.SetNewDesktopClientFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) { + t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) { return desktopclient.NewClient(desktopclient.ProbeResult{Origin: srvURL}, debugSalt) })) } diff --git a/internal/commands/desktop/desktop.go b/internal/commands/desktop/desktop.go index 1843e0d26..da7db5cfa 100644 --- a/internal/commands/desktop/desktop.go +++ b/internal/commands/desktop/desktop.go @@ -8,6 +8,8 @@ import ( "github.com/neo4j/cli/internal/clicfg" "github.com/neo4j/cli/internal/commands/desktop/connection" "github.com/neo4j/cli/internal/commands/desktop/dbms" + "github.com/neo4j/cli/internal/commands/desktop/project" + "github.com/neo4j/cli/internal/commands/desktop/tag" "github.com/neo4j/cli/internal/debug" "github.com/neo4j/cli/internal/desktopclient" "github.com/spf13/cobra" @@ -21,9 +23,9 @@ func NewCmd(cfg *clicfg.Config) *cobra.Command { cmd := &cobra.Command{ Use: "desktop", Short: "Manage DBMSes under a local Neo4j Desktop 2 install", - Long: "Manage Neo4j Desktop 2 — local DBMSes (`dbms`), saved remote connections (`connection`), and install the Desktop app itself (`install`). " + + Long: "Manage Neo4j Desktop 2 — local DBMSes (`dbms`), saved remote connections (`connection`), the project catalog (`project`), the tag catalog (`tag`), and install the Desktop app itself (`install`). " + "`desktop list` shows DBMSes and saved connections together; use `desktop dbms list` or `desktop connection list` for single-resource views. " + - "Write commands (`dbms create/delete/start/stop`, `connection create/update/delete`, `install`) require `--rw`.", + "Write commands (`dbms create/update/delete/start/stop`, `connection create/update/delete`, `project create/update/delete`, `tag create/update/delete`, `install`) require `--rw`.", } cmd.PersistentFlags().Int(portFlag, 0, "Pin the Desktop relate API to a specific port instead of probing 44222..44232") @@ -46,6 +48,8 @@ func NewCmd(cfg *clicfg.Config) *cobra.Command { cmd.AddCommand(dbms.NewCmd(cfg)) cmd.AddCommand(connection.NewCmd(cfg)) + cmd.AddCommand(project.NewCmd(cfg)) + cmd.AddCommand(tag.NewCmd(cfg)) cmd.AddCommand(newListCmd(cfg)) cmd.AddCommand(newDoctorCmd(cfg)) cmd.AddCommand(newInstallCmd(cfg)) diff --git a/internal/commands/desktop/helpers.go b/internal/commands/desktop/helpers.go deleted file mode 100644 index 34bbda6eb..000000000 --- a/internal/commands/desktop/helpers.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) "Neo4j" -// Neo4j Sweden AB [http://neo4j.com] - -package desktop - -import ( - "context" - "errors" - - "github.com/neo4j/cli/internal/clierr" - "github.com/neo4j/cli/internal/desktopclient" - "github.com/spf13/afero" -) - -// newDesktopClient probes the port range, resolves the data dir, loads the -// salt, and signs a JWT. A probe miss or missing/unreadable salt both surface -// as the canonical "Desktop unreachable" message — a missing salt means -// Desktop has not yet finished first-run auth setup. -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 - // for the /info/app discovery step. - 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 unreachable error as a probe miss. - return nil, desktopclient.UnreachableError() - } - return desktopclient.NewClient(probe, salt) -} - -// SetNewDesktopClientFnForTest overrides the desktop client constructor. -func SetNewDesktopClientFnForTest(fn func(context.Context, afero.Fs, int) (*desktopclient.Client, error)) func() { - prev := newDesktopClientFn - newDesktopClientFn = fn - return func() { newDesktopClientFn = prev } -} diff --git a/internal/commands/desktop/internal/catalogcmd/color.go b/internal/commands/desktop/internal/catalogcmd/color.go new file mode 100644 index 000000000..65bfdc423 --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/color.go @@ -0,0 +1,31 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd + +import ( + "strings" + + "github.com/neo4j/cli/internal/clierr" +) + +// validPaletteColors are the only `--color` values Desktop's TagSchema +// accepts: the palette indices "1".."12" (NOT hex colours). +var validPaletteColors = []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"} + +// validatePaletteColor rejects any --color value outside Desktop's "1".."12" +// palette. The empty string means "unset" and passes — the client then omits +// the color key from the request body. +func validatePaletteColor(color string) error { + if color == "" { + return nil + } + for _, v := range validPaletteColors { + if color == v { + return nil + } + } + return clierr.NewUsageError( + "invalid --color %q: must be one of %s (Desktop palette index, not a hex colour)", + color, strings.Join(validPaletteColors, ", ")) +} diff --git a/internal/commands/desktop/internal/catalogcmd/create.go b/internal/commands/desktop/internal/catalogcmd/create.go new file mode 100644 index 000000000..df1209452 --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/create.go @@ -0,0 +1,69 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd + +import ( + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/desktopclient" + commonoutput "github.com/neo4j/cli/internal/output" + "github.com/spf13/cobra" +) + +// newCreateCmd generates the ` create ` leaf. Desktop's POST +// responds with the full post-create catalog state rather than the single +// entry, so the created entry is resolved out of that state by name via the +// spec's FindByName — absent or ambiguous is a fatal error, never a silently +// printed `null` or a guessed entry. A color-bearing spec validates --color +// before the client is built so a bad palette index fails fast without +// touching Desktop. +func newCreateCmd[T any, S any](cfg *clicfg.Config, spec Spec[T, S]) *cobra.Command { + var color string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a " + spec.Noun + " in the local Neo4j Desktop 2 install", + Long: spec.CreateLong, + Example: spec.CreateExample, + Annotations: map[string]string{"write": "true"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + name := args[0] + + if spec.Color != nil { + if err := validatePaletteColor(color); err != nil { + return err + } + } + + ctx := cmd.Context() + fs := cfg.Fs() + port, _ := cmd.Flags().GetInt(portFlag) + + client, err := desktopclient.Connect(ctx, fs, port) + if err != nil { + return err + } + + state, err := spec.Create(ctx, client, name, color) + if err != nil { + return err + } + + created, err := spec.FindByName(state, name) + if err != nil { + return err + } + + commonoutput.PrintBodyMap(cmd, cfg, spec.itemResult(created), spec.Fields) + return nil + }, + } + + if spec.Color != nil { + cmd.Flags().StringVar(&color, "color", "", spec.Color.CreateUsage) + } + + return cmd +} diff --git a/internal/commands/desktop/internal/catalogcmd/create_test.go b/internal/commands/desktop/internal/catalogcmd/create_test.go new file mode 100644 index 000000000..a9a53ac4b --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/create_test.go @@ -0,0 +1,124 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd_test + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +func TestCreate_HappyPath_JSON(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + var gotName, gotColor string + spec.Create = func(_ context.Context, _ *desktopclient.Client, name, color string) (*fakeState, error) { + gotName, gotColor = name, color + return &fakeState{entries: []fakeEntry{{ID: "w1", Name: "my-widget"}}}, nil + } + spec.FindByName = findByName + + if err := h.run(spec, "widget create my-widget --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if gotName != "my-widget" || gotColor != "" { + t.Fatalf("create hook got name=%q color=%q, want my-widget/''", gotName, gotColor) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "w1" || got["name"] != "my-widget" { + t.Fatalf("unexpected create output: %v", got) + } +} + +func TestCreate_FinderError_SurfacesFatalAndPrintsNoNull(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.Create = func(context.Context, *desktopclient.Client, string, string) (*fakeState, error) { + return &fakeState{}, nil + } + spec.FindByName = findByName + + err := h.run(spec, "widget create my-widget --format json") + if err == nil || !strings.Contains(err.Error(), "missing from the catalog state") { + t.Fatalf("expected finder error to surface, got: %v", err) + } + if strings.Contains(h.out.String(), "null") { + t.Fatalf("must not print null on failure, got:\n%s", h.out.String()) + } +} + +func TestCreate_RequiresName(t *testing.T) { + h := newHarness(t) + h.stubConnect() + if err := h.run(baseSpec(t), "widget create"); err == nil { + t.Fatalf("expected error when is missing") + } +} + +func TestCreate_ColorValidatedBeforeConnect(t *testing.T) { + h := newHarness(t) + connectCalled := false + t.Cleanup(desktopclient.SetConnectFnForTest(func(context.Context, afero.Fs, int) (*desktopclient.Client, error) { + connectCalled = true + return nil, nil //nolint:nilnil // unreachable in this test + })) + spec := withColor(baseSpec(t)) + + err := h.run(spec, "widget create my-widget --color 99") + if err == nil { + t.Fatalf("expected usage error for --color 99") + } + if !strings.Contains(err.Error(), "--color") || + !strings.Contains(err.Error(), "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12") { + t.Fatalf("expected palette listing in error, got: %v", err) + } + if connectCalled { + t.Fatalf("connect must not run when --color is invalid") + } +} + +func TestCreate_ColorForwarded(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := withColor(baseSpec(t)) + var gotColor string + spec.Create = func(_ context.Context, _ *desktopclient.Client, _, color string) (*fakeState, error) { + gotColor = color + return &fakeState{entries: []fakeEntry{{ID: "w1", Name: "my-widget", Color: "5"}}}, nil + } + spec.FindByName = findByName + + if err := h.run(spec, "widget create my-widget --color 5 --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if gotColor != "5" { + t.Fatalf("expected --color 5 forwarded to the create hook, got %q", gotColor) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["color"] != "5" { + t.Fatalf("expected color in output, got %v", got) + } +} + +func TestCreate_NoColorFlagWithoutColorSpec(t *testing.T) { + h := newHarness(t) + h.stubConnect() + err := h.run(baseSpec(t), "widget create my-widget --color 5") + if err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("expected unknown-flag error for --color on a colorless spec, got: %v", err) + } +} diff --git a/internal/commands/desktop/internal/catalogcmd/delete.go b/internal/commands/desktop/internal/catalogcmd/delete.go new file mode 100644 index 000000000..9c4c00477 --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/delete.go @@ -0,0 +1,80 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd + +import ( + "encoding/json" + "fmt" + + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/confirm" + "github.com/neo4j/cli/internal/desktopclient" + commonoutput "github.com/neo4j/cli/internal/output" + "github.com/spf13/cobra" +) + +// newDeleteCmd generates the ` delete ` leaf. The +// positional accepts a UUID or an exact name (resolved via the spec's Resolve +// hook); the resolved id rides in the DELETE querystring. The destructive +// action is gated by the shared confirm helper on the RAW positional BEFORE any +// catalog lookup, so non-TTY callers without `--yes --force` fail fast without +// touching Desktop and the TTY prompt echoes what the user typed. +func newDeleteCmd[T any, S any](cfg *clicfg.Config, spec Spec[T, S]) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete <" + spec.Noun + ">", + Short: "Delete a " + spec.Noun + " from the local Neo4j Desktop 2 install", + Long: spec.DeleteLong, + Example: spec.DeleteExample, + Annotations: map[string]string{"write": "true"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + fs := cfg.Fs() + port, _ := cmd.Flags().GetInt(portFlag) + + if err := confirm.Require(cmd, args[0]); err != nil { + return err + } + + client, err := desktopclient.Connect(ctx, fs, port) + if err != nil { + return err + } + + id, err := spec.Resolve(ctx, client, args[0]) + if err != nil { + return err + } + + if err := spec.Delete(ctx, client, id); err != nil { + return err + } + + // DELETE responds with the full post-delete state — the removed + // entry is by definition absent from it — so emit a slim + // confirmation envelope keyed on the id (mirrors the slim + // `{"id": ...}` result dbms start/delete use). + switch commonoutput.ResolveOutput(cmd, cfg) { + case "json": + payload := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ID: id, Deleted: true} + buf, jerr := json.MarshalIndent(payload, "", "\t") + if jerr != nil { + return jerr + } + cmd.Println(string(buf)) + default: + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Deleted %s %s.\n", spec.Noun, id) + } + return nil + }, + } + + confirm.Register(cmd) + + return cmd +} diff --git a/internal/commands/desktop/internal/catalogcmd/delete_test.go b/internal/commands/desktop/internal/catalogcmd/delete_test.go new file mode 100644 index 000000000..c1b73c1f0 --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/delete_test.go @@ -0,0 +1,158 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/neo4j/cli/internal/confirm" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +// TestDelete_ConfirmGateFiresBeforeConnect: the confirm gate runs on the RAW +// positional BEFORE any client construction, so non-TTY callers without +// --yes --force fail fast without touching Desktop. +func TestDelete_ConfirmGateFiresBeforeConnect(t *testing.T) { + h := newHarness(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + connectCalled := false + t.Cleanup(desktopclient.SetConnectFnForTest(func(context.Context, afero.Fs, int) (*desktopclient.Client, error) { + connectCalled = true + return nil, nil //nolint:nilnil // unreachable in this test + })) + spec := baseSpec(t) + + err := h.run(spec, "widget delete my-widget --yes") + if err == nil { + t.Fatalf("expected usage error when --force is missing") + } + if !strings.Contains(err.Error(), "pass both --yes and --force") { + t.Fatalf("expected error to mention 'pass both --yes and --force', got: %v", err) + } + if connectCalled { + t.Fatalf("connect must not run before the confirm gate") + } +} + +// TestDelete_TTYDecline_PromptEchoesRawPositional: the TTY prompt names what +// the user typed (not a resolved id), and declining cancels before any +// Desktop contact. +func TestDelete_TTYDecline_PromptEchoesRawPositional(t *testing.T) { + h := newHarness(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return true })) + h.in.WriteString("n\n") + connectCalled := false + t.Cleanup(desktopclient.SetConnectFnForTest(func(context.Context, afero.Fs, int) (*desktopclient.Client, error) { + connectCalled = true + return nil, nil //nolint:nilnil // unreachable in this test + })) + spec := baseSpec(t) + + err := h.run(spec, "widget delete my-widget --format json") + if err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("expected confirm.ErrCancelled on decline; got: %v", err) + } + if !strings.Contains(h.err.String(), `widget "my-widget"`) { + t.Fatalf("expected prompt to echo the typed name; stderr=%q", h.err.String()) + } + if connectCalled { + t.Fatalf("connect must not run before the confirm gate") + } +} + +func TestDelete_JSON_SlimEnvelope(t *testing.T) { + h := newHarness(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + h.stubConnect() + spec := baseSpec(t) + var gotID string + spec.Resolve = func(_ context.Context, _ *desktopclient.Client, nameOrID string) (string, error) { + return "w1", nil + } + spec.Delete = func(_ context.Context, _ *desktopclient.Client, id string) error { + gotID = id + return nil + } + + if err := h.run(spec, "widget delete my-widget --yes --force --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if gotID != "w1" { + t.Fatalf("delete hook got id=%q, want w1", gotID) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "w1" || got["deleted"] != true || len(got) != 2 { + t.Fatalf("expected exactly {id, deleted:true}, got %v", got) + } +} + +func TestDelete_TableConfirmation(t *testing.T) { + h := newHarness(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + h.stubConnect() + spec := baseSpec(t) + spec.Resolve = func(_ context.Context, _ *desktopclient.Client, nameOrID string) (string, error) { + return nameOrID, nil + } + spec.Delete = func(context.Context, *desktopclient.Client, string) error { return nil } + + if err := h.run(spec, "widget delete "+validFakeID+" --yes --force --format table"); err != nil { + t.Fatalf("run: %v", err) + } + got := strings.TrimSpace(h.out.String()) + want := "Deleted widget " + validFakeID + "." + if got != want { + t.Fatalf("table confirmation mismatch:\n got: %q\n want: %q", got, want) + } +} + +func TestDelete_ResolveError_SurfacesBeforeDelete(t *testing.T) { + h := newHarness(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + h.stubConnect() + spec := baseSpec(t) + spec.Resolve = func(context.Context, *desktopclient.Client, string) (string, error) { + return "", errors.New("unknown widget \"nope\"") + } + + err := h.run(spec, "widget delete nope --yes --force --format json") + if err == nil || !strings.Contains(err.Error(), "unknown widget") { + t.Fatalf("expected resolve error to surface, got: %v", err) + } +} + +func TestDelete_HookError_Surfaces(t *testing.T) { + h := newHarness(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + h.stubConnect() + spec := baseSpec(t) + spec.Resolve = func(_ context.Context, _ *desktopclient.Client, nameOrID string) (string, error) { + return nameOrID, nil + } + spec.Delete = func(context.Context, *desktopclient.Client, string) error { + return errors.New("delete boom") + } + + err := h.run(spec, "widget delete "+validFakeID+" --yes --force --format json") + if err == nil || !strings.Contains(err.Error(), "delete boom") { + t.Fatalf("expected delete hook error to surface, got: %v", err) + } +} + +func TestDelete_RequiresPositional(t *testing.T) { + h := newHarness(t) + h.stubConnect() + if err := h.run(baseSpec(t), "widget delete --yes --force"); err == nil { + t.Fatalf("expected error when is missing") + } +} diff --git a/internal/commands/desktop/internal/catalogcmd/helpers_test.go b/internal/commands/desktop/internal/catalogcmd/helpers_test.go new file mode 100644 index 000000000..1d21be4de --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/helpers_test.go @@ -0,0 +1,239 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd_test + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/google/shlex" + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/neo4j/cli/internal/flags" + "github.com/neo4j/cli/internal/testutil/testfs" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// fakeEntry is the scaffold test's catalog row type; fakeState is its +// post-mutation state type. They stand in for desktopclient.Project/Tag and +// ProjectsState/TagsState so the scaffold core is exercised without any HTTP. +type fakeEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +type fakeState struct { + entries []fakeEntry +} + +const validFakeID = "f4e2f3c0-1111-2222-3333-444455556666" + +// Flush-left example blocks satisfying the whole-tree example gate (>=2 +// invocations, `# comment` each, neo4j-cli prefix, --rw on writes, --format +// json on reads). +const ( + fakeListExample = `# List widgets as a table +neo4j-cli desktop widget list + +# List widgets as JSON +neo4j-cli desktop widget list --format json` + fakeCreateExample = `# Create a widget +neo4j-cli desktop widget create my-widget --rw + +# Create a widget and emit the created entry as JSON +neo4j-cli desktop widget create my-widget --format json --rw` + fakeUpdateExample = `# Rename a widget addressed by its exact name +neo4j-cli desktop widget update my-widget --name renamed --rw + +# Rename a widget addressed by UUID, emitting the updated entry as JSON +neo4j-cli desktop widget update f4e2f3c0-1111-2222-3333-444455556666 --name renamed --format json --rw` + fakeDeleteExample = `# Delete a widget by exact name with an interactive y/N confirmation +neo4j-cli desktop widget delete my-widget --rw + +# Delete a widget by UUID without prompting +neo4j-cli desktop widget delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a widget and emit a machine-readable confirmation +neo4j-cli desktop widget delete my-widget --yes --force --format json --rw` +) + +// baseSpec returns a colorless widget spec with hooks that fail the test if +// called — tests override the hooks they exercise. RowJSON is the identity +// projection (like tag's raw Tag payload). +func baseSpec(t *testing.T) catalogcmd.Spec[fakeEntry, *fakeState] { + t.Helper() + called := func(name string) { + t.Helper() + t.Fatalf("spec hook %s called unexpectedly", name) + } + return catalogcmd.Spec[fakeEntry, *fakeState]{ + Noun: "widget", + Plural: "widgets", + Fields: []string{"id", "name"}, + UpdateVerb: "Update", + + ListLong: "List the widget catalog.", + ListExample: fakeListExample, + CreateLong: "Create a widget.", + CreateExample: fakeCreateExample, + UpdateLong: "Update a widget.", + UpdateExample: fakeUpdateExample, + DeleteLong: "Delete a widget.", + DeleteExample: fakeDeleteExample, + + List: func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + called("List") + return nil, nil + }, + Create: func(context.Context, *desktopclient.Client, string, string) (*fakeState, error) { + called("Create") + return nil, nil + }, + Update: func(context.Context, *desktopclient.Client, string, string, string) (*fakeState, error) { + called("Update") + return nil, nil + }, + Delete: func(context.Context, *desktopclient.Client, string) error { + called("Delete") + return nil + }, + Resolve: func(context.Context, *desktopclient.Client, string) (string, error) { + called("Resolve") + return "", nil + }, + FindByName: func(*fakeState, string) (*fakeEntry, error) { + called("FindByName") + return nil, nil + }, + FindByID: func(*fakeState, string) (*fakeEntry, error) { + called("FindByID") + return nil, nil + }, + + RowJSON: func(e fakeEntry) any { return e }, + RowValues: func(e fakeEntry) []string { return []string{e.ID, e.Name} }, + } +} + +// withColor turns a base spec into a color-bearing one (the tag shape): the +// color field joins the output columns and --color mounts on create/update. +func withColor(spec catalogcmd.Spec[fakeEntry, *fakeState]) catalogcmd.Spec[fakeEntry, *fakeState] { + spec.Fields = []string{"id", "name", "color"} + spec.Color = &catalogcmd.ColorSpec{ + CreateUsage: `Optional widget color: one of Desktop's palette indices "1".."12"`, + UpdateUsage: `New widget color: one of Desktop's palette indices "1".."12"`, + } + spec.RowValues = func(e fakeEntry) []string { return []string{e.ID, e.Name, e.Color} } + return spec +} + +// findByName is a find-one fake mirroring the desktopclient finder policy. +func findByName(state *fakeState, name string) (*fakeEntry, error) { + if state != nil { + for i := range state.entries { + if state.entries[i].Name == name { + return &state.entries[i], nil + } + } + } + return nil, errors.New(`fake: widget "` + name + `" is missing from the catalog state`) +} + +// findByID is a find-one fake mirroring the desktopclient finder policy. +func findByID(state *fakeState, id string) (*fakeEntry, error) { + if state != nil { + for i := range state.entries { + if state.entries[i].ID == id { + return &state.entries[i], nil + } + } + } + return nil, errors.New(`fake: widget "` + id + `" is missing from the catalog state`) +} + +// harness wires a fake `desktop` root (persistent --port + the output flag) +// against an in-memory FS, mirroring how the real desktop root mounts the +// generated leaves. +type harness struct { + t *testing.T + in *bytes.Buffer + out *bytes.Buffer + err *bytes.Buffer + fs afero.Fs +} + +func newHarness(t *testing.T) *harness { + t.Helper() + cobra.EnableTraverseRunHooks = true + fs, err := testfs.GetTestFs(`{"format":"json"}`, "{}") + if err != nil { + t.Fatalf("GetTestFs: %v", err) + } + return &harness{ + t: t, + in: &bytes.Buffer{}, + out: &bytes.Buffer{}, + err: &bytes.Buffer{}, + fs: fs, + } +} + +// stubConnect pins the desktopclient.Connect seam to a no-op constructor; the +// fake spec hooks ignore the client. Returns a pointer to the last port the +// constructor saw. +func (h *harness) stubConnect() *int { + h.t.Helper() + seen := new(int) + h.t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) { + *seen = port + return nil, nil //nolint:nilnil // the fake hooks never dereference the client + })) + return seen +} + +func (h *harness) run(spec catalogcmd.Spec[fakeEntry, *fakeState], command string) error { + h.t.Helper() + args, err := shlex.Split(command) + if err != nil { + h.t.Fatalf("shlex: %v", err) + } + cfg := clicfg.NewConfig(h.fs, "test") + defer cfg.Events.Flush() + root := &cobra.Command{Use: "desktop"} + root.PersistentFlags().Int("port", 0, "Pin the Desktop relate API to a specific port") + flags.RegisterOutputFlag(root, cfg) + parent := &cobra.Command{Use: spec.Noun} + root.AddCommand(parent) + catalogcmd.Mount(cfg, parent, spec) + root.SetArgs(args) + root.SetIn(h.in) + root.SetOut(h.out) + root.SetErr(h.err) + return root.Execute() +} + +// findLeaf locates one generated leaf under a freshly mounted parent. +func findLeaf(t *testing.T, spec catalogcmd.Spec[fakeEntry, *fakeState], leafName string) *cobra.Command { + t.Helper() + fs, err := testfs.GetTestFs(`{"format":"json"}`, "{}") + if err != nil { + t.Fatalf("GetTestFs: %v", err) + } + cfg := clicfg.NewConfig(fs, "test") + defer cfg.Events.Flush() + parent := &cobra.Command{Use: spec.Noun} + catalogcmd.Mount(cfg, parent, spec) + for _, c := range parent.Commands() { + if c.Name() == leafName { + return c + } + } + t.Fatalf("leaf %q not generated", leafName) + return nil +} diff --git a/internal/commands/desktop/internal/catalogcmd/list.go b/internal/commands/desktop/internal/catalogcmd/list.go new file mode 100644 index 000000000..f2a087bab --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/list.go @@ -0,0 +1,50 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd + +import ( + "fmt" + + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/desktopclient" + commonoutput "github.com/neo4j/cli/internal/output" + "github.com/spf13/cobra" +) + +// newListCmd generates the ` list` leaf: connect, fetch the catalog, +// render. Table mode goes through the custom renderer (header + one row per +// entry, `(none)` placeholder when empty); json/toon emit the RowJSON-projected +// array (`[]`, never `null`, when empty). +func newListCmd[T any, S any](cfg *clicfg.Config, spec Spec[T, S]) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List " + spec.Plural + " in the local Neo4j Desktop 2 install", + Long: spec.ListLong, + Example: spec.ListExample, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + fs := cfg.Fs() + port, _ := cmd.Flags().GetInt(portFlag) + + client, err := desktopclient.Connect(ctx, fs, port) + if err != nil { + return err + } + + items, err := spec.List(ctx, client) + if err != nil { + return err + } + + if commonoutput.ResolveOutput(cmd, cfg) == "table" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), renderTable(spec.Fields, items, spec.RowValues)) + return nil + } + commonoutput.PrintBodyMap(cmd, cfg, listResult[T]{items: items, rowJSON: spec.RowJSON}, nil) + return nil + }, + } +} diff --git a/internal/commands/desktop/internal/catalogcmd/list_test.go b/internal/commands/desktop/internal/catalogcmd/list_test.go new file mode 100644 index 000000000..11dae9c54 --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/list_test.go @@ -0,0 +1,152 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +func TestList_JSON_RendersRowJSONArray(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.List = func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + return []fakeEntry{{ID: "w1", Name: "alpha"}, {ID: "w2", Name: "beta"}}, nil + } + + if err := h.run(spec, "widget list --format json"); err != nil { + t.Fatalf("run: %v", err) + } + var got []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if len(got) != 2 || got[0]["id"] != "w1" || got[1]["name"] != "beta" { + t.Fatalf("unexpected list output: %v", got) + } +} + +func TestList_EmptyJSON_RendersEmptyArrayNotNull(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.List = func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + return nil, nil + } + + if err := h.run(spec, "widget list --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if trimmed := strings.TrimSpace(h.out.String()); trimmed != "[]" { + t.Fatalf("expected `[]` for an empty list, got %q", trimmed) + } +} + +func TestList_Table_RendersHeaderAndStrippedRows(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.List = func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + return []fakeEntry{{ID: "w1", Name: "al\x1b[31mpha"}}, nil + } + + if err := h.run(spec, "widget list --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + for _, col := range []string{"ID", "NAME"} { + if !strings.Contains(strings.ToUpper(out), col) { + t.Fatalf("expected column %q in header, got:\n%s", col, out) + } + } + if !strings.Contains(out, "al?[31mpha") { + t.Fatalf("expected stripped cell value in table, got:\n%s", out) + } + if strings.Contains(out, "\x1b") { + t.Fatalf("control bytes must be stripped from table cells, got:\n%q", out) + } +} + +func TestList_EmptyTable_RendersNonePlaceholder(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.List = func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + return nil, nil + } + + if err := h.run(spec, "widget list --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + if !strings.Contains(out, "(none)") { + t.Fatalf("expected `(none)` placeholder in empty table, got:\n%s", out) + } + for _, col := range []string{"ID", "NAME"} { + if !strings.Contains(strings.ToUpper(out), col) { + t.Fatalf("expected column %q in header, got:\n%s", col, out) + } + } +} + +func TestList_ConnectError_Surfaces(t *testing.T) { + h := newHarness(t) + t.Cleanup(desktopclient.SetConnectFnForTest(func(context.Context, afero.Fs, int) (*desktopclient.Client, error) { + return nil, errors.New("boom") + })) + spec := baseSpec(t) + + err := h.run(spec, "widget list --format json") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected connect error to surface, got: %v", err) + } +} + +func TestList_HookError_Surfaces(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.List = func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + return nil, errors.New("list boom") + } + + err := h.run(spec, "widget list --format json") + if err == nil || !strings.Contains(err.Error(), "list boom") { + t.Fatalf("expected list hook error to surface, got: %v", err) + } +} + +func TestList_PortFlagPropagatesToClientConstructor(t *testing.T) { + h := newHarness(t) + seen := h.stubConnect() + spec := baseSpec(t) + spec.List = func(context.Context, *desktopclient.Client) ([]fakeEntry, error) { + return nil, nil + } + + if err := h.run(spec, "widget list --port 44225 --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if *seen != 44225 { + t.Fatalf("expected --port=44225 to reach the client constructor, got %d", *seen) + } +} + +func TestList_RejectsPositional(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + + if err := h.run(spec, "widget list extra"); err == nil { + t.Fatalf("expected error when a positional is supplied") + } +} diff --git a/internal/commands/desktop/internal/catalogcmd/render.go b/internal/commands/desktop/internal/catalogcmd/render.go new file mode 100644 index 000000000..cb65c50df --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/render.go @@ -0,0 +1,100 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd + +import ( + "encoding/json" + + "github.com/jedib0t/go-pretty/v6/table" + commonoutput "github.com/neo4j/cli/internal/output" +) + +// listResult is the payload returned by a generated list leaf. +type listResult[T any] struct { + items []T + rowJSON func(T) any +} + +// AsArray satisfies commonoutput.ResponseData; table mode routes through the +// custom renderer so this returns nil. +func (r listResult[T]) AsArray() []map[string]any { return nil } + +// MarshalJSON emits the RowJSON-projected array unwrapped. Empty marshals to +// `[]` (never `null`) so JSON consumers always see an array. +func (r listResult[T]) MarshalJSON() ([]byte, error) { + out := make([]any, 0, len(r.items)) + for _, it := range r.items { + out = append(out, r.rowJSON(it)) + } + return json.Marshal(out) +} + +// itemResult adapts a single catalog entry to `output.ResponseData` for the +// create/update leaves. +type itemResult[T any] struct { + item *T + fields []string + rowJSON func(T) any + rowValues func(T) []string +} + +// itemResult wraps one found entry with the spec's projections. +func (s Spec[T, S]) itemResult(item *T) itemResult[T] { + return itemResult[T]{item: item, fields: s.Fields, rowJSON: s.RowJSON, rowValues: s.RowValues} +} + +// AsArray zips Fields x RowValues into a single-row map; the values are the +// same strings the table renderer prints (printTable strips control bytes at +// render time). +func (r itemResult[T]) AsArray() []map[string]any { + if r.item == nil { + return nil + } + vals := r.rowValues(*r.item) + row := make(map[string]any, len(r.fields)) + for i, f := range r.fields { + row[f] = vals[i] + } + return []map[string]any{row} +} + +// MarshalJSON emits the RowJSON projection; a nil item marshals to `null`. +// The fail-loud finder policy means leaves never reach this with a nil item. +func (r itemResult[T]) MarshalJSON() ([]byte, error) { + if r.item == nil { + return []byte("null"), nil + } + return json.Marshal(r.rowJSON(*r.item)) +} + +// renderTable emits the list table: a header row from fields, then one row per +// entry with control-stripped cells. Empty input yields a one-row `(none)` +// placeholder so the table is visually present. +func renderTable[T any](fields []string, items []T, rowValues func(T) []string) string { + t := table.NewWriter() + header := make(table.Row, 0, len(fields)) + for _, f := range fields { + header = append(header, f) + } + t.AppendHeader(header) + if len(items) == 0 { + row := make(table.Row, len(fields)) + row[0] = "(none)" + for i := 1; i < len(row); i++ { + row[i] = "" + } + t.AppendRow(row) + } else { + for _, it := range items { + vals := rowValues(it) + row := make(table.Row, len(vals)) + for i, v := range vals { + row[i] = commonoutput.StripControl(v) + } + t.AppendRow(row) + } + } + t.SetStyle(table.StyleLight) + return t.Render() +} diff --git a/internal/commands/desktop/internal/catalogcmd/spec.go b/internal/commands/desktop/internal/catalogcmd/spec.go new file mode 100644 index 000000000..a0c58be6b --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/spec.go @@ -0,0 +1,107 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +// Package catalogcmd is the shared scaffold behind the `desktop project` and +// `desktop tag` subtrees. Both catalogs are structurally identical — Desktop's +// /projects and /tags routes identify the mutated entry in the BODY (PATCH) or +// QUERYSTRING (DELETE), and every mutation responds with the full post-mutation +// state — so the four leaves (list/create/update/delete) are generated from a +// per-resource Spec rather than hand-authored per resource. This is the same +// data-driven exception to the one-file-per-leaf rule that `admin privilege` +// uses for its category leaves (see AGENTS.md "Cobra Command Layout"). +// +// A Spec supplies the resource noun, the output field list, the user-facing +// help text, and typed hooks closing over the desktopclient methods; the +// scaffold core stays generic over the row (T) and post-mutation state (S) +// types only — no reflection, no any-heavy magic. +package catalogcmd + +import ( + "context" + + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/cobra" +) + +// portFlag is the desktop root's persistent --port flag (0 = probe +// 44222..44232), inherited by every generated leaf. +const portFlag = "port" + +// Spec declares one Desktop catalog resource (project, tag) to the scaffold. +// Mount generates the list/create/update/delete leaves from it. +// +// Type parameters: T is the catalog row type (desktopclient.Project / Tag); +// S is the post-mutation state type every /projects or /tags mutation returns +// (*desktopclient.ProjectsState / *TagsState). +type Spec[T any, S any] struct { + // Noun is the singular resource noun ("project"); it composes the + // update/delete Use strings, every Short, and the delete confirmation + // line. Plural is used by the list Short. Fields is the output column + // order for table/toon single-entry output and the list table header. + Noun string + Plural string + Fields []string + + // UpdateVerb is the update leaf's verb ("Rename" for project, "Update" + // for tag); the other leaf verbs are fixed. The Long and Example strings + // are verbatim: each leaf's Example must be flush-left with >=2 + // invocations, a `# comment` each, the `neo4j-cli` prefix, `--rw` on + // writes, and `--format json` on reads (the whole-tree example gate). + UpdateVerb string + ListLong string + ListExample string + CreateLong string + CreateExample string + UpdateLong string + UpdateExample string + DeleteLong string + DeleteExample string + + // Color, when non-nil, mounts the optional palette --color flag on the + // create/update leaves and validates it against Desktop's "1".."12" + // palette BEFORE any Desktop contact. Nil means the resource has no color + // and the leaves carry no --color flag. + Color *ColorSpec + + // Client hooks. Create/Update receive the validated --color value ("" + // when the spec has no Color or the flag was unset). Delete's post-delete + // state is discarded by the hook: the delete leaf prints a slim + // {"id","deleted":true} envelope keyed on the resolved id instead. + List func(ctx context.Context, client *desktopclient.Client) ([]T, error) + Create func(ctx context.Context, client *desktopclient.Client, name, color string) (S, error) + Update func(ctx context.Context, client *desktopclient.Client, id, name, color string) (S, error) + Delete func(ctx context.Context, client *desktopclient.Client, id string) error + Resolve func(ctx context.Context, client *desktopclient.Client, nameOrID string) (string, error) + + // Finders pick the mutated entry out of the post-mutation catalog state. + // The fail-loud policy (absent or ambiguous is a FATAL error, never a + // printed `null`) lives in the desktopclient finders these wrap: create + // finds by name, update finds by the resolved id. + FindByName func(state S, name string) (*T, error) + FindByID func(state S, id string) (*T, error) + + // Projections. RowJSON is the JSON/toon form of one entry (list array + // element and single-entry body alike). RowValues returns the row's cell + // values in Fields order, unescaped; the scaffold strips control bytes + // for table cells and zips Fields x values for the AsArray map. + RowJSON func(item T) any + RowValues func(item T) []string +} + +// ColorSpec carries the --color flag's per-leaf usage strings for a +// color-bearing resource (tag). The palette validation itself is shared. +type ColorSpec struct { + CreateUsage string + UpdateUsage string +} + +// Mount generates the list/create/update/delete leaves from spec and mounts +// them on parent. Registration order matches the hand-written predecessors +// (help output is alphabetical either way). +func Mount[T any, S any](cfg *clicfg.Config, parent *cobra.Command, spec Spec[T, S]) { + parent.AddCommand(newListCmd(cfg, spec)) + parent.AddCommand(newCreateCmd(cfg, spec)) + parent.AddCommand(newUpdateCmd(cfg, spec)) + parent.AddCommand(newDeleteCmd(cfg, spec)) +} diff --git a/internal/commands/desktop/internal/catalogcmd/spec_test.go b/internal/commands/desktop/internal/catalogcmd/spec_test.go new file mode 100644 index 000000000..1688f262a --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/spec_test.go @@ -0,0 +1,124 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd_test + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestLeaves_WriteAnnotations pins the --rw enforcement surface: every write +// leaf is annotated write=true, the read leaf is not. +func TestLeaves_WriteAnnotations(t *testing.T) { + spec := baseSpec(t) + for _, leaf := range []string{"create", "update", "delete"} { + if got := findLeaf(t, spec, leaf).Annotations["write"]; got != "true" { + t.Fatalf("%s must be annotated write=true; got %v", leaf, got) + } + } + if got := findLeaf(t, spec, "list").Annotations["write"]; got != "" { + t.Fatalf("list must not be write-annotated; got %q", got) + } +} + +// TestLeaves_UseStrings pins the generated Use strings (the positional is +// composed from the spec noun). +func TestLeaves_UseStrings(t *testing.T) { + spec := baseSpec(t) + want := map[string]string{ + "list": "list", + "create": "create ", + "update": "update ", + "delete": "delete ", + } + for leaf, use := range want { + if got := findLeaf(t, spec, leaf).Use; got != use { + t.Fatalf("%s Use = %q, want %q", leaf, got, use) + } + } +} + +// TestLeaves_Examples mirrors the whole-tree example gate: every generated +// leaf carries a flush-left Example with >=2 invocations; writes carry --rw +// and every leaf shows --format json. +func TestLeaves_Examples(t *testing.T) { + spec := baseSpec(t) + for _, leaf := range []string{"list", "create", "update", "delete"} { + cmd := findLeaf(t, spec, leaf) + if cmd.Example == "" { + t.Fatalf("%s Example must be non-empty", leaf) + } + firstLine := strings.SplitN(cmd.Example, "\n", 2)[0] + if strings.HasPrefix(firstLine, " ") { + t.Fatalf("%s Example first line must be flush-left; got %q", leaf, firstLine) + } + if c := strings.Count(cmd.Example, "neo4j-cli desktop widget "+leaf); c < 2 { + t.Fatalf("%s Example must contain >=2 invocations; got %d", leaf, c) + } + if !strings.Contains(cmd.Example, "--format json") { + t.Fatalf("%s Example must contain --format json; got:\n%s", leaf, cmd.Example) + } + if leaf != "list" && !strings.Contains(cmd.Example, "--rw") { + t.Fatalf("%s Example must contain --rw; got:\n%s", leaf, cmd.Example) + } + } +} + +// TestUpdate_NameFlagRequired pins the flag surface: --name exists, is marked +// required, and its usage string is composed from the spec noun. +func TestUpdate_NameFlagRequired(t *testing.T) { + spec := baseSpec(t) + cmd := findLeaf(t, spec, "update") + flag := cmd.LocalFlags().Lookup("name") + if flag == nil { + t.Fatalf("update must register --name") + } + if flag.Usage != "(required) New name for the widget" { + t.Fatalf("--name usage = %q", flag.Usage) + } + required := flag.Annotations[cobra.BashCompOneRequiredFlag] + if len(required) == 0 || required[0] != "true" { + t.Fatalf("--name must be marked required; annotations: %v", flag.Annotations) + } +} + +// TestColorSpec_MountsColorFlag pins that --color appears on create/update +// exactly when the spec carries a Color block. +func TestColorSpec_MountsColorFlag(t *testing.T) { + plain := baseSpec(t) + for _, leaf := range []string{"create", "update"} { + if f := findLeaf(t, plain, leaf).LocalFlags().Lookup("color"); f != nil { + t.Fatalf("%s must not register --color on a colorless spec", leaf) + } + } + colored := withColor(baseSpec(t)) + for _, leaf := range []string{"create", "update"} { + cmd := findLeaf(t, colored, leaf) + f := cmd.LocalFlags().Lookup("color") + if f == nil { + t.Fatalf("%s must register --color on a color spec", leaf) + } + want := colored.Color.CreateUsage + if leaf == "update" { + want = colored.Color.UpdateUsage + } + if f.Usage != want { + t.Fatalf("%s --color usage = %q, want %q", leaf, f.Usage, want) + } + } +} + +// TestDelete_ConfirmFlags pins that the delete leaf carries the shared +// --yes/--force confirm flags. +func TestDelete_ConfirmFlags(t *testing.T) { + spec := baseSpec(t) + cmd := findLeaf(t, spec, "delete") + for _, name := range []string{"yes", "force"} { + if f := cmd.LocalFlags().Lookup(name); f == nil { + t.Fatalf("delete must register --%s", name) + } + } +} diff --git a/internal/commands/desktop/internal/catalogcmd/update.go b/internal/commands/desktop/internal/catalogcmd/update.go new file mode 100644 index 000000000..fb6eb28ea --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/update.go @@ -0,0 +1,79 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd + +import ( + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/desktopclient" + commonoutput "github.com/neo4j/cli/internal/output" + "github.com/spf13/cobra" +) + +// newUpdateCmd generates the ` update --name ` leaf. +// The positional accepts a UUID or an exact name (resolved via the spec's +// Resolve hook; UUIDs pass through without a catalog fetch). `--name` is +// required because Desktop's update schemas demand a name on every PATCH; the +// id rides in the PATCH body, not the path. A color-bearing spec validates +// --color before the client is built so a bad palette index fails fast without +// touching Desktop. +func newUpdateCmd[T any, S any](cfg *clicfg.Config, spec Spec[T, S]) *cobra.Command { + var ( + name string + color string + ) + + cmd := &cobra.Command{ + Use: "update <" + spec.Noun + ">", + Short: spec.UpdateVerb + " a " + spec.Noun + " in the local Neo4j Desktop 2 install", + Long: spec.UpdateLong, + Example: spec.UpdateExample, + Annotations: map[string]string{"write": "true"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + if spec.Color != nil { + if err := validatePaletteColor(color); err != nil { + return err + } + } + + ctx := cmd.Context() + fs := cfg.Fs() + port, _ := cmd.Flags().GetInt(portFlag) + + client, err := desktopclient.Connect(ctx, fs, port) + if err != nil { + return err + } + + id, err := spec.Resolve(ctx, client, args[0]) + if err != nil { + return err + } + + state, err := spec.Update(ctx, client, id, name, color) + if err != nil { + return err + } + + updated, err := spec.FindByID(state, id) + if err != nil { + return err + } + + commonoutput.PrintBodyMap(cmd, cfg, spec.itemResult(updated), spec.Fields) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "(required) New name for the "+spec.Noun) + cmd.MarkFlagRequired("name") //nolint:errcheck // MarkFlagRequired only errors if the flag does not exist, which is a programming error caught at startup + + if spec.Color != nil { + cmd.Flags().StringVar(&color, "color", "", spec.Color.UpdateUsage) + } + + return cmd +} diff --git a/internal/commands/desktop/internal/catalogcmd/update_test.go b/internal/commands/desktop/internal/catalogcmd/update_test.go new file mode 100644 index 000000000..0e31be405 --- /dev/null +++ b/internal/commands/desktop/internal/catalogcmd/update_test.go @@ -0,0 +1,133 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package catalogcmd_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +func TestUpdate_HappyPath_ResolvesUpdatesAndPrints(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := withColor(baseSpec(t)) + var gotID, gotName, gotColor string + spec.Resolve = func(_ context.Context, _ *desktopclient.Client, nameOrID string) (string, error) { + if nameOrID != "my-widget" { + t.Errorf("resolve got %q, want my-widget", nameOrID) + } + return "w1", nil + } + spec.Update = func(_ context.Context, _ *desktopclient.Client, id, name, color string) (*fakeState, error) { + gotID, gotName, gotColor = id, name, color + return &fakeState{entries: []fakeEntry{{ID: "w1", Name: "renamed", Color: "7"}}}, nil + } + spec.FindByID = findByID + + if err := h.run(spec, "widget update my-widget --name renamed --color 7 --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if gotID != "w1" || gotName != "renamed" || gotColor != "7" { + t.Fatalf("update hook got id=%q name=%q color=%q", gotID, gotName, gotColor) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "w1" || got["name"] != "renamed" || got["color"] != "7" { + t.Fatalf("unexpected update output: %v", got) + } +} + +func TestUpdate_RequiresNameFlag(t *testing.T) { + h := newHarness(t) + connectCalled := false + t.Cleanup(desktopclient.SetConnectFnForTest(func(context.Context, afero.Fs, int) (*desktopclient.Client, error) { + connectCalled = true + return nil, nil //nolint:nilnil // unreachable in this test + })) + spec := baseSpec(t) + + err := h.run(spec, "widget update "+validFakeID) + if err == nil { + t.Fatalf("expected required-flag error when --name is missing") + } + if !strings.Contains(err.Error(), `"name"`) { + t.Fatalf("expected error to mention \"name\", got: %v", err) + } + if connectCalled { + t.Fatalf("connect must not run when --name is missing") + } +} + +func TestUpdate_RequiresPositional(t *testing.T) { + h := newHarness(t) + h.stubConnect() + if err := h.run(baseSpec(t), "widget update --name renamed"); err == nil { + t.Fatalf("expected error when is missing") + } +} + +func TestUpdate_ResolveError_SurfacesBeforeUpdate(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.Resolve = func(context.Context, *desktopclient.Client, string) (string, error) { + return "", errors.New("unknown widget \"nope\"") + } + + err := h.run(spec, "widget update nope --name renamed --format json") + if err == nil || !strings.Contains(err.Error(), "unknown widget") { + t.Fatalf("expected resolve error to surface, got: %v", err) + } +} + +func TestUpdate_FinderError_SurfacesFatalAndPrintsNoNull(t *testing.T) { + h := newHarness(t) + h.stubConnect() + spec := baseSpec(t) + spec.Resolve = func(context.Context, *desktopclient.Client, string) (string, error) { + return "w1", nil + } + spec.Update = func(context.Context, *desktopclient.Client, string, string, string) (*fakeState, error) { + return &fakeState{}, nil + } + spec.FindByID = findByID + + err := h.run(spec, "widget update "+validFakeID+" --name renamed --format json") + if err == nil || !strings.Contains(err.Error(), "missing from the catalog state") { + t.Fatalf("expected finder error to surface, got: %v", err) + } + if strings.Contains(h.out.String(), "null") { + t.Fatalf("must not print null on failure, got:\n%s", h.out.String()) + } +} + +func TestUpdate_ColorValidatedBeforeConnect(t *testing.T) { + h := newHarness(t) + connectCalled := false + t.Cleanup(desktopclient.SetConnectFnForTest(func(context.Context, afero.Fs, int) (*desktopclient.Client, error) { + connectCalled = true + return nil, nil //nolint:nilnil // unreachable in this test + })) + spec := withColor(baseSpec(t)) + + err := h.run(spec, "widget update "+validFakeID+" --name x --color blue") + if err == nil { + t.Fatalf("expected usage error for --color blue") + } + if !strings.Contains(err.Error(), "--color") { + t.Fatalf("expected error to mention --color, got: %v", err) + } + if connectCalled { + t.Fatalf("connect must not run when --color is invalid") + } +} diff --git a/internal/commands/desktop/list.go b/internal/commands/desktop/list.go index 26e480a7f..ce854ac22 100644 --- a/internal/commands/desktop/list.go +++ b/internal/commands/desktop/list.go @@ -62,7 +62,7 @@ neo4j-cli desktop list --port 44225`, fs := cfg.Fs() port, _ := cmd.Flags().GetInt(portFlag) - client, err := newDesktopClientFn(ctx, fs, port) + client, err := desktopclient.Connect(ctx, fs, port) if err != nil { return err } diff --git a/internal/commands/desktop/list_test.go b/internal/commands/desktop/list_test.go index 7a981a9bb..56da6bfe2 100644 --- a/internal/commands/desktop/list_test.go +++ b/internal/commands/desktop/list_test.go @@ -28,7 +28,7 @@ import ( ) // listHelper wires desktop.NewCmd against an in-memory FS, with the new -// `newDesktopClientFn` seam pinned to a desktopclient.Client backed by an +// `desktopclient.Connect` seam pinned to a desktopclient.Client backed by an // httptest server. End-to-end: cobra flag parse → leaf RunE → desktopclient // → httptest handler. No real filesystem, no real port range. type listHelper struct { @@ -61,7 +61,7 @@ func newListHelper(t *testing.T) *listHelper { } } -// withHandler swaps the `newDesktopClientFn` seam to a closure that returns +// withHandler swaps the `desktopclient.Connect` seam to a closure that returns // a desktopclient.Client wired to the supplied httptest handler. The handler // receives every request the leaf sends; recordings live in // listHelper.handlerCalls for assertion. @@ -79,7 +79,7 @@ func (h *listHelper) withHandler(handler http.HandlerFunc) *httptest.Server { })) h.t.Cleanup(srv.Close) - h.t.Cleanup(desktop.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 @@ -90,7 +90,7 @@ func (h *listHelper) withHandler(handler http.HandlerFunc) *httptest.Server { // real httptest server (e.g. empty-list hint when Desktop is off). func (h *listHelper) pinClientUnreachable() { h.t.Helper() - h.t.Cleanup(desktop.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 nil, desktopclient.UnreachableError() })) } @@ -148,7 +148,7 @@ func TestList_JSON_BothPopulated(t *testing.T) { {"id":"b","name":"Bob"} ]` connections := `[ - {"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","project":"my-proj"}, + {"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","projects":["my-proj"]}, {"id":"c2","name":"Sandbox","connectionUri":"neo4j+s://xyz.databases.neo4j.io"} ]` h.withHandler(defaultListHandler(t, slim, perID, connections)) @@ -188,13 +188,14 @@ func TestList_JSON_BothPopulated(t *testing.T) { t.Fatalf("expected tags=[x], got %v (raw: %s)", out.Dbmss[0]["tags"], h.out.String()) } // Connections section carries the full Connection wire shape, including - // `project` when populated — JSON exposes every wire field even though the + // `projects` when populated — JSON exposes every wire field even though the // column subset does not. if out.Connections[0]["id"] != "c1" || out.Connections[0]["name"] != "Aura prod" { t.Fatalf("expected first connection c1/Aura prod, got %v", out.Connections[0]) } - if out.Connections[0]["project"] != "my-proj" { - t.Fatalf("expected project=my-proj on JSON wire payload, got %v", out.Connections[0]["project"]) + projects, ok := out.Connections[0]["projects"].([]any) + if !ok || len(projects) != 1 || projects[0] != "my-proj" { + t.Fatalf("expected projects=[my-proj] on JSON wire payload, got %v", out.Connections[0]["projects"]) } } @@ -343,7 +344,7 @@ func TestList_Table_TwoSections_BothPopulated(t *testing.T) { } h.withHandler(defaultListHandler(t, `[{"id":"a","name":"Alice"}]`, perID, - `[{"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","project":"my-proj"}]`)) + `[{"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","projects":["my-proj"]}]`)) if err := h.run("list --format table"); err != nil { t.Fatalf("run: %v", err) @@ -368,10 +369,10 @@ func TestList_Table_TwoSections_BothPopulated(t *testing.T) { } } // Connections section uses the connection column set (excludes - // VERSION/STATUS and PROJECT — `project` rides the wire on `--format json` + // VERSION/STATUS and PROJECTS — `projects` rides the wire on `--format json` // only, never as a column per the PRD non-goals). - if strings.Contains(out, "PROJECT") { - t.Fatalf("expected NO PROJECT column header, got: %s", out) + if strings.Contains(out, "PROJECTS") { + t.Fatalf("expected NO PROJECTS column header, got: %s", out) } // Both row payloads visible. if !strings.Contains(out, "Alice") { @@ -381,7 +382,7 @@ func TestList_Table_TwoSections_BothPopulated(t *testing.T) { t.Fatalf("expected connection row name 'Aura prod', got: %s", out) } if strings.Contains(out, "my-proj") { - t.Fatalf("expected NO project value rendered in table output, got: %s", out) + t.Fatalf("expected NO projects value rendered in table output, got: %s", out) } } @@ -511,7 +512,7 @@ func TestList_NoArgs(t *testing.T) { func TestList_PortFlagPropagatesToClientConstructor(t *testing.T) { h := newListHelper(t) var gotPort int - t.Cleanup(desktop.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, errors.New("stop here, we already captured the port") })) diff --git a/internal/commands/desktop/project/create_test.go b/internal/commands/desktop/project/create_test.go new file mode 100644 index 000000000..d91e28d05 --- /dev/null +++ b/internal/commands/desktop/project/create_test.go @@ -0,0 +1,164 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// readRequestBody captures the JSON body of a mutation call so tests can +// assert the wire shape. +func readRequestBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("body json: %v (raw: %s)", err, string(b)) + } + return out +} + +func TestCreate_PostsNameAndPrintsCreatedEntry(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/fastify/api/projects" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + return + } + body := readRequestBody(t, r) + if body["name"] != "my-project" { + t.Errorf("expected POST body name=my-project, got %v", body) + } + if len(body) != 1 { + t.Errorf("expected POST body to carry only `name`, got %v", body) + } + _, _ = w.Write([]byte(`{"projects":[ + {"id":"p1","name":"other","createdAt":1700000000000}, + {"id":"p2","name":"my-project","createdAt":1747843200000} + ],"currentProject":"p2"}`)) + }) + if err := h.run("project create my-project --format json"); err != nil { + t.Fatalf("run: %v", err) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "p2" { + t.Fatalf("expected created id=p2, got %v", got["id"]) + } + if got["name"] != "my-project" { + t.Fatalf("expected name=my-project, got %v", got["name"]) + } + if got["created_at"] != wantCreatedAtRFC3339() { + t.Fatalf("expected created_at=%q, got %v", wantCreatedAtRFC3339(), got["created_at"]) + } +} + +// TestCreate_AmbiguousName_FailsLoud: when the post-create state holds two +// projects with the requested name, create MUST fail with a fatal error +// listing the candidate ids — never print one of them as the created entry. +func TestCreate_AmbiguousName_FailsLoud(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[ + {"id":"p-old","name":"my-project","createdAt":1700000000000}, + {"id":"p-new","name":"my-project","createdAt":1747843200000} + ]}`)) + }) + err := h.run("project create my-project --format json") + assertFatalCatalogError(t, err, `project "my-project" matches 2 entries`, "p-old", "p-new") +} + +// TestCreate_CreatedProjectMissingFromState: when the post-create state lacks +// the requested name, create MUST fail with a fatal error instead of printing +// literal `null` with exit 0. +func TestCreate_CreatedProjectMissingFromState(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"other","createdAt":1700000000000}]}`)) + }) + err := h.run("project create my-project --format json") + assertFatalCatalogError(t, err, `project "my-project" is missing from the catalog state`) + if strings.Contains(h.out.String(), "null") { + t.Fatalf("must not print null on failure, got:\n%s", h.out.String()) + } +} + +func TestCreate_Table_RendersRow(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[{"id":"p2","name":"my-project","createdAt":1747843200000}]}`)) + }) + if err := h.run("project create my-project --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + for _, want := range []string{"p2", "my-project", wantCreatedAtRFC3339()} { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in table output, got:\n%s", want, out) + } + } +} + +// TestCreate_RequiresName guards against the positional being silently +// optional. +func TestCreate_RequiresName(t *testing.T) { + h := newProjectHelper(t) + if err := h.run("project create"); err == nil { + t.Fatalf("expected error when is missing") + } +} + +// TestCreate_Annotated_Write: the create leaf must be tagged write=true so +// the root --rw enforcement fires. +func TestCreate_Annotated_Write(t *testing.T) { + leaf := findProjectLeaf(t, "create") + if leaf.Annotations["write"] != "true" { + t.Fatalf("create must be annotated write=true; got %v", leaf.Annotations) + } +} + +// TestCreate_Example_FlushLeft mirrors the whole-tree example gate. +func TestCreate_Example_FlushLeft(t *testing.T) { + leaf := findProjectLeaf(t, "create") + if leaf.Example == "" { + t.Fatalf("create Example must be non-empty") + } + firstLine := strings.SplitN(leaf.Example, "\n", 2)[0] + if strings.HasPrefix(firstLine, " ") { + t.Fatalf("create Example first line must be flush-left; got %q", firstLine) + } + if c := strings.Count(leaf.Example, "neo4j-cli desktop project create"); c < 2 { + t.Fatalf("create Example must contain >=2 invocations; got %d", c) + } + for _, want := range []string{"--rw", "--format json"} { + if !strings.Contains(leaf.Example, want) { + t.Fatalf("create Example must contain %q; got:\n%s", want, leaf.Example) + } + } +} + +func TestCreate_5xx_SurfacesError(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("oops")) + }) + err := h.run("project create my-project --format json") + if err == nil { + t.Fatalf("expected error on 5xx") + } + if !strings.Contains(err.Error(), "Neo4j Desktop 2") { + t.Fatalf("expected Desktop-flavoured error text, got: %v", err) + } +} diff --git a/internal/commands/desktop/project/delete_test.go b/internal/commands/desktop/project/delete_test.go new file mode 100644 index 000000000..8919db98c --- /dev/null +++ b/internal/commands/desktop/project/delete_test.go @@ -0,0 +1,252 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project_test + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/neo4j/cli/internal/confirm" +) + +// TestDelete_ByUUID_SendsQuerystringDelete: a UUID positional skips the +// catalog lookup; the id rides in the DELETE querystring and the output is a +// slim `{id, deleted}` envelope. +func TestDelete_ByUUID_SendsQuerystringDelete(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + var listCalls atomic.Int32 + var deleteCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + listCalls.Add(1) + t.Errorf("UUID positional must not trigger a catalog lookup; got GET %s", r.URL.Path) + case r.Method == http.MethodDelete && r.URL.Path == "/fastify/api/projects": + deleteCalls.Add(1) + if got := r.URL.Query().Get("id"); got != validProjectID { + t.Errorf("expected DELETE querystring id=%s, got %q", validProjectID, got) + } + _, _ = w.Write([]byte(`{"projects":[]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run("project delete " + validProjectID + " --yes --force --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if listCalls.Load() != 0 { + t.Fatalf("expected no GET /projects for a UUID positional; got %d", listCalls.Load()) + } + if deleteCalls.Load() != 1 { + t.Fatalf("expected exactly 1 DELETE call; got %d", deleteCalls.Load()) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != validProjectID { + t.Fatalf("expected id in output, got %v", got["id"]) + } + if got["deleted"] != true { + t.Fatalf("expected deleted=true in output, got %v", got["deleted"]) + } + if len(got) != 2 { + t.Fatalf("expected exactly 2 keys (id, deleted); got %d: %s", len(got), h.out.String()) + } +} + +// TestDelete_ByName_ResolvesViaCatalog: an exact project name resolves to its +// id through a GET /projects lookup before the DELETE. +func TestDelete_ByName_ResolvesViaCatalog(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + var deletedID string + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"alpha"}]}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/fastify/api/projects": + deletedID = r.URL.Query().Get("id") + _, _ = w.Write([]byte(`{"projects":[]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run("project delete alpha --yes --force --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if deletedID != "p1" { + t.Fatalf("expected resolved id p1 in DELETE querystring, got %q", deletedID) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "p1" { + t.Fatalf("expected id=p1 in output, got %v", got["id"]) + } +} + +// TestDelete_TableConfirmation: default (table) format emits a one-line +// confirmation carrying the removed id. +func TestDelete_TableConfirmation(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && r.URL.Path == "/fastify/api/projects" { + _, _ = w.Write([]byte(`{"projects":[]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + if err := h.run("project delete " + validProjectID + " --yes --force --format table"); err != nil { + t.Fatalf("run: %v", err) + } + got := strings.TrimSpace(h.out.String()) + want := "Deleted project " + validProjectID + "." + if got != want { + t.Fatalf("table confirmation mismatch:\n got: %q\n want: %q", got, want) + } +} + +// TestDelete_NonTTY_OnlyYes_Exit2: both --yes and --force are required for +// non-TTY callers; the confirm gate fires BEFORE any HTTP call. +func TestDelete_NonTTY_OnlyYes_Exit2(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("non-TTY without --force must not hit the API; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run("project delete " + validProjectID + " --yes") + if err == nil { + t.Fatalf("expected usage error when --force is missing") + } + if !strings.Contains(err.Error(), "pass both --yes and --force") { + t.Fatalf("expected error to mention 'pass both --yes and --force', got: %v", err) + } +} + +func TestDelete_NonTTY_OnlyForce_Exit2(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("non-TTY without --yes must not hit the API; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run("project delete " + validProjectID + " --force") + if err == nil { + t.Fatalf("expected usage error when --yes is missing") + } + if !strings.Contains(err.Error(), "pass both --yes and --force") { + t.Fatalf("expected error to mention 'pass both --yes and --force', got: %v", err) + } +} + +// TestDelete_TTY_EmptyStdin_Cancels: TTY + empty stdin → cancelled, no DELETE. +func TestDelete_TTY_EmptyStdin_Cancels(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return true })) + // Empty buffer simulates immediate EOF. + + var deleteCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleteCalls.Add(1) + t.Errorf("DELETE should not fire when stdin EOFs before any input") + } else { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + err := h.run("project delete " + validProjectID + " --format json") + if err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("expected confirm.ErrCancelled on EOF cancel; got: %v", err) + } + if deleteCalls.Load() != 0 { + t.Fatalf("expected zero DELETE on EOF; got %d", deleteCalls.Load()) + } +} + +// TestDelete_UnknownName_UsageError: a name absent from the catalog is a +// usage error before any DELETE. +func TestDelete_UnknownName_UsageError(t *testing.T) { + h := newProjectHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + var deleteCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"alpha"}]}`)) + case r.Method == http.MethodDelete: + deleteCalls.Add(1) + t.Errorf("DELETE must not fire for an unknown name") + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + err := h.run("project delete nope --yes --force --format json") + if err == nil { + t.Fatalf("expected usage error for unknown project name") + } + if !strings.Contains(err.Error(), "unknown project") { + t.Fatalf("expected 'unknown project' error, got: %v", err) + } + if deleteCalls.Load() != 0 { + t.Fatalf("expected zero DELETE calls; got %d", deleteCalls.Load()) + } +} + +// TestDelete_RequiresPositional guards against the positional being silently +// optional. +func TestDelete_RequiresPositional(t *testing.T) { + h := newProjectHelper(t) + if err := h.run("project delete --yes --force"); err == nil { + t.Fatalf("expected error when is missing") + } +} + +// TestDelete_Annotated_Write: the delete leaf must be tagged write=true so +// the root --rw enforcement fires. +func TestDelete_Annotated_Write(t *testing.T) { + leaf := findProjectLeaf(t, "delete") + if leaf.Annotations["write"] != "true" { + t.Fatalf("delete must be annotated write=true; got %v", leaf.Annotations) + } +} + +// TestDelete_Example_FlushLeft mirrors the whole-tree example gate. +func TestDelete_Example_FlushLeft(t *testing.T) { + leaf := findProjectLeaf(t, "delete") + if leaf.Example == "" { + t.Fatalf("delete Example must be non-empty") + } + firstLine := strings.SplitN(leaf.Example, "\n", 2)[0] + if strings.HasPrefix(firstLine, " ") { + t.Fatalf("delete Example first line must be flush-left; got %q", firstLine) + } + if c := strings.Count(leaf.Example, "neo4j-cli desktop project delete"); c < 3 { + t.Fatalf("delete Example must contain >=3 invocations; got %d", c) + } + for _, want := range []string{"--rw", "--format json"} { + if !strings.Contains(leaf.Example, want) { + t.Fatalf("delete Example must contain %q; got:\n%s", want, leaf.Example) + } + } +} diff --git a/internal/commands/desktop/project/helpers_test.go b/internal/commands/desktop/project/helpers_test.go new file mode 100644 index 000000000..d6e8bbf74 --- /dev/null +++ b/internal/commands/desktop/project/helpers_test.go @@ -0,0 +1,148 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/shlex" + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/clierr" + "github.com/neo4j/cli/internal/commands/desktop" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/neo4j/cli/internal/flags" + "github.com/neo4j/cli/internal/testutil/testfs" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// projectHelper wires `desktop.NewCmd` against an in-memory FS, with the +// project subtree's `desktopclient.Connect` seam pinned to a desktopclient.Client +// backed by an httptest server. Mirrors the helper pattern in the sibling +// `connection` package's tests so the project leaves get the same hermetic +// end-to-end coverage. +type projectHelper struct { + t *testing.T + in *bytes.Buffer + out *bytes.Buffer + err *bytes.Buffer + fs afero.Fs +} + +func newProjectHelper(t *testing.T) *projectHelper { + t.Helper() + cobra.EnableTraverseRunHooks = true + fs, err := testfs.GetTestFs(`{"format":"json"}`, "{}") + if err != nil { + t.Fatalf("GetTestFs: %v", err) + } + return &projectHelper{ + t: t, + in: &bytes.Buffer{}, + out: &bytes.Buffer{}, + err: &bytes.Buffer{}, + fs: fs, + } +} + +// withHandler swaps the project subtree's client-constructor seam to a +// closure returning a desktopclient.Client wired to the supplied httptest +// handler. +func (h *projectHelper) withHandler(handler http.HandlerFunc) *httptest.Server { + h.t.Helper() + const ( + salt = "salt-project" + clientID = "cid-project" + ) + h.t.Cleanup(desktopclient.SetUUIDFnForTest(func() string { return clientID })) + 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(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) { + return desktopclient.NewClient(desktopclient.ProbeResult{Origin: srv.URL}, salt) + })) + return srv +} + +func (h *projectHelper) run(command string) error { + h.t.Helper() + args, err := shlex.Split(command) + if err != nil { + h.t.Fatalf("shlex: %v", err) + } + cfg := clicfg.NewConfig(h.fs, "test") + cmd := desktop.NewCmd(cfg) + flags.RegisterOutputFlag(cmd, cfg) + cmd.SetArgs(args) + cmd.SetIn(h.in) + cmd.SetOut(h.out) + cmd.SetErr(h.err) + return cmd.Execute() +} + +// mustTestFs builds the in-memory FS for tests that only inspect the cobra +// tree (no execution). +func mustTestFs(t *testing.T) afero.Fs { + t.Helper() + fs, err := testfs.GetTestFs(`{"format":"json"}`, "{}") + if err != nil { + t.Fatalf("GetTestFs: %v", err) + } + return fs +} + +// findProjectLeaf locates one leaf under the `project` subtree of a freshly +// built desktop command. +func findProjectLeaf(t *testing.T, leafName string) *cobra.Command { + t.Helper() + cfg := clicfg.NewConfig(mustTestFs(t), "test") + parent := desktop.NewCmd(cfg) + for _, c := range parent.Commands() { + if c.Name() != "project" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() == leafName { + return sub + } + } + } + t.Fatalf("desktop project %s command not registered", leafName) + return nil +} + +// wantCreatedAt is the unix-ms fixture timestamp used across the project +// tests; wantCreatedAtRFC3339 is the RFC3339 UTC rendering the CLI must print. +const wantCreatedAt = 1747843200000 + +func wantCreatedAtRFC3339() string { + return time.UnixMilli(wantCreatedAt).UTC().Format(time.RFC3339) +} + +// assertFatalCatalogError pins the create/update finder policy: a mutated +// entry that cannot be identified in Desktop's returned catalog state is a +// clierr FATAL error (exit 1) carrying each wanted substring — never a +// silently printed `null` with exit 0. +func assertFatalCatalogError(t *testing.T, err error, substrings ...string) { + t.Helper() + if err == nil { + t.Fatalf("expected fatal error, got nil") + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 1 { + t.Fatalf("error = %v (%T), want clierr fatal error (exit 1)", err, err) + } + for _, s := range substrings { + if !strings.Contains(err.Error(), s) { + t.Fatalf("error = %q, want substring %q", err.Error(), s) + } + } +} diff --git a/internal/commands/desktop/project/list_test.go b/internal/commands/desktop/project/list_test.go new file mode 100644 index 000000000..8e8b30725 --- /dev/null +++ b/internal/commands/desktop/project/list_test.go @@ -0,0 +1,134 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +func TestProjectList_JSON_RendersProjectArray(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/fastify/api/projects" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + return + } + _, _ = w.Write([]byte(`{"projects":[ + {"id":"p1","name":"alpha","createdAt":1747843200000}, + {"id":"p2","name":"beta"} + ],"currentProject":"p1"}`)) + }) + if err := h.run("project list --format json"); err != nil { + t.Fatalf("run: %v", err) + } + var got []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if len(got) != 2 { + t.Fatalf("expected 2 projects, got %d (raw: %s)", len(got), h.out.String()) + } + if got[0]["id"] != "p1" || got[1]["id"] != "p2" { + t.Fatalf("expected ids [p1 p2], got %v", got) + } + if got[0]["name"] != "alpha" || got[1]["name"] != "beta" { + t.Fatalf("expected names [alpha beta], got %v", got) + } + // created_at renders as RFC3339 UTC; omitted entirely when Desktop does + // not report a timestamp (legacy entries). + if got[0]["created_at"] != wantCreatedAtRFC3339() { + t.Fatalf("expected created_at=%q, got %v", wantCreatedAtRFC3339(), got[0]["created_at"]) + } + if _, ok := got[1]["created_at"]; ok { + t.Fatalf("expected created_at omitted for legacy entry, got %v", got[1]) + } +} + +func TestProjectList_EmptyJSON_RendersEmptyArrayNotNull(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[]}`)) + }) + if err := h.run("project list --format json"); err != nil { + t.Fatalf("run: %v", err) + } + trimmed := strings.TrimSpace(h.out.String()) + if trimmed != "[]" { + t.Fatalf("expected `[]` for empty project list, got %q", trimmed) + } +} + +func TestProjectList_EmptyTable_RendersNonePlaceholder(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[]}`)) + }) + if err := h.run("project list --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + if !strings.Contains(out, "(none)") { + t.Fatalf("expected `(none)` placeholder in empty table, got:\n%s", out) + } + // Column headers must still appear so the user knows what they're looking + // at even with no rows. + for _, col := range []string{"ID", "NAME", "CREATED_AT"} { + if !strings.Contains(strings.ToUpper(out), col) { + t.Fatalf("expected column %q in header, got:\n%s", col, out) + } + } +} + +func TestProjectList_Table_RendersRows(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"alpha","createdAt":1747843200000}]}`)) + }) + if err := h.run("project list --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + for _, want := range []string{"p1", "alpha", wantCreatedAtRFC3339()} { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in table output, got:\n%s", want, out) + } + } +} + +func TestProjectList_5xx_SurfacesError(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("oops")) + }) + err := h.run("project list --format json") + if err == nil { + t.Fatalf("expected error on 5xx") + } + if !strings.Contains(err.Error(), "Neo4j Desktop 2") { + t.Fatalf("expected Desktop-flavoured error text, got: %v", err) + } +} + +func TestProjectList_PortFlagPropagatesToClientConstructor(t *testing.T) { + h := newProjectHelper(t) + var seenPort int + 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") + })) + _ = h.run("project list --port 44225 --format json") + if seenPort != 44225 { + t.Fatalf("expected --port=44225 to reach the client constructor, got %d", seenPort) + } +} diff --git a/internal/commands/desktop/project/output.go b/internal/commands/desktop/project/output.go new file mode 100644 index 000000000..f6f404c97 --- /dev/null +++ b/internal/commands/desktop/project/output.go @@ -0,0 +1,34 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project + +import ( + "time" + + "github.com/neo4j/cli/internal/desktopclient" +) + +// projectOutput is the snake_case CLI-rendered projection of a Project. +// Desktop's CreatedAt is a unix-milliseconds timestamp (omitted for legacy +// entries); the CLI renders it as an RFC3339 UTC string, "" when Desktop +// does not report one. This single projection is shared by list / create / +// update so `created_at` renders identically on every project output. +type projectOutput struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at,omitempty"` +} + +func toProjectOutput(p desktopclient.Project) projectOutput { + return projectOutput{ID: p.ID, Name: p.Name, CreatedAt: formatCreatedAt(p.CreatedAt)} +} + +// formatCreatedAt renders a unix-milliseconds timestamp as RFC3339 UTC; +// a zero/absent timestamp renders as "". +func formatCreatedAt(unixMs int64) string { + if unixMs <= 0 { + return "" + } + return time.UnixMilli(unixMs).UTC().Format(time.RFC3339) +} diff --git a/internal/commands/desktop/project/project.go b/internal/commands/desktop/project/project.go new file mode 100644 index 000000000..d2fe2982e --- /dev/null +++ b/internal/commands/desktop/project/project.go @@ -0,0 +1,112 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +// Package project implements the `neo4j-cli desktop project` subtree as a spec +// over the shared catalogcmd scaffold (see desktop/internal/catalogcmd). +package project + +import ( + "context" + + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/cobra" +) + +// spec declares the project catalog to the catalogcmd scaffold: the noun and +// output fields, the verbatim per-leaf help text, and hooks closing over the +// typed desktopclient project methods. Projects carry no color, so Color is +// nil and the generated leaves have no --color flag. +var spec = catalogcmd.Spec[desktopclient.Project, *desktopclient.ProjectsState]{ + Noun: "project", + Plural: "projects", + Fields: []string{"id", "name", "created_at"}, + UpdateVerb: "Rename", + + ListLong: "List the project catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "Each row carries `id`, `name` and `created_at`; `created_at` is Desktop's unix-milliseconds timestamp rendered as RFC3339 UTC (empty when Desktop omits it for legacy entries). " + + "`--format json` emits a JSON array of projects; `--format toon` mirrors the JSON shape.", + ListExample: `# List Desktop projects as a table +neo4j-cli desktop project list + +# List Desktop projects as JSON (agent-friendly) +neo4j-cli desktop project list --format json + +# List Desktop projects against a pinned port instead of probing 44222..44232 +neo4j-cli desktop project list --port 44225`, + CreateLong: "Create a project in the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "Prints the created project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-create catalog state Desktop returns.", + CreateExample: `# Create a Desktop project +neo4j-cli desktop project create my-project --rw + +# Create a Desktop project and emit the created entry as JSON +neo4j-cli desktop project create my-project --format json --rw`, + UpdateLong: "Rename a project in the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "`` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). " + + "`--name` is required: Desktop's ProjectUpdateSchema demands a name on every PATCH. " + + "Prints the updated project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-update catalog state Desktop returns.", + UpdateExample: `# Rename a Desktop project addressed by its exact name +neo4j-cli desktop project update my-project --name my-renamed-project --rw + +# Rename a Desktop project addressed by UUID, emitting the updated entry as JSON +neo4j-cli desktop project update f4e2f3c0-1111-2222-3333-444455556666 --name my-renamed-project --format json --rw`, + DeleteLong: "Delete a project from the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "`` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). " + + "Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively. " + + "Prints a minimal confirmation envelope carrying the removed id.", + DeleteExample: `# Delete a Desktop project by exact name with an interactive y/N confirmation +neo4j-cli desktop project delete my-project --rw + +# Delete a Desktop project by UUID without prompting (scripts, CI, non-TTY shells) +neo4j-cli desktop project delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a Desktop project and emit a machine-readable confirmation for scripting +neo4j-cli desktop project delete my-project --yes --force --format json --rw`, + + List: func(ctx context.Context, c *desktopclient.Client) ([]desktopclient.Project, error) { + return c.ListProjects(ctx) + }, + Create: func(ctx context.Context, c *desktopclient.Client, name, _ string) (*desktopclient.ProjectsState, error) { + return c.CreateProject(ctx, name) + }, + Update: func(ctx context.Context, c *desktopclient.Client, id, name, _ string) (*desktopclient.ProjectsState, error) { + return c.UpdateProject(ctx, id, name) + }, + Delete: func(ctx context.Context, c *desktopclient.Client, id string) error { + _, err := c.DeleteProject(ctx, id) + return err + }, + Resolve: func(ctx context.Context, c *desktopclient.Client, nameOrID string) (string, error) { + ids, err := c.ResolveProjectIDs(ctx, []string{nameOrID}) + if err != nil { + return "", err + } + return ids[0], nil + }, + FindByName: desktopclient.FindProjectByName, + FindByID: desktopclient.FindProjectByID, + + RowJSON: func(p desktopclient.Project) any { return toProjectOutput(p) }, + RowValues: func(p desktopclient.Project) []string { return []string{p.ID, p.Name, formatCreatedAt(p.CreatedAt)} }, +} + +// NewCmd returns the `desktop project` parent cobra command. +func NewCmd(cfg *clicfg.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Manage projects in the local Neo4j Desktop 2 install", + Long: "Manage the project catalog of the local Neo4j Desktop 2 install via its local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "Projects are Desktop's grouping mechanism for local DBMSes and saved remote connections. " + + "Every read and mutation round-trips the full project catalog; `list` shows it, and `create`/`update`/`delete` print the affected entry. " + + "Write commands (`create`, `update`, `delete`) require `--rw`.", + } + + catalogcmd.Mount(cfg, cmd, spec) + + return cmd +} diff --git a/internal/commands/desktop/project/update_test.go b/internal/commands/desktop/project/update_test.go new file mode 100644 index 000000000..7daa37e22 --- /dev/null +++ b/internal/commands/desktop/project/update_test.go @@ -0,0 +1,190 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project_test + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" +) + +const validProjectID = "f4e2f3c0-1111-2222-3333-444455556666" + +// TestUpdate_ByUUID_PatchesIDAndName: a UUID positional skips the catalog +// lookup; the PATCH body carries `{id, name}` and the printed entry is +// resolved from the post-update state by id. +func TestUpdate_ByUUID_PatchesIDAndName(t *testing.T) { + h := newProjectHelper(t) + var listCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + listCalls.Add(1) + t.Errorf("UUID positional must not trigger a catalog lookup; got GET %s", r.URL.Path) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/projects": + body := readRequestBody(t, r) + if body["id"] != validProjectID || body["name"] != "renamed" { + t.Errorf("expected PATCH body {id, name}, got %v", body) + } + if len(body) != 2 { + t.Errorf("expected PATCH body to carry only id+name, got %v", body) + } + _, _ = w.Write([]byte(`{"projects":[{"id":"` + validProjectID + `","name":"renamed","createdAt":1747843200000}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + if err := h.run("project update " + validProjectID + " --name renamed --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if listCalls.Load() != 0 { + t.Fatalf("expected no GET /projects for a UUID positional; got %d", listCalls.Load()) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != validProjectID || got["name"] != "renamed" { + t.Fatalf("expected updated entry, got %v", got) + } + if got["created_at"] != wantCreatedAtRFC3339() { + t.Fatalf("expected created_at=%q, got %v", wantCreatedAtRFC3339(), got["created_at"]) + } +} + +// TestUpdate_ByName_ResolvesViaCatalog: an exact project name resolves to its +// id through a GET /projects lookup before the PATCH. +func TestUpdate_ByName_ResolvesViaCatalog(t *testing.T) { + h := newProjectHelper(t) + var patchBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"alpha","createdAt":1700000000000}]}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/projects": + patchBody = readRequestBody(t, r) + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"renamed","createdAt":1700000000000}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + if err := h.run("project update alpha --name renamed --format json"); err != nil { + t.Fatalf("run: %v", err) + } + if patchBody["id"] != "p1" { + t.Fatalf("expected resolved id p1 in PATCH body, got %v", patchBody) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "p1" || got["name"] != "renamed" { + t.Fatalf("expected updated entry, got %v", got) + } +} + +// TestUpdate_UpdatedProjectMissingFromState: when the post-update state lacks +// the patched id, update MUST fail with a fatal error instead of printing +// literal `null` with exit 0. +func TestUpdate_UpdatedProjectMissingFromState(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"p-other","name":"renamed","createdAt":1747843200000}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + err := h.run("project update " + validProjectID + " --name renamed --format json") + assertFatalCatalogError(t, err, `project "`+validProjectID+`" is missing from the catalog state`) + if strings.Contains(h.out.String(), "null") { + t.Fatalf("must not print null on failure, got:\n%s", h.out.String()) + } +} + +// TestUpdate_UnknownName_UsageError: a name absent from the catalog is a +// usage error before any PATCH. +func TestUpdate_UnknownName_UsageError(t *testing.T) { + h := newProjectHelper(t) + var patchCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"alpha"}]}`)) + case r.Method == http.MethodPatch: + patchCalls.Add(1) + t.Errorf("PATCH must not fire for an unknown name") + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + err := h.run("project update nope --name renamed --format json") + if err == nil { + t.Fatalf("expected usage error for unknown project name") + } + if !strings.Contains(err.Error(), "unknown project") { + t.Fatalf("expected 'unknown project' error, got: %v", err) + } + if patchCalls.Load() != 0 { + t.Fatalf("expected zero PATCH calls; got %d", patchCalls.Load()) + } +} + +// TestUpdate_RequiresName: cobra's MarkFlagRequired enforcement — missing +// --name is a usage error before any HTTP call. +func TestUpdate_RequiresName(t *testing.T) { + h := newProjectHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("must not hit the API when --name is missing; got %s %s", r.Method, r.URL.Path) + }) + err := h.run("project update " + validProjectID) + if err == nil { + t.Fatalf("expected error when --name is missing") + } + if !strings.Contains(err.Error(), "name") { + t.Fatalf("expected error to mention --name, got: %v", err) + } +} + +// TestUpdate_RequiresPositional guards against the positional being silently +// optional. +func TestUpdate_RequiresPositional(t *testing.T) { + h := newProjectHelper(t) + if err := h.run("project update --name renamed"); err == nil { + t.Fatalf("expected error when is missing") + } +} + +// TestUpdate_Annotated_Write: the update leaf must be tagged write=true so +// the root --rw enforcement fires. +func TestUpdate_Annotated_Write(t *testing.T) { + leaf := findProjectLeaf(t, "update") + if leaf.Annotations["write"] != "true" { + t.Fatalf("update must be annotated write=true; got %v", leaf.Annotations) + } +} + +// TestUpdate_Example_FlushLeft mirrors the whole-tree example gate. +func TestUpdate_Example_FlushLeft(t *testing.T) { + leaf := findProjectLeaf(t, "update") + if leaf.Example == "" { + t.Fatalf("update Example must be non-empty") + } + firstLine := strings.SplitN(leaf.Example, "\n", 2)[0] + if strings.HasPrefix(firstLine, " ") { + t.Fatalf("update Example first line must be flush-left; got %q", firstLine) + } + if c := strings.Count(leaf.Example, "neo4j-cli desktop project update"); c < 2 { + t.Fatalf("update Example must contain >=2 invocations; got %d", c) + } + for _, want := range []string{"--rw", "--format json"} { + if !strings.Contains(leaf.Example, want) { + t.Fatalf("update Example must contain %q; got:\n%s", want, leaf.Example) + } + } +} diff --git a/internal/commands/desktop/tag/create_test.go b/internal/commands/desktop/tag/create_test.go new file mode 100644 index 000000000..9bd5e1163 --- /dev/null +++ b/internal/commands/desktop/tag/create_test.go @@ -0,0 +1,232 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package tag_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/neo4j/cli/internal/clierr" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +// assertFatalCatalogErr pins the create/update finder policy: a mutated entry +// that cannot be identified in Desktop's returned catalog state is a clierr +// FATAL error (exit 1) carrying each wanted substring — never a silently +// printed stale entry. Lives here (not helpers_test.go) so the helper file +// stays untouched by this change. +func assertFatalCatalogErr(t *testing.T, err error, substrings ...string) { + t.Helper() + if err == nil { + t.Fatalf("expected fatal error, got nil") + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 1 { + t.Fatalf("error = %v (%T), want clierr fatal error (exit 1)", err, err) + } + for _, s := range substrings { + if !strings.Contains(err.Error(), s) { + t.Fatalf("error = %q, want substring %q", err.Error(), s) + } + } +} + +// readBody captures the JSON body of a request so tests can assert the wire +// shape — minimum fields populated, no unexpected extras. +func readBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("body json: %v (raw: %s)", err, string(b)) + } + return out +} + +// TestCreate_RequiresName guards against the positional being silently +// optional. +func TestCreate_RequiresName(t *testing.T) { + h := newTagHelper(t) + if err := h.run(`tag create`); err == nil { + t.Fatalf("expected error when is missing") + } +} + +// TestCreate_SuccessfulCreate covers the happy path: POST /tags carries only +// the name when --color is unset, and the created tag is resolved out of the +// returned catalog state by name. +func TestCreate_SuccessfulCreate(t *testing.T) { + h := newTagHelper(t) + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/fastify/api/tags" { + capturedBody = readBody(t, r) + _, _ = w.Write([]byte(`{"tags":[{"id":"t0","name":"other"},{"id":"t1","name":"Prod","color":"5"}]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + if err := h.run(`tag create Prod --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if capturedBody == nil { + t.Fatalf("expected POST /tags to be called") + } + if capturedBody["name"] != "Prod" { + t.Fatalf("name mismatch in body: %v", capturedBody["name"]) + } + if _, hasColor := capturedBody["color"]; hasColor { + t.Fatalf("color must NOT be in body when --color not supplied; got: %v", capturedBody) + } + if len(capturedBody) != 1 { + t.Fatalf("expected exactly 1 key when --color omitted; got %d: %v", len(capturedBody), capturedBody) + } + + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "t1" { + t.Fatalf("expected created tag id resolved from state, got %v", got["id"]) + } + if got["name"] != "Prod" { + t.Fatalf("expected name in output, got %v", got["name"]) + } + if got["color"] != "5" { + t.Fatalf("expected color in output, got %v", got["color"]) + } +} + +// TestCreate_WithColor verifies that a valid --color is forwarded verbatim. +func TestCreate_WithColor(t *testing.T) { + h := newTagHelper(t) + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/fastify/api/tags" { + capturedBody = readBody(t, r) + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"Prod","color":"12"}]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + if err := h.run(`tag create Prod --color 12 --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if capturedBody["color"] != "12" { + t.Fatalf("expected color=12 in body, got %v", capturedBody["color"]) + } +} + +// TestCreate_InvalidColor_UsageError covers the palette gate: anything +// outside "1".."12" must fail with a usage error BEFORE any HTTP call. +func TestCreate_InvalidColor_UsageError(t *testing.T) { + cases := []string{"0", "13", "blue", "#ff0000", "5.5"} + for _, color := range cases { + t.Run(color, func(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("must not hit HTTP when --color is invalid; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag create Prod --color "` + color + `"`) + if err == nil { + t.Fatalf("expected usage error for --color %q", color) + } + if !strings.Contains(err.Error(), "--color") { + t.Fatalf("expected error to mention --color, got: %v", err) + } + if !strings.Contains(err.Error(), "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12") { + t.Fatalf("expected error to list valid palette values, got: %v", err) + } + }) + } +} + +// TestCreate_CreatedTagMissingFromState covers the drift path: Desktop's +// post-create state must contain the new tag; if not, surface a fatal error +// rather than printing `null`. +func TestCreate_CreatedTagMissingFromState(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/fastify/api/tags" { + _, _ = w.Write([]byte(`{"tags":[]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag create Prod --format json`) + if err == nil { + t.Fatalf("expected error when created tag is missing from returned state") + } + if !strings.Contains(err.Error(), "missing from the catalog state") { + t.Fatalf("unexpected error text: %v", err) + } +} + +// TestCreate_PreExistingSameName_FailsLoud: when the post-create state holds +// two tags with the requested name (e.g. a pre-existing same-named tag), +// create MUST fail with a fatal error listing the candidate ids — it must +// never print the stale first match as the created tag. +func TestCreate_PreExistingSameName_FailsLoud(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/fastify/api/tags" { + _, _ = w.Write([]byte(`{"tags":[{"id":"t-old","name":"Prod","color":"1"},{"id":"t-new","name":"Prod","color":"5"}]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag create Prod --format json`) + assertFatalCatalogErr(t, err, `tag "Prod" matches 2 entries`, "t-old", "t-new") + if strings.Contains(h.out.String(), "t-old") { + t.Fatalf("must not print the stale pre-existing tag, got:\n%s", h.out.String()) + } +} + +// TestCreate_PortFlagPropagates verifies the parent's persistent --port flag +// reaches the client constructor seam. +func TestCreate_PortFlagPropagates(t *testing.T) { + h := newTagHelper(t) + var gotPort int + t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, port int) (*desktopclient.Client, error) { + gotPort = port + return nil, desktopclient.UnreachableError() + })) + + _ = h.run(`tag create Prod --port 44231 --format json`) + if gotPort != 44231 { + t.Fatalf("expected --port=44231 to propagate; got %d", gotPort) + } +} + +// TestCreate_DesktopUnreachable_ReturnsCanonicalError verifies the canonical +// error mapping when Desktop is not running. +func TestCreate_DesktopUnreachable_ReturnsCanonicalError(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) { + return nil, desktopclient.UnreachableError() + })) + + err := h.run(`tag create Prod --format json`) + if err == nil { + t.Fatalf("expected error when desktop is unreachable") + } + if !strings.Contains(err.Error(), "doesn't appear to be running") { + t.Fatalf("expected canonical unreachable message, got: %v", err) + } +} diff --git a/internal/commands/desktop/tag/delete_test.go b/internal/commands/desktop/tag/delete_test.go new file mode 100644 index 000000000..324006220 --- /dev/null +++ b/internal/commands/desktop/tag/delete_test.go @@ -0,0 +1,262 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package tag_test + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/neo4j/cli/internal/confirm" + "github.com/neo4j/cli/internal/confirm/confirmtest" +) + +// TestDelete_RequiresPositional guards against the positional being silently +// optional. +func TestDelete_RequiresPositional(t *testing.T) { + h := newTagHelper(t) + if err := h.run(`tag delete`); err == nil { + t.Fatalf("expected error when is missing") + } +} + +// TestDelete_ConfirmGate is the canonical (TTY × flag-state) replay shared +// across every destructive leaf. The UUID positional keeps ResolveTagIDs off +// the network so the only wire call is the DELETE itself. +func TestDelete_ConfirmGate(t *testing.T) { + confirmtest.AssertLeafGate(t, confirmtest.LeafGateCase{ + Name: "desktop tag delete", + NoFlagsArgs: "tag delete " + validTagID + " --format json", + BothFlagsArgs: "tag delete " + validTagID + " --yes --force --format json", + ResourceLabel: "tag", + Run: func(t *testing.T, args, stdin string) confirmtest.GateRunResult { + h := newTagHelper(t) + h.in.WriteString(stdin) + var deleteCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleteCalls.Add(1) + _, _ = w.Write([]byte(`{"tags":[]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + err := h.run(args) + return confirmtest.GateRunResult{Err: err, Stderr: h.err.String(), Invoked: deleteCalls.Load() > 0} + }, + }) +} + +// TestDelete_NonTTY_OnlyYes_Exit2: both --yes and --force are required for +// non-TTY callers; the confirm gate fires BEFORE any client construction or +// catalog lookup, so the API sees zero requests. A NAME positional makes the +// ordering observable: pre-convergence the resolve GET ran before the gate. +func TestDelete_NonTTY_OnlyYes_Exit2(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("non-TTY without --force must not hit the API; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag delete staging --yes`) + if err == nil { + t.Fatalf("expected usage error when --force is missing") + } + if !strings.Contains(err.Error(), "pass both --yes and --force") { + t.Fatalf("expected error to mention 'pass both --yes and --force', got: %v", err) + } +} + +func TestDelete_NonTTY_OnlyForce_Exit2(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("non-TTY without --yes must not hit the API; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag delete staging --force`) + if err == nil { + t.Fatalf("expected usage error when --yes is missing") + } + if !strings.Contains(err.Error(), "pass both --yes and --force") { + t.Fatalf("expected error to mention 'pass both --yes and --force', got: %v", err) + } +} + +// TestDelete_TTY_EmptyStdin_Cancels: TTY + empty stdin → cancelled, no DELETE. +func TestDelete_TTY_EmptyStdin_Cancels(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return true })) + // Empty buffer simulates immediate EOF. + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("no request should fire when stdin EOFs before any input; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag delete ` + validTagID + ` --format json`) + if err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("expected confirm.ErrCancelled on EOF cancel; got: %v", err) + } +} + +// TestDelete_TTY_Decline_NoHTTP: a TTY caller answering "n" cancels before +// any client construction or catalog lookup — the name positional would have +// triggered a resolve GET under the old ordering, so zero requests proves +// confirm-first. The prompt must echo what the user typed, not a resolved id. +func TestDelete_TTY_Decline_NoHTTP(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return true })) + h.in.WriteString("n\n") + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("no request should fire when the TTY prompt is declined; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag delete staging --format json`) + if err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("expected confirm.ErrCancelled on decline; got: %v", err) + } + if !strings.Contains(h.err.String(), `tag "staging"`) { + t.Fatalf("expected prompt to echo the typed name; stderr=%q", h.err.String()) + } +} + +// TestDelete_ByUUID_SendsQuerystring covers the wire shape: the id rides in +// the QUERYSTRING (`DELETE /tags?id=`) with no body, and the JSON +// output is the slim `{id, deleted}` envelope (aligned with project delete). +func TestDelete_ByUUID_SendsQuerystring(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + var deleteCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + t.Errorf("UUID positional must not trigger a catalog GET; got GET %s", r.URL.Path) + case r.Method == http.MethodDelete && r.URL.Path == "/fastify/api/tags": + deleteCalls.Add(1) + if got := r.URL.Query().Get("id"); got != validTagID { + t.Errorf("expected querystring id=%s, got %q", validTagID, got) + } + _, _ = w.Write([]byte(`{"tags":[]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`tag delete ` + validTagID + ` --yes --force --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if deleteCalls.Load() != 1 { + t.Fatalf("expected exactly 1 DELETE call; got %d", deleteCalls.Load()) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != validTagID { + t.Fatalf("expected removed id in output, got %v", got) + } + if got["deleted"] != true { + t.Fatalf("expected deleted=true in output, got %v", got["deleted"]) + } + if len(got) != 2 { + t.Fatalf("expected exactly 2 keys (id, deleted) in the slim envelope; got %d: %s", len(got), h.out.String()) + } +} + +// TestDelete_ByName_ResolvesThenDeletes covers the name path: the positional +// is resolved to an id via ResolveTagIDs (one GET /tags) before the DELETE. +func TestDelete_ByName_ResolvesThenDeletes(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + var deleteCalls atomic.Int32 + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"staging"}]}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/fastify/api/tags": + deleteCalls.Add(1) + if got := r.URL.Query().Get("id"); got != "t1" { + t.Errorf("expected querystring id=t1, got %q", got) + } + _, _ = w.Write([]byte(`{"tags":[]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`tag delete staging --yes --force --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if deleteCalls.Load() != 1 { + t.Fatalf("expected exactly 1 DELETE call; got %d", deleteCalls.Load()) + } + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "t1" { + t.Fatalf("expected removed id t1 in output, got %v", got) + } + if got["deleted"] != true { + t.Fatalf("expected deleted=true in output, got %v", got["deleted"]) + } +} + +// TestDelete_UnknownName_UsageError covers the ResolveTagIDs miss: an +// unknown name must surface a usage error before any DELETE. +func TestDelete_UnknownName_UsageError(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"staging"}]}`)) + case r.Method == http.MethodDelete: + t.Errorf("must not DELETE when the name does not resolve") + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + err := h.run(`tag delete nope --yes --force --format json`) + if err == nil { + t.Fatalf("expected usage error for unknown tag name") + } + if !strings.Contains(err.Error(), "unknown tag") { + t.Fatalf("expected 'unknown tag' error, got: %v", err) + } +} + +// TestDelete_TableConfirmation: default (table) format emits a one-line +// confirmation carrying the removed id. +func TestDelete_TableConfirmation(t *testing.T) { + h := newTagHelper(t) + t.Cleanup(confirm.SetStdinIsTerminal(func() bool { return false })) + + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && r.URL.Path == "/fastify/api/tags" { + _, _ = w.Write([]byte(`{"tags":[]}`)) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + + if err := h.run(`tag delete ` + validTagID + ` --yes --force --format table`); err != nil { + t.Fatalf("run: %v", err) + } + got := strings.TrimSpace(h.out.String()) + want := `Deleted tag ` + validTagID + `.` + if got != want { + t.Fatalf("table confirmation mismatch:\n got: %q\n want: %q", got, want) + } +} diff --git a/internal/commands/desktop/tag/helpers_test.go b/internal/commands/desktop/tag/helpers_test.go new file mode 100644 index 000000000..4fae24896 --- /dev/null +++ b/internal/commands/desktop/tag/helpers_test.go @@ -0,0 +1,89 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package tag_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/shlex" + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/commands/desktop" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/neo4j/cli/internal/flags" + "github.com/neo4j/cli/internal/testutil/testfs" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// validTagID is a well-formed UUID usable as a positional wherever a test +// needs the UUID fast-path (ResolveTagIDs passes UUIDs through without a +// catalog fetch). +const validTagID = "f4e2f3c0-1111-2222-3333-444455556666" + +// tagHelper wires `desktop.NewCmd` against an in-memory FS, with the tag +// subtree's `desktopclient.Connect` seam pinned to a desktopclient.Client backed +// by an httptest server. Mirrors the connection package's helper pattern so +// the tag leaves get the same hermetic end-to-end coverage. +type tagHelper struct { + t *testing.T + in *bytes.Buffer + out *bytes.Buffer + err *bytes.Buffer + fs afero.Fs +} + +func newTagHelper(t *testing.T) *tagHelper { + t.Helper() + cobra.EnableTraverseRunHooks = true + fs, err := testfs.GetTestFs(`{"format":"json"}`, "{}") + if err != nil { + t.Fatalf("GetTestFs: %v", err) + } + return &tagHelper{ + t: t, + in: &bytes.Buffer{}, + out: &bytes.Buffer{}, + err: &bytes.Buffer{}, + fs: fs, + } +} + +// withHandler swaps the tag subtree's client-constructor seam to a closure +// returning a desktopclient.Client wired to the supplied httptest handler. +func (h *tagHelper) withHandler(handler http.HandlerFunc) *httptest.Server { + h.t.Helper() + const ( + salt = "salt-tag" + clientID = "cid-tag" + ) + h.t.Cleanup(desktopclient.SetUUIDFnForTest(func() string { return clientID })) + 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(desktopclient.SetConnectFnForTest(func(_ context.Context, _ afero.Fs, _ int) (*desktopclient.Client, error) { + return desktopclient.NewClient(desktopclient.ProbeResult{Origin: srv.URL}, salt) + })) + return srv +} + +func (h *tagHelper) run(command string) error { + h.t.Helper() + args, err := shlex.Split(command) + if err != nil { + h.t.Fatalf("shlex: %v", err) + } + cfg := clicfg.NewConfig(h.fs, "test") + cmd := desktop.NewCmd(cfg) + flags.RegisterOutputFlag(cmd, cfg) + cmd.SetArgs(args) + cmd.SetIn(h.in) + cmd.SetOut(h.out) + cmd.SetErr(h.err) + return cmd.Execute() +} diff --git a/internal/commands/desktop/tag/list_test.go b/internal/commands/desktop/tag/list_test.go new file mode 100644 index 000000000..1578a6028 --- /dev/null +++ b/internal/commands/desktop/tag/list_test.go @@ -0,0 +1,128 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package tag_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/afero" +) + +func TestTagList_JSON_RendersTagArray(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/fastify/api/tags" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + return + } + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"Prod","color":"5"},{"id":"t2","name":"Dev"}],"filter":{"selectedTagIds":["t1"]}}`)) + }) + if err := h.run("tag list --format json"); err != nil { + t.Fatalf("run: %v", err) + } + var got []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if len(got) != 2 { + t.Fatalf("expected 2 tags, got %d (raw: %s)", len(got), h.out.String()) + } + if got[0]["id"] != "t1" || got[0]["name"] != "Prod" || got[0]["color"] != "5" { + t.Fatalf("unexpected first tag: %v", got[0]) + } + if got[1]["id"] != "t2" || got[1]["name"] != "Dev" { + t.Fatalf("unexpected second tag: %v", got[1]) + } +} + +func TestTagList_EmptyJSON_RendersEmptyArrayNotNull(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"tags":[]}`)) + }) + if err := h.run("tag list --format json"); err != nil { + t.Fatalf("run: %v", err) + } + trimmed := strings.TrimSpace(h.out.String()) + if trimmed != "[]" { + t.Fatalf("expected `[]` for empty tag list, got %q", trimmed) + } +} + +func TestTagList_Table_RendersOneRowPerTag(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"Prod","color":"5"},{"id":"t2","name":"Dev"}]}`)) + }) + if err := h.run("tag list --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + for _, col := range []string{"ID", "NAME", "COLOR"} { + if !strings.Contains(strings.ToUpper(out), col) { + t.Fatalf("expected column %q in header, got:\n%s", col, out) + } + } + for _, cell := range []string{"t1", "Prod", "5", "t2", "Dev"} { + if !strings.Contains(out, cell) { + t.Fatalf("expected cell %q in table, got:\n%s", cell, out) + } + } +} + +func TestTagList_EmptyTable_RendersNonePlaceholder(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"tags":[]}`)) + }) + if err := h.run("tag list --format table"); err != nil { + t.Fatalf("run: %v", err) + } + out := h.out.String() + if !strings.Contains(out, "(none)") { + t.Fatalf("expected `(none)` placeholder in empty table, got:\n%s", out) + } + // Column headers must still appear so the user knows what they're looking + // at even with no rows. + for _, col := range []string{"ID", "NAME", "COLOR"} { + if !strings.Contains(strings.ToUpper(out), col) { + t.Fatalf("expected column %q in header, got:\n%s", col, out) + } + } +} + +func TestTagList_5xx_SurfacesError(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("oops")) + }) + err := h.run("tag list --format json") + if err == nil { + t.Fatalf("expected error on 5xx") + } + if !strings.Contains(err.Error(), "Neo4j Desktop 2") { + t.Fatalf("expected Desktop-flavoured error text, got: %v", err) + } +} + +func TestTagList_PortFlagPropagatesToClientConstructor(t *testing.T) { + h := newTagHelper(t) + var seenPort int + 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") + })) + _ = h.run("tag list --port 44225 --format json") + if seenPort != 44225 { + t.Fatalf("expected --port=44225 to reach the client constructor, got %d", seenPort) + } +} diff --git a/internal/commands/desktop/tag/tag.go b/internal/commands/desktop/tag/tag.go new file mode 100644 index 000000000..c7c9a6123 --- /dev/null +++ b/internal/commands/desktop/tag/tag.go @@ -0,0 +1,124 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +// Package tag implements the `neo4j-cli desktop tag` subtree as a spec over +// the shared catalogcmd scaffold (see desktop/internal/catalogcmd). +package tag + +import ( + "context" + + "github.com/neo4j/cli/internal/clicfg" + "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" + "github.com/neo4j/cli/internal/desktopclient" + "github.com/spf13/cobra" +) + +// spec declares the tag catalog to the catalogcmd scaffold: the noun and +// output fields, the verbatim per-leaf help text, and hooks closing over the +// typed desktopclient tag methods. Tags carry an optional palette color, so +// Color is set and the generated create/update leaves take --color. +var spec = catalogcmd.Spec[desktopclient.Tag, *desktopclient.TagsState]{ + Noun: "tag", + Plural: "tags", + Fields: []string{"id", "name", "color"}, + UpdateVerb: "Update", + + ListLong: "List the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "One row per tag with `id`, `name` and `color` (Desktop's palette index \"1\"..\"12\"; empty when the tag carries no color). " + + "`--format json` emits a JSON array of `Tag` objects. " + + "`--format toon` mirrors the JSON shape.", + ListExample: `# List tags as a table +neo4j-cli desktop tag list + +# List tags as JSON (full Tag payload, agent-friendly) +neo4j-cli desktop tag list --format json + +# List tags against a pinned port instead of probing 44222..44232 +neo4j-cli desktop tag list --port 44225`, + CreateLong: "Create a tag in the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "`--color` is optional and accepts only Desktop's palette indices \"1\"..\"12\" (not a hex colour); when omitted the POST body carries no color key and Desktop picks a default. " + + "Prints the created tag (`id`, `name`, `color`) resolved from the post-create catalog state Desktop returns.", + CreateExample: `# Create a tag and let Desktop pick the default color +neo4j-cli desktop tag create production --rw + +# Create a tag with an explicit palette color +neo4j-cli desktop tag create staging --color 5 --rw + +# Create a tag and emit the created tag as JSON +neo4j-cli desktop tag create staging --color 5 --format json --rw`, + UpdateLong: "Update a tag in the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). " + + "`--name` is required (Desktop's TagUpdateSchema demands it); `--color` accepts only Desktop's palette indices \"1\"..\"12\" and is omitted from the PATCH body when unset. " + + "Prints the updated tag (`id`, `name`, `color`) resolved from the post-update catalog state Desktop returns.", + UpdateExample: `# Rename a tag selected by name +neo4j-cli desktop tag update staging --name pre-prod --rw + +# Rename and recolor a tag selected by UUID, emitting the updated tag as JSON +neo4j-cli desktop tag update f4e2f3c0-1111-2222-3333-444455556666 --name pre-prod --color 7 --format json --rw`, + DeleteLong: "Delete a tag from the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). " + + "Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively; the confirmation fires before any Desktop contact. " + + "Prints a minimal confirmation envelope carrying the removed id.", + DeleteExample: `# Delete a tag by name with an interactive y/N confirmation +neo4j-cli desktop tag delete staging --rw + +# Delete a tag by UUID without prompting (scripts, CI, non-TTY shells) +neo4j-cli desktop tag delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a tag and emit a machine-readable confirmation for scripting +neo4j-cli desktop tag delete staging --yes --force --format json --rw`, + + Color: &catalogcmd.ColorSpec{ + CreateUsage: `Optional tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the POST body when unset`, + UpdateUsage: `New tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the PATCH body when unset`, + }, + + List: func(ctx context.Context, c *desktopclient.Client) ([]desktopclient.Tag, error) { + return c.ListTags(ctx) + }, + Create: func(ctx context.Context, c *desktopclient.Client, name, color string) (*desktopclient.TagsState, error) { + return c.CreateTag(ctx, name, color) + }, + Update: func(ctx context.Context, c *desktopclient.Client, id, name, color string) (*desktopclient.TagsState, error) { + return c.UpdateTag(ctx, id, name, color) + }, + Delete: func(ctx context.Context, c *desktopclient.Client, id string) error { + _, err := c.DeleteTag(ctx, id) + return err + }, + Resolve: func(ctx context.Context, c *desktopclient.Client, nameOrID string) (string, error) { + ids, err := c.ResolveTagIDs(ctx, []string{nameOrID}) + if err != nil { + return "", err + } + return ids[0], nil + }, + FindByName: desktopclient.FindTagByName, + FindByID: desktopclient.FindTagByID, + + RowJSON: func(t desktopclient.Tag) any { return t }, + RowValues: func(t desktopclient.Tag) []string { return []string{t.ID, t.Name, t.Color} }, +} + +// NewCmd returns the `desktop tag` parent cobra command with all leaves +// mounted. The desktop root's persistent --port flag is inherited; tag does +// not re-register it. +func NewCmd(cfg *clicfg.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "tag", + Short: "Manage the tag catalog of a Neo4j Desktop 2 install", + Long: "Manage the tag catalog of the local Neo4j Desktop 2 install — list, create, update, delete. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "Tags organise DBMSes and saved remote connections in Desktop's UI; a tag's color is one of Desktop's palette indices \"1\"..\"12\" (not a hex colour). " + + "Write commands (`create`, `update`, `delete`) require `--rw`.", + } + + catalogcmd.Mount(cfg, cmd, spec) + + return cmd +} diff --git a/internal/commands/desktop/tag/update_test.go b/internal/commands/desktop/tag/update_test.go new file mode 100644 index 000000000..ed8089775 --- /dev/null +++ b/internal/commands/desktop/tag/update_test.go @@ -0,0 +1,181 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package tag_test + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" +) + +// TestUpdate_RequiresNameFlag covers cobra's MarkFlagRequired enforcement: +// --name is mandatory because Desktop's TagUpdateSchema demands it. +func TestUpdate_RequiresNameFlag(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("must not hit HTTP when --name is missing; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag update ` + validTagID) + if err == nil { + t.Fatalf("expected required-flag error when --name is missing") + } + if !strings.Contains(err.Error(), "required") || !strings.Contains(err.Error(), `"name"`) { + t.Fatalf("expected required-flag error mentioning \"name\", got: %v", err) + } +} + +// TestUpdate_ByUUID covers the UUID fast-path: ResolveTagIDs passes the UUID +// through without a catalog GET, and the PATCH body carries {id, name} only +// when --color is unset. +func TestUpdate_ByUUID(t *testing.T) { + h := newTagHelper(t) + var listCalls atomic.Int32 + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + listCalls.Add(1) + t.Errorf("UUID positional must not trigger a catalog GET; got GET %s", r.URL.Path) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/tags": + capturedBody = readBody(t, r) + _, _ = w.Write([]byte(`{"tags":[{"id":"` + validTagID + `","name":"pre-prod","color":"5"}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`tag update ` + validTagID + ` --name pre-prod --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if listCalls.Load() != 0 { + t.Fatalf("UUID positional must not trigger a catalog GET; got %d", listCalls.Load()) + } + if capturedBody["id"] != validTagID { + t.Fatalf("id mismatch in body: %v", capturedBody["id"]) + } + if capturedBody["name"] != "pre-prod" { + t.Fatalf("name mismatch in body: %v", capturedBody["name"]) + } + if _, hasColor := capturedBody["color"]; hasColor { + t.Fatalf("color must NOT be in body when --color not supplied; got: %v", capturedBody) + } + if len(capturedBody) != 2 { + t.Fatalf("expected exactly 2 keys when --color omitted; got %d: %v", len(capturedBody), capturedBody) + } + + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != validTagID || got["name"] != "pre-prod" || got["color"] != "5" { + t.Fatalf("unexpected output: %v", got) + } +} + +// TestUpdate_ByName_ResolvesViaCatalog covers the name path: the positional +// is resolved to an id via ResolveTagIDs (one GET /tags) before the PATCH. +func TestUpdate_ByName_ResolvesViaCatalog(t *testing.T) { + h := newTagHelper(t) + var capturedBody map[string]any + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"staging","color":"2"}]}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/tags": + capturedBody = readBody(t, r) + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"pre-prod","color":"7"}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + if err := h.run(`tag update staging --name pre-prod --color 7 --format json`); err != nil { + t.Fatalf("run: %v", err) + } + if capturedBody["id"] != "t1" { + t.Fatalf("expected resolved id t1 in body, got %v", capturedBody["id"]) + } + if capturedBody["name"] != "pre-prod" { + t.Fatalf("name mismatch in body: %v", capturedBody["name"]) + } + if capturedBody["color"] != "7" { + t.Fatalf("expected color=7 in body, got %v", capturedBody["color"]) + } + + var got map[string]any + if err := json.Unmarshal(bytes.TrimSpace(h.out.Bytes()), &got); err != nil { + t.Fatalf("json out: %v (raw: %s)", err, h.out.String()) + } + if got["id"] != "t1" || got["name"] != "pre-prod" || got["color"] != "7" { + t.Fatalf("unexpected output: %v", got) + } +} + +// TestUpdate_UpdatedTagMissingFromState: when the post-update state lacks the +// patched id, update MUST fail with a fatal error instead of printing `null`. +func TestUpdate_UpdatedTagMissingFromState(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/fastify/api/tags": + _, _ = w.Write([]byte(`{"tags":[{"id":"t-other","name":"pre-prod"}]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + err := h.run(`tag update ` + validTagID + ` --name pre-prod --format json`) + assertFatalCatalogErr(t, err, `tag "`+validTagID+`" is missing from the catalog state`) + if strings.Contains(h.out.String(), "null") { + t.Fatalf("must not print null on failure, got:\n%s", h.out.String()) + } +} + +// TestUpdate_UnknownName_UsageError covers the ResolveTagIDs miss: an +// unknown name must surface a usage error before any PATCH. +func TestUpdate_UnknownName_UsageError(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fastify/api/tags": + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"staging"}]}`)) + case r.Method == http.MethodPatch: + t.Errorf("must not PATCH when the name does not resolve") + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + + err := h.run(`tag update nope --name x --format json`) + if err == nil { + t.Fatalf("expected usage error for unknown tag name") + } + if !strings.Contains(err.Error(), "unknown tag") { + t.Fatalf("expected 'unknown tag' error, got: %v", err) + } +} + +// TestUpdate_InvalidColor_UsageError covers the palette gate: anything +// outside "1".."12" must fail with a usage error BEFORE any HTTP call. +func TestUpdate_InvalidColor_UsageError(t *testing.T) { + h := newTagHelper(t) + h.withHandler(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("must not hit HTTP when --color is invalid; got %s %s", r.Method, r.URL.Path) + }) + + err := h.run(`tag update ` + validTagID + ` --name x --color 99`) + if err == nil { + t.Fatalf("expected usage error for --color 99") + } + if !strings.Contains(err.Error(), "--color") { + t.Fatalf("expected error to mention --color, got: %v", err) + } + if !strings.Contains(err.Error(), "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12") { + t.Fatalf("expected error to list valid palette values, got: %v", err) + } +} diff --git a/internal/commands/mcp/server/testdata/policy.golden b/internal/commands/mcp/server/testdata/policy.golden index ca530319c..28ebb5ee2 100644 --- a/internal/commands/mcp/server/testdata/policy.golden +++ b/internal/commands/mcp/server/testdata/policy.golden @@ -159,10 +159,21 @@ desktop dbms plugin list allow desktop dbms plugin uninstall write desktop dbms start write desktop dbms stop write +desktop dbms update write desktop dbms upgrade write desktop doctor allow desktop install write desktop list allow +desktop project allow +desktop project create write +desktop project delete write +desktop project list allow +desktop project update write +desktop tag allow +desktop tag create write +desktop tag delete write +desktop tag list allow +desktop tag update write docker allow docker create write docker delete write diff --git a/internal/desktopclient/client.go b/internal/desktopclient/client.go index 3884206ba..b78537093 100644 --- a/internal/desktopclient/client.go +++ b/internal/desktopclient/client.go @@ -361,6 +361,36 @@ func (c *Client) DeleteDbms(ctx context.Context, id string) (*DbmsInfo, error) { return &out, nil } +// UpdateDbms PATCHes only the populated keys among name / description / +// tags / projects — `nil` pointer fields are dropped from the body so +// Desktop's server-side values for those keys are left untouched. Tags and +// projects carry Desktop catalog IDs (resolve names via ResolveTagIDs / +// ResolveProjectIDs first). +func (c *Client) UpdateDbms(ctx context.Context, id string, args DbmsUpdateArgs) (*DbmsInfo, error) { + payload := map[string]any{} + if args.Name != nil { + payload["name"] = *args.Name + } + if args.Description != nil { + payload["description"] = *args.Description + } + if args.Tags != nil { + payload["tags"] = *args.Tags + } + if args.Projects != nil { + payload["projects"] = *args.Projects + } + body, err := c.do(ctx, http.MethodPatch, "/dbmss/"+url.PathEscape(id), payload) + if err != nil { + return nil, err + } + var out DbmsInfo + if err := json.Unmarshal(body, &out); err != nil { + return nil, clierr.NewFatalError("desktop: failed to decode updated dbms: %s", err.Error()) + } + return &out, nil +} + // StartDbms issues `POST /dbmss/:id/start`. Desktop's body is a stringified // shell output, discarded here — callers poll GetDbms for `status=started`. func (c *Client) StartDbms(ctx context.Context, id string) error { @@ -472,6 +502,12 @@ func (c *Client) UpdateConnection(ctx context.Context, id string, args Connectio if args.Description != nil { payload["description"] = *args.Description } + if args.Tags != nil { + payload["tags"] = *args.Tags + } + if args.Projects != nil { + payload["projects"] = *args.Projects + } body, err := c.do(ctx, http.MethodPatch, "/connections/"+url.PathEscape(id), payload) if err != nil { return nil, err diff --git a/internal/desktopclient/connect.go b/internal/desktopclient/connect.go new file mode 100644 index 000000000..e511c846a --- /dev/null +++ b/internal/desktopclient/connect.go @@ -0,0 +1,55 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "context" + "errors" + + "github.com/spf13/afero" + + "github.com/neo4j/cli/internal/clierr" +) + +// connectFn is the single test seam for Connect, shared by every `desktop` +// subcommand package (they previously each kept a private copy). +var connectFn = connect + +// SetConnectFnForTest overrides Connect for tests and returns a restore func. +func SetConnectFnForTest(fn func(context.Context, afero.Fs, int) (*Client, error)) func() { + prev := connectFn + connectFn = fn + return func() { connectFn = prev } +} + +// Connect probes for a running Desktop, resolves its data dir, loads the +// relate salt, and returns an authenticated Client. A probe miss or a +// missing/unreadable salt (Desktop has not finished first-run auth setup) +// both surface as the canonical "Desktop unreachable" error. +func Connect(ctx context.Context, fs afero.Fs, port int) (*Client, error) { + return connectFn(ctx, fs, port) +} + +func connect(ctx context.Context, fs afero.Fs, port int) (*Client, error) { + // Discover runs first so its origin can be threaded into ResolveDataDir + // for the /info/app discovery step. + probe, err := Discover(ctx, port) + if err != nil { + if errors.Is(err, ErrNoDesktop) { + return nil, UnreachableError() + } + return nil, clierr.NewFatalError("desktop: probe failed: %s", err.Error()) + } + dataDir, err := ResolveDataDir(ctx, fs, probe) + if err != nil { + return nil, clierr.NewFatalError("desktop: could not resolve relate data dir: %s", err.Error()) + } + salt, err := LoadSalt(fs, dataDir) + if err != nil { + // Missing/unreadable salt = Desktop has not finished first-run auth + // setup. Route to the same unreachable error as a probe miss. + return nil, UnreachableError() + } + return NewClient(probe, salt) +} diff --git a/internal/desktopclient/connect_test.go b/internal/desktopclient/connect_test.go new file mode 100644 index 000000000..8e638444f --- /dev/null +++ b/internal/desktopclient/connect_test.go @@ -0,0 +1,150 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/afero" +) + +// pinDiscoverMiss stubs both mDNS tiers so Discover falls through to the +// legacy port scan (which pinProbeTo then controls). +func pinDiscoverMiss(t *testing.T) { + t.Helper() + t.Cleanup(SetMDNSBrowseFnForTest(func(_ context.Context) (int, bool) { return 0, false })) + t.Cleanup(SetDNSSDLookupFnForTest(func(_ context.Context) (int, bool) { return 0, false })) +} + +// stubAppInfo routes the unauthenticated /info/app fetch (via httpDoFn) to a +// handler serving the given AppInfo JSON body with HTTP 200. +func stubAppInfo(t *testing.T, body string) { + t.Helper() + t.Cleanup(SetHTTPDoFnForTest(func(_ *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + _, _ = rec.WriteString(body) + return rec.Result(), nil + })) +} + +func TestConnect_Success(t *testing.T) { + pinDiscoverMiss(t) + pinProbeTo(t, "127.0.0.1", map[int]bool{ProbePortStart: true}) + + fs := afero.NewMemMapFs() + dataDir := "/data" + stubAppInfo(t, fmt.Sprintf(`{"dataPath": %q}`, dataDir)) + if err := afero.WriteFile(fs, filepath.Join(dataDir, SaltFilename), []byte("test-salt"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + client, err := Connect(context.Background(), fs, 0) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if client == nil { + t.Fatal("Connect returned nil client") + } + if client.ClientID() == "" { + t.Fatal("client.ClientID() is empty") + } + if client.token == "" { + t.Fatal("client.token is empty") + } +} + +func TestConnect_ProbeMissIsUnreachable(t *testing.T) { + pinDiscoverMiss(t) + pinProbeTo(t, "127.0.0.1", map[int]bool{}) + + _, err := Connect(context.Background(), afero.NewMemMapFs(), 0) + if err == nil { + t.Fatal("Connect: expected error") + } + if !strings.Contains(err.Error(), "doesn't appear to be running") { + t.Fatalf("err = %q, want canonical unreachable message", err.Error()) + } +} + +func TestConnect_ProbeErrorNonSentinel(t *testing.T) { + pinDiscoverMiss(t) + pinProbeTo(t, "127.0.0.1", map[int]bool{ProbePortStart: true}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := Connect(ctx, afero.NewMemMapFs(), 0) + if err == nil { + t.Fatal("Connect: expected error") + } + if !strings.Contains(err.Error(), "desktop: probe failed: context canceled") { + t.Fatalf("err = %q, want probe-failed wrapper", err.Error()) + } +} + +func TestConnect_ResolveDataDirError(t *testing.T) { + pinDiscoverMiss(t) + pinProbeTo(t, "127.0.0.1", map[int]bool{ProbePortStart: true}) + t.Setenv("NEO4J_DESKTOP_DATA_PATH", "") + + // /info/app transport error → ResolveDataDir falls through to the env + // JSON (absent in memFs) and then the per-OS default, which fails here. + t.Cleanup(SetHTTPDoFnForTest(func(_ *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("connection refused (test)") + })) + t.Cleanup(SetHomeDirFnForTest(func() (string, error) { + return "", fmt.Errorf("no home (test)") + })) + + _, err := Connect(context.Background(), afero.NewMemMapFs(), 0) + if err == nil { + t.Fatal("Connect: expected error") + } + if !strings.Contains(err.Error(), "desktop: could not resolve relate data dir") { + t.Fatalf("err = %q, want data-dir wrapper", err.Error()) + } +} + +func TestConnect_MissingSaltIsUnreachable(t *testing.T) { + pinDiscoverMiss(t) + pinProbeTo(t, "127.0.0.1", map[int]bool{ProbePortStart: true}) + + fs := afero.NewMemMapFs() + dataDir := "/data" + stubAppInfo(t, fmt.Sprintf(`{"dataPath": %q}`, dataDir)) + // No relate.secret.key written — Desktop has not finished first-run auth. + + _, err := Connect(context.Background(), fs, 0) + if err == nil { + t.Fatal("Connect: expected error") + } + if !strings.Contains(err.Error(), "doesn't appear to be running") { + t.Fatalf("err = %q, want canonical unreachable message", err.Error()) + } +} + +func TestConnect_SeamOverride(t *testing.T) { + sentinel := &Client{clientID: "sentinel"} + t.Cleanup(SetConnectFnForTest(func(_ context.Context, _ afero.Fs, port int) (*Client, error) { + if port != 44229 { + t.Fatalf("seam got port %d, want 44229", port) + } + return sentinel, nil + })) + + client, err := Connect(context.Background(), afero.NewMemMapFs(), 44229) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if client != sentinel { + t.Fatal("Connect did not route through the seam") + } +} diff --git a/internal/desktopclient/finders.go b/internal/desktopclient/finders.go new file mode 100644 index 000000000..59a4f1101 --- /dev/null +++ b/internal/desktopclient/finders.go @@ -0,0 +1,104 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "strings" + + "github.com/neo4j/cli/internal/clierr" +) + +// Shared finders for picking the mutated entry out of the full catalog state +// that every /projects and /tags mutation returns (POST/PATCH/DELETE all +// respond with the post-mutation state, not the single entry). One policy for +// both catalogs — the mutated entry MUST be identifiable exactly once: +// +// - absent from the returned state is a FATAL error: Desktop acknowledged +// the mutation but its catalog disagrees, and rendering nothing (or a +// literal `null` with exit 0) would report success for a change that may +// not exist; +// - a name matching several entries is a FATAL error listing the candidate +// ids: the catalog holds duplicates, so printing any one of them could +// present a stale pre-existing entry as the one just mutated. +// +// No first-match-wins and no newest-CreatedAt tiebreaks. Create paths locate +// by name; update paths locate by the resolved id. + +// FindProjectByName locates the single project named `name` in a +// post-mutation ProjectsState. A nil state, no match, or more than one match +// is a fatal error (see the policy above). +func FindProjectByName(state *ProjectsState, name string) (*Project, error) { + var items []Project + if state != nil { + items = state.Projects + } + return findOne(items, "project", name, + func(p *Project) bool { return p.Name == name }, + func(p *Project) string { return p.ID }) +} + +// FindProjectByID locates the project with `id` in a post-mutation +// ProjectsState. A nil state or no match is a fatal error. +func FindProjectByID(state *ProjectsState, id string) (*Project, error) { + var items []Project + if state != nil { + items = state.Projects + } + return findOne(items, "project", id, + func(p *Project) bool { return p.ID == id }, + func(p *Project) string { return p.ID }) +} + +// FindTagByName locates the single tag named `name` in a post-mutation +// TagsState. A nil state, no match, or more than one match is a fatal error +// (see the policy above). +func FindTagByName(state *TagsState, name string) (*Tag, error) { + var items []Tag + if state != nil { + items = state.Tags + } + return findOne(items, "tag", name, + func(t *Tag) bool { return t.Name == name }, + func(t *Tag) string { return t.ID }) +} + +// FindTagByID locates the tag with `id` in a post-mutation TagsState. A nil +// state or no match is a fatal error. +func FindTagByID(state *TagsState, id string) (*Tag, error) { + var items []Tag + if state != nil { + items = state.Tags + } + return findOne(items, "tag", id, + func(t *Tag) bool { return t.ID == id }, + func(t *Tag) string { return t.ID }) +} + +// findOne is the shared core behind the Find* helpers: exactly one match is +// required. `selector` is the name or id that was looked up (echoed in error +// text); `match` tests one entry; `idOf` extracts an entry's id for the +// ambiguity message. +func findOne[T any](items []T, resource, selector string, match func(*T) bool, idOf func(*T) string) (*T, error) { + var matches []*T + for i := range items { + if match(&items[i]) { + matches = append(matches, &items[i]) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return nil, clierr.NewFatalError( + "desktop: %s %q is missing from the catalog state Desktop returned", resource, selector) + default: + ids := make([]string, 0, len(matches)) + for _, m := range matches { + ids = append(ids, idOf(m)) + } + return nil, clierr.NewFatalError( + "desktop: %s %q matches %d entries in the catalog state Desktop returned (ids: %s) — the catalog holds duplicates, so the mutated entry cannot be identified", + resource, selector, len(matches), strings.Join(ids, ", ")) + } +} diff --git a/internal/desktopclient/finders_test.go b/internal/desktopclient/finders_test.go new file mode 100644 index 000000000..107febc95 --- /dev/null +++ b/internal/desktopclient/finders_test.go @@ -0,0 +1,244 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "errors" + "strings" + "testing" + + "github.com/neo4j/cli/internal/clierr" +) + +// assertFatalFinderError pins the shared finder policy: every failure is a +// clierr FATAL error (exit 1) whose message carries each wanted substring. +func assertFatalFinderError(t *testing.T, err error, substrings ...string) { + t.Helper() + if err == nil { + t.Fatalf("expected fatal error, got nil") + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 1 { + t.Fatalf("error = %v (%T), want clierr fatal error (exit 1)", err, err) + } + for _, s := range substrings { + if !strings.Contains(ce.Message, s) { + t.Fatalf("message = %q, want substring %q", ce.Message, s) + } + } +} + +func TestFindProjectByName(t *testing.T) { + cases := []struct { + name string + state *ProjectsState + selector string + wantID string + wantErr []string + }{ + { + name: "nil state is a fatal miss", + state: nil, + selector: "Proj", + wantErr: []string{`project "Proj" is missing from the catalog state`}, + }, + { + name: "absent from state is a fatal miss", + state: &ProjectsState{Projects: []Project{{ID: "p1", Name: "Other"}}}, + selector: "Proj", + wantErr: []string{`project "Proj" is missing from the catalog state`}, + }, + { + name: "single match is returned", + state: &ProjectsState{Projects: []Project{ + {ID: "p1", Name: "Other"}, + {ID: "p2", Name: "Proj", CreatedAt: 1747843200000}, + }}, + selector: "Proj", + wantID: "p2", + }, + { + name: "match is case-sensitive", + state: &ProjectsState{Projects: []Project{ + {ID: "p1", Name: "proj"}, + }}, + selector: "Proj", + wantErr: []string{`project "Proj" is missing from the catalog state`}, + }, + { + name: "duplicate names are a fatal ambiguity listing candidate ids", + state: &ProjectsState{Projects: []Project{ + {ID: "p-old", Name: "Proj", CreatedAt: 1700000000000}, + {ID: "p-new", Name: "Proj", CreatedAt: 1747843200000}, + }}, + selector: "Proj", + wantErr: []string{`project "Proj" matches 2 entries`, "p-old", "p-new", "duplicates"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := FindProjectByName(tc.state, tc.selector) + if len(tc.wantErr) > 0 { + assertFatalFinderError(t, err, tc.wantErr...) + return + } + if err != nil { + t.Fatalf("FindProjectByName: %v", err) + } + if got == nil || got.ID != tc.wantID { + t.Fatalf("got %+v, want id %q", got, tc.wantID) + } + }) + } +} + +func TestFindProjectByID(t *testing.T) { + cases := []struct { + name string + state *ProjectsState + selector string + wantID string + wantErr []string + }{ + { + name: "nil state is a fatal miss", + state: nil, + selector: "p2", + wantErr: []string{`project "p2" is missing from the catalog state`}, + }, + { + name: "absent id is a fatal miss", + state: &ProjectsState{Projects: []Project{{ID: "p1", Name: "A"}}}, + selector: "p2", + wantErr: []string{`project "p2" is missing from the catalog state`}, + }, + { + name: "matching id is returned even among same-named entries", + state: &ProjectsState{Projects: []Project{ + {ID: "p1", Name: "Dup"}, + {ID: "p2", Name: "Dup"}, + }}, + selector: "p2", + wantID: "p2", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := FindProjectByID(tc.state, tc.selector) + if len(tc.wantErr) > 0 { + assertFatalFinderError(t, err, tc.wantErr...) + return + } + if err != nil { + t.Fatalf("FindProjectByID: %v", err) + } + if got == nil || got.ID != tc.wantID { + t.Fatalf("got %+v, want id %q", got, tc.wantID) + } + }) + } +} + +func TestFindTagByName(t *testing.T) { + cases := []struct { + name string + state *TagsState + selector string + wantID string + wantErr []string + }{ + { + name: "nil state is a fatal miss", + state: nil, + selector: "Prod", + wantErr: []string{`tag "Prod" is missing from the catalog state`}, + }, + { + name: "absent from state is a fatal miss", + state: &TagsState{Tags: []Tag{{ID: "t1", Name: "Dev"}}}, + selector: "Prod", + wantErr: []string{`tag "Prod" is missing from the catalog state`}, + }, + { + name: "single match is returned", + state: &TagsState{Tags: []Tag{ + {ID: "t0", Name: "Dev"}, + {ID: "t1", Name: "Prod", Color: "5"}, + }}, + selector: "Prod", + wantID: "t1", + }, + { + name: "duplicate names are a fatal ambiguity listing candidate ids", + state: &TagsState{Tags: []Tag{ + {ID: "t-old", Name: "Prod", Color: "1"}, + {ID: "t-new", Name: "Prod", Color: "5"}, + }}, + selector: "Prod", + wantErr: []string{`tag "Prod" matches 2 entries`, "t-old", "t-new", "duplicates"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := FindTagByName(tc.state, tc.selector) + if len(tc.wantErr) > 0 { + assertFatalFinderError(t, err, tc.wantErr...) + return + } + if err != nil { + t.Fatalf("FindTagByName: %v", err) + } + if got == nil || got.ID != tc.wantID { + t.Fatalf("got %+v, want id %q", got, tc.wantID) + } + }) + } +} + +func TestFindTagByID(t *testing.T) { + cases := []struct { + name string + state *TagsState + selector string + wantID string + wantErr []string + }{ + { + name: "nil state is a fatal miss", + state: nil, + selector: "t2", + wantErr: []string{`tag "t2" is missing from the catalog state`}, + }, + { + name: "absent id is a fatal miss", + state: &TagsState{Tags: []Tag{{ID: "t1", Name: "Prod"}}}, + selector: "t2", + wantErr: []string{`tag "t2" is missing from the catalog state`}, + }, + { + name: "matching id is returned even among same-named entries", + state: &TagsState{Tags: []Tag{ + {ID: "t1", Name: "Dup"}, + {ID: "t2", Name: "Dup", Color: "7"}, + }}, + selector: "t2", + wantID: "t2", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := FindTagByID(tc.state, tc.selector) + if len(tc.wantErr) > 0 { + assertFatalFinderError(t, err, tc.wantErr...) + return + } + if err != nil { + t.Fatalf("FindTagByID: %v", err) + } + if got == nil || got.ID != tc.wantID { + t.Fatalf("got %+v, want id %q", got, tc.wantID) + } + }) + } +} diff --git a/internal/desktopclient/output.go b/internal/desktopclient/output.go index 7de92d4df..13663b699 100644 --- a/internal/desktopclient/output.go +++ b/internal/desktopclient/output.go @@ -15,7 +15,7 @@ type DbmsInfoOutput struct { Name string `json:"name"` Description string `json:"description,omitempty"` Tags []string `json:"tags,omitempty"` - Project string `json:"project,omitempty"` + Projects []string `json:"projects,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ConnectionURI string `json:"connection_uri,omitempty"` RootPath string `json:"root_path,omitempty"` @@ -46,7 +46,7 @@ type ConnectionOutput struct { Name string `json:"name"` Description string `json:"description,omitempty"` Tags []string `json:"tags,omitempty"` - Project string `json:"project,omitempty"` + Projects []string `json:"projects,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ConnectionURI string `json:"connection_uri,omitempty"` CreatedAt string `json:"created_at,omitempty"` diff --git a/internal/desktopclient/projects_tags.go b/internal/desktopclient/projects_tags.go new file mode 100644 index 000000000..23c6f1114 --- /dev/null +++ b/internal/desktopclient/projects_tags.go @@ -0,0 +1,226 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + + "github.com/neo4j/cli/internal/clierr" +) + +// This file covers Desktop's project + tag catalogs (`/projects`, `/tags`) +// and the name→ID resolvers built on top of them. Unlike the `/dbmss` and +// `/connections` routes, these routes identify the mutated entry in the BODY +// (PATCH) or QUERYSTRING (DELETE) — never as a path segment — and every +// mutation responds with the full post-mutation state, not the single entry. + +// ListProjects returns the project catalog (`GET /projects`). The +// `currentProject` selection is Desktop-UI state and intentionally not +// surfaced here. +func (c *Client) ListProjects(ctx context.Context) ([]Project, error) { + body, err := c.do(ctx, http.MethodGet, "/projects", nil) + if err != nil { + return nil, err + } + out, err := decodeState[ProjectsState](body, "project list") + if err != nil { + return nil, err + } + return out.Projects, nil +} + +// CreateProject posts `{"name": ...}` and returns the full post-create state. +func (c *Client) CreateProject(ctx context.Context, name string) (*ProjectsState, error) { + body, err := c.do(ctx, http.MethodPost, "/projects", map[string]any{"name": name}) + if err != nil { + return nil, err + } + return decodeState[ProjectsState](body, "created project state") +} + +// UpdateProject renames a project. The id rides in the BODY (`{"id","name"}`) +// — the route has no path parameter. +func (c *Client) UpdateProject(ctx context.Context, id, name string) (*ProjectsState, error) { + body, err := c.do(ctx, http.MethodPatch, "/projects", map[string]any{"id": id, "name": name}) + if err != nil { + return nil, err + } + return decodeState[ProjectsState](body, "updated project state") +} + +// DeleteProject removes one project. The id rides in the QUERYSTRING +// (`DELETE /projects?id=`) with no body. +func (c *Client) DeleteProject(ctx context.Context, id string) (*ProjectsState, error) { + body, err := c.do(ctx, http.MethodDelete, "/projects?id="+url.QueryEscape(id), nil) + if err != nil { + return nil, err + } + return decodeState[ProjectsState](body, "deleted project state") +} + +// decodeState is the shared JSON decoder for the full-catalog-state bodies +// that every /projects and /tags route responds with. `what` names the +// decoded payload for the error message (e.g. "created project state"). +func decodeState[T any](body []byte, what string) (*T, error) { + var out T + if err := json.Unmarshal(body, &out); err != nil { + return nil, clierr.NewFatalError("desktop: failed to decode %s: %s", what, err.Error()) + } + return &out, nil +} + +// ListTags returns the tag catalog (`GET /tags`). The UI `filter` selection +// is intentionally not surfaced here. +func (c *Client) ListTags(ctx context.Context) ([]Tag, error) { + body, err := c.do(ctx, http.MethodGet, "/tags", nil) + if err != nil { + return nil, err + } + out, err := decodeState[TagsState](body, "tag list") + if err != nil { + return nil, err + } + return out.Tags, nil +} + +// CreateTag posts `{"name": ..., "color": ...}`; an empty color omits the key +// (Desktop then picks a default). Returns the full post-create state. +func (c *Client) CreateTag(ctx context.Context, name, color string) (*TagsState, error) { + payload := map[string]any{"name": name} + if color != "" { + payload["color"] = color + } + body, err := c.do(ctx, http.MethodPost, "/tags", payload) + if err != nil { + return nil, err + } + return decodeState[TagsState](body, "created tag state") +} + +// UpdateTag edits a tag. The id rides in the BODY (`{"id","name","color"?}`) +// — the route has no path parameter. An empty color omits the key. +func (c *Client) UpdateTag(ctx context.Context, id, name, color string) (*TagsState, error) { + payload := map[string]any{"id": id, "name": name} + if color != "" { + payload["color"] = color + } + body, err := c.do(ctx, http.MethodPatch, "/tags", payload) + if err != nil { + return nil, err + } + return decodeState[TagsState](body, "updated tag state") +} + +// DeleteTag removes one tag. The id rides in the QUERYSTRING +// (`DELETE /tags?id=`) with no body. +func (c *Client) DeleteTag(ctx context.Context, id string) (*TagsState, error) { + body, err := c.do(ctx, http.MethodDelete, "/tags?id="+url.QueryEscape(id), nil) + if err != nil { + return nil, err + } + return decodeState[TagsState](body, "deleted tag state") +} + +// NonEmptyStrings drops empty-string elements (StringSlice quirks like +// `--tags ""` yield one empty element) so they never reach name resolution; +// an all-empty input collapses to an empty slice, which clears the set +// server-side. +func NonEmptyStrings(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if s != "" { + out = append(out, s) + } + } + return out +} + +// ResolveProjectIDs maps each entry of `namesOrIDs` to a project ID: values +// that parse as UUIDs pass through verbatim; anything else is matched +// case-sensitively against the ListProjects catalog by name. Unknown names +// and names matching more than one project are usage errors. +func (c *Client) ResolveProjectIDs(ctx context.Context, namesOrIDs []string) ([]string, error) { + return resolveIDs(namesOrIDs, "project", func() ([]namedID, error) { + projects, err := c.ListProjects(ctx) + if err != nil { + return nil, err + } + out := make([]namedID, 0, len(projects)) + for _, p := range projects { + out = append(out, namedID{id: p.ID, name: p.Name}) + } + return out, nil + }) +} + +// ResolveTagIDs maps each entry of `namesOrIDs` to a tag ID: values that +// parse as UUIDs pass through verbatim; anything else is matched +// case-sensitively against the ListTags catalog by name. Unknown names and +// names matching more than one tag are usage errors. +func (c *Client) ResolveTagIDs(ctx context.Context, namesOrIDs []string) ([]string, error) { + return resolveIDs(namesOrIDs, "tag", func() ([]namedID, error) { + tags, err := c.ListTags(ctx) + if err != nil { + return nil, err + } + out := make([]namedID, 0, len(tags)) + for _, t := range tags { + out = append(out, namedID{id: t.ID, name: t.Name}) + } + return out, nil + }) +} + +type namedID struct { + id string + name string +} + +// resolveIDs is the shared name→ID resolution behind ResolveProjectIDs / +// ResolveTagIDs. The catalog is fetched lazily — an all-UUID input never +// hits the network. +func resolveIDs(namesOrIDs []string, resource string, catalog func() ([]namedID, error)) ([]string, error) { + out := make([]string, 0, len(namesOrIDs)) + var entries []namedID + fetched := false + for _, in := range namesOrIDs { + if _, err := uuid.Parse(in); err == nil { + out = append(out, in) + continue + } + if !fetched { + var err error + entries, err = catalog() + if err != nil { + return nil, err + } + fetched = true + } + var matches []namedID + for _, e := range entries { + if e.name == in { + matches = append(matches, e) + } + } + switch len(matches) { + case 0: + return nil, clierr.NewUsageError("unknown %s %q — pass a %s ID or an exact %s name from the Desktop catalog", resource, in, resource, resource) + case 1: + out = append(out, matches[0].id) + default: + ids := make([]string, 0, len(matches)) + for _, m := range matches { + ids = append(ids, m.id) + } + return nil, clierr.NewUsageError("ambiguous %s name %q matches %d %ss (%s) — pass one of the IDs instead", resource, in, len(matches), resource, strings.Join(ids, ", ")) + } + } + return out, nil +} diff --git a/internal/desktopclient/projects_tags_test.go b/internal/desktopclient/projects_tags_test.go new file mode 100644 index 000000000..d6a4a093b --- /dev/null +++ b/internal/desktopclient/projects_tags_test.go @@ -0,0 +1,574 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/neo4j/cli/internal/clierr" +) + +// recordedRequest captures what a handler saw so table-driven cases can +// assert method + path + querystring + decoded body after the call. +type recordedRequest struct { + method string + path string + query string + body map[string]any +} + +// projectTagHandler returns a handler that records the request and responds +// with `status`/`responseBody`. +func projectTagHandler(t *testing.T, status int, responseBody string, seen *recordedRequest) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + seen.method = r.Method + seen.path = r.URL.Path + seen.query = r.URL.RawQuery + if r.Body != nil && r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&seen.body); err != nil { + t.Fatalf("decode request body: %v", err) + } + } + w.WriteHeader(status) + _, _ = w.Write([]byte(responseBody)) + } +} + +func TestClient_ProjectTagEndpoints_MethodPathBody(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + const projectsState = `{"projects":[{"id":"p1","name":"Proj","createdAt":1750000000000}],"currentProject":"p1"}` + const tagsState = `{"tags":[{"id":"t1","name":"Prod","color":"5"}],"filter":{"selectedTagIds":["t1"]}}` + + cases := []struct { + name string + call func(cl *Client) error + wantMethod string + wantPath string + wantQuery string + wantBody map[string]any + wantNoBody bool + }{ + { + name: "ListProjects", + call: func(cl *Client) error { _, err := cl.ListProjects(context.Background()); return err }, + wantMethod: http.MethodGet, + wantPath: "/fastify/api/projects", + wantNoBody: true, + }, + { + name: "CreateProject", + call: func(cl *Client) error { _, err := cl.CreateProject(context.Background(), "Proj"); return err }, + wantMethod: http.MethodPost, + wantPath: "/fastify/api/projects", + wantBody: map[string]any{"name": "Proj"}, + }, + { + name: "UpdateProject_IdInBody", + call: func(cl *Client) error { + _, err := cl.UpdateProject(context.Background(), "p1", "Renamed") + return err + }, + wantMethod: http.MethodPatch, + wantPath: "/fastify/api/projects", + wantBody: map[string]any{"id": "p1", "name": "Renamed"}, + }, + { + name: "DeleteProject_IdInQuerystring", + call: func(cl *Client) error { _, err := cl.DeleteProject(context.Background(), "p1"); return err }, + wantMethod: http.MethodDelete, + wantPath: "/fastify/api/projects", + wantQuery: "id=p1", + wantNoBody: true, + }, + { + name: "ListTags", + call: func(cl *Client) error { _, err := cl.ListTags(context.Background()); return err }, + wantMethod: http.MethodGet, + wantPath: "/fastify/api/tags", + wantNoBody: true, + }, + { + name: "CreateTag_WithColor", + call: func(cl *Client) error { _, err := cl.CreateTag(context.Background(), "Prod", "5"); return err }, + wantMethod: http.MethodPost, + wantPath: "/fastify/api/tags", + wantBody: map[string]any{"name": "Prod", "color": "5"}, + }, + { + name: "CreateTag_NoColorOmitsKey", + call: func(cl *Client) error { _, err := cl.CreateTag(context.Background(), "Prod", ""); return err }, + wantMethod: http.MethodPost, + wantPath: "/fastify/api/tags", + wantBody: map[string]any{"name": "Prod"}, + }, + { + name: "UpdateTag_IdInBody_ColorOmittedWhenEmpty", + call: func(cl *Client) error { + _, err := cl.UpdateTag(context.Background(), "t1", "Renamed", "") + return err + }, + wantMethod: http.MethodPatch, + wantPath: "/fastify/api/tags", + wantBody: map[string]any{"id": "t1", "name": "Renamed"}, + }, + { + name: "UpdateTag_WithColor", + call: func(cl *Client) error { + _, err := cl.UpdateTag(context.Background(), "t1", "Renamed", "7") + return err + }, + wantMethod: http.MethodPatch, + wantPath: "/fastify/api/tags", + wantBody: map[string]any{"id": "t1", "name": "Renamed", "color": "7"}, + }, + { + name: "DeleteTag_IdInQuerystring", + call: func(cl *Client) error { _, err := cl.DeleteTag(context.Background(), "t1"); return err }, + wantMethod: http.MethodDelete, + wantPath: "/fastify/api/tags", + wantQuery: "id=t1", + wantNoBody: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var seen recordedRequest + response := projectsState + if strings.Contains(tc.name, "Tag") { + response = tagsState + } + srv, probe := newAuthedServer(t, salt, clientID, projectTagHandler(t, http.StatusOK, response, &seen)) + _ = srv + + cl, err := NewClient(probe, salt) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if err := tc.call(cl); err != nil { + t.Fatalf("call: %v", err) + } + if seen.method != tc.wantMethod || seen.path != tc.wantPath { + t.Fatalf("got %s %s, want %s %s", seen.method, seen.path, tc.wantMethod, tc.wantPath) + } + if seen.query != tc.wantQuery { + t.Fatalf("query = %q, want %q", seen.query, tc.wantQuery) + } + if tc.wantNoBody { + if seen.body != nil { + t.Fatalf("expected no request body, got %+v", seen.body) + } + return + } + if len(seen.body) != len(tc.wantBody) { + t.Fatalf("body = %+v, want exactly %+v", seen.body, tc.wantBody) + } + for k, v := range tc.wantBody { + if seen.body[k] != v { + t.Errorf("body[%q] = %v, want %v", k, seen.body[k], v) + } + } + }) + } +} + +func TestClient_ProjectTagEndpoints_DecodeFullState(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + t.Run("ListProjects decodes entries + drops currentProject", func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"A","createdAt":1750000000000},{"id":"p2","name":"B"}],"currentProject":"p1"}`)) + }) + _ = srv + cl, _ := NewClient(probe, salt) + got, err := cl.ListProjects(context.Background()) + if err != nil { + t.Fatalf("ListProjects: %v", err) + } + if len(got) != 2 || got[0].ID != "p1" || got[0].Name != "A" || got[0].CreatedAt != 1750000000000 { + t.Fatalf("unexpected: %+v", got) + } + if got[1].CreatedAt != 0 { + t.Fatalf("optional createdAt must decode to 0 when absent, got %+v", got[1]) + } + }) + + t.Run("CreateProject returns full state incl currentProject", func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[{"id":"p9","name":"New"}],"currentProject":"p9"}`)) + }) + _ = srv + cl, _ := NewClient(probe, salt) + got, err := cl.CreateProject(context.Background(), "New") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + if got.CurrentProject != "p9" || len(got.Projects) != 1 || got.Projects[0].ID != "p9" { + t.Fatalf("unexpected: %+v", got) + } + }) + + t.Run("ListTags decodes entries + drops filter", func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"tags":[{"id":"t1","name":"Prod","color":"5"},{"id":"t2","name":"Dev"}],"filter":{"selectedTagIds":["t1"]}}`)) + }) + _ = srv + cl, _ := NewClient(probe, salt) + got, err := cl.ListTags(context.Background()) + if err != nil { + t.Fatalf("ListTags: %v", err) + } + if len(got) != 2 || got[0].ID != "t1" || got[0].Color != "5" { + t.Fatalf("unexpected: %+v", got) + } + if got[1].Color != "" { + t.Fatalf("optional color must decode empty when absent, got %+v", got[1]) + } + }) + + t.Run("DeleteTag returns full state incl filter", func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"tags":[],"filter":{"selectedTagIds":["t2"]}}`)) + }) + _ = srv + cl, _ := NewClient(probe, salt) + got, err := cl.DeleteTag(context.Background(), "t1") + if err != nil { + t.Fatalf("DeleteTag: %v", err) + } + if got.Filter == nil || len(got.Filter.SelectedTagIds) != 1 || got.Filter.SelectedTagIds[0] != "t2" { + t.Fatalf("unexpected: %+v", got) + } + }) +} + +func TestClient_UpdateDbms_OnlySendsSuppliedFields(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + cases := []struct { + name string + args DbmsUpdateArgs + wantBody map[string]any + }{ + { + name: "NameOnly", + args: DbmsUpdateArgs{Name: strPtr("renamed")}, + wantBody: map[string]any{"name": "renamed"}, + }, + { + name: "AllFields", + args: DbmsUpdateArgs{ + Name: strPtr("n"), + Description: strPtr("d"), + Tags: &[]string{"t1", "t2"}, + Projects: &[]string{"p1"}, + }, + wantBody: map[string]any{ + "name": "n", + "description": "d", + "tags": []any{"t1", "t2"}, + "projects": []any{"p1"}, + }, + }, + { + name: "EmptySliceClearsTags", + args: DbmsUpdateArgs{Tags: &[]string{}}, + wantBody: map[string]any{"tags": []any{}}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var seen recordedRequest + srv, probe := newAuthedServer(t, salt, clientID, + projectTagHandler(t, http.StatusOK, `{"id":"abc","name":"n"}`, &seen)) + _ = srv + + cl, _ := NewClient(probe, salt) + out, err := cl.UpdateDbms(context.Background(), "abc", tc.args) + if err != nil { + t.Fatalf("UpdateDbms: %v", err) + } + if out.ID != "abc" { + t.Fatalf("unexpected: %+v", out) + } + if seen.method != http.MethodPatch || seen.path != "/fastify/api/dbmss/abc" { + t.Fatalf("got %s %s, want PATCH /fastify/api/dbmss/abc", seen.method, seen.path) + } + if len(seen.body) != len(tc.wantBody) { + t.Fatalf("body = %+v, want exactly %+v", seen.body, tc.wantBody) + } + for k, v := range tc.wantBody { + if fmt.Sprintf("%v", seen.body[k]) != fmt.Sprintf("%v", v) { + t.Errorf("body[%q] = %v, want %v", k, seen.body[k], v) + } + } + }) + } +} + +func TestClient_UpdateConnection_SendsTagsAndProjects(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + var seen recordedRequest + srv, probe := newAuthedServer(t, salt, clientID, + projectTagHandler(t, http.StatusOK, `{"id":"abc","name":"X","connectionUri":"neo4j://localhost:7687"}`, &seen)) + _ = srv + + cl, _ := NewClient(probe, salt) + _, err := cl.UpdateConnection(context.Background(), "abc", ConnectionUpdateArgs{ + Tags: &[]string{"t1"}, + Projects: &[]string{}, + }) + if err != nil { + t.Fatalf("UpdateConnection: %v", err) + } + if seen.method != http.MethodPatch || seen.path != "/fastify/api/connections/abc" { + t.Fatalf("got %s %s, want PATCH /fastify/api/connections/abc", seen.method, seen.path) + } + if len(seen.body) != 2 { + t.Fatalf("body = %+v, want exactly tags+projects", seen.body) + } + tags, ok := seen.body["tags"].([]any) + if !ok || len(tags) != 1 || tags[0] != "t1" { + t.Errorf("body[tags] = %v, want [t1]", seen.body["tags"]) + } + projects, ok := seen.body["projects"].([]any) + if !ok || len(projects) != 0 { + t.Errorf("body[projects] = %v, want [] (non-nil empty slice must serialize, not be dropped)", seen.body["projects"]) + } +} + +// ---- Resolvers ---- + +const ( + resolverUUID1 = "11111111-2222-3333-4444-555555555555" + resolverUUID2 = "66666666-7777-8888-9999-aaaaaaaaaaaa" +) + +func TestClient_ResolveProjectIDs(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + catalog := `{"projects":[{"id":"` + resolverUUID1 + `","name":"Prod"},{"id":"` + resolverUUID2 + `","name":"Prod"},{"id":"p3","name":"Staging"}]}` + + cases := []struct { + name string + // catalogBody, when non-empty, is served for GET /projects; when + // empty the server fails the test on ANY request (proves the + // all-UUID path never hits the network). + catalogBody string + input []string + want []string + wantErr string // substring; empty means success + }{ + { + name: "UUID passes through verbatim without catalog fetch", + input: []string{resolverUUID1}, + want: []string{resolverUUID1}, + }, + { + name: "Name resolves to ID", + catalogBody: catalog, + input: []string{"Staging"}, + want: []string{"p3"}, + }, + { + name: "Mixed UUID and name", + catalogBody: catalog, + input: []string{resolverUUID1, "Staging"}, + want: []string{resolverUUID1, "p3"}, + }, + { + name: "Unknown name is a usage error", + catalogBody: catalog, + input: []string{"Nope"}, + wantErr: `unknown project "Nope"`, + }, + { + name: "Ambiguous name is a usage error listing candidate IDs", + catalogBody: catalog, + input: []string{"Prod"}, + wantErr: `ambiguous project name "Prod"`, + }, + { + name: "Name match is case-sensitive", + catalogBody: catalog, + input: []string{"staging"}, + wantErr: `unknown project "staging"`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, r *http.Request) { + if tc.catalogBody == "" { + t.Errorf("unexpected request %s %s — all-UUID input must not fetch the catalog", r.Method, r.URL.Path) + return + } + if r.Method != http.MethodGet || r.URL.Path != "/fastify/api/projects" { + t.Errorf("got %s %s, want GET /fastify/api/projects", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(tc.catalogBody)) + }) + _ = srv + + cl, _ := NewClient(probe, salt) + got, err := cl.ResolveProjectIDs(context.Background(), tc.input) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("ResolveProjectIDs: %v", err) + } + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Fatalf("got %v, want %v", got, tc.want) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil (result %v)", tc.wantErr, got) + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 2 { + t.Fatalf("error = %v (%T), want clierr usage error (exit 2)", err, err) + } + if !strings.Contains(ce.Message, tc.wantErr) { + t.Fatalf("message = %q, want substring %q", ce.Message, tc.wantErr) + } + }) + } +} + +func TestClient_ResolveProjectIDs_AmbiguousErrorListsIDs(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"projects":[{"id":"` + resolverUUID1 + `","name":"Prod"},{"id":"` + resolverUUID2 + `","name":"Prod"}]}`)) + }) + _ = srv + + cl, _ := NewClient(probe, salt) + _, err := cl.ResolveProjectIDs(context.Background(), []string{"Prod"}) + if err == nil { + t.Fatalf("expected ambiguous error") + } + if !strings.Contains(err.Error(), resolverUUID1) || !strings.Contains(err.Error(), resolverUUID2) { + t.Fatalf("ambiguous error must list candidate IDs, got: %v", err) + } +} + +func TestClient_ResolveTagIDs(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + catalog := `{"tags":[{"id":"` + resolverUUID1 + `","name":"Prod","color":"5"},{"id":"` + resolverUUID2 + `","name":"Prod"},{"id":"t3","name":"Dev"}]}` + + cases := []struct { + name string + catalogBody string + input []string + want []string + wantErr string + }{ + { + name: "UUID passes through verbatim without catalog fetch", + input: []string{resolverUUID2}, + want: []string{resolverUUID2}, + }, + { + name: "Name resolves to ID", + catalogBody: catalog, + input: []string{"Dev"}, + want: []string{"t3"}, + }, + { + name: "Unknown name is a usage error", + catalogBody: catalog, + input: []string{"Nope"}, + wantErr: `unknown tag "Nope"`, + }, + { + name: "Ambiguous name is a usage error", + catalogBody: catalog, + input: []string{"Prod"}, + wantErr: `ambiguous tag name "Prod"`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, r *http.Request) { + if tc.catalogBody == "" { + t.Errorf("unexpected request %s %s — all-UUID input must not fetch the catalog", r.Method, r.URL.Path) + return + } + if r.Method != http.MethodGet || r.URL.Path != "/fastify/api/tags" { + t.Errorf("got %s %s, want GET /fastify/api/tags", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(tc.catalogBody)) + }) + _ = srv + + cl, _ := NewClient(probe, salt) + got, err := cl.ResolveTagIDs(context.Background(), tc.input) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("ResolveTagIDs: %v", err) + } + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Fatalf("got %v, want %v", got, tc.want) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil (result %v)", tc.wantErr, got) + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 2 { + t.Fatalf("error = %v (%T), want clierr usage error (exit 2)", err, err) + } + if !strings.Contains(ce.Message, tc.wantErr) { + t.Fatalf("message = %q, want substring %q", ce.Message, tc.wantErr) + } + }) + } +} + +func strPtr(s string) *string { return &s } + +func TestNonEmptyStrings(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + {name: "nil", in: nil, want: []string{}}, + {name: "empty slice", in: []string{}, want: []string{}}, + {name: "all empty", in: []string{"", ""}, want: []string{}}, + {name: "drops empties", in: []string{"a", "", "b"}, want: []string{"a", "b"}}, + {name: "keeps all", in: []string{"a", "b"}, want: []string{"a", "b"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := NonEmptyStrings(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("NonEmptyStrings(%q) = %q, want %q", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("NonEmptyStrings(%q) = %q, want %q", tc.in, got, tc.want) + } + } + }) + } +} diff --git a/internal/desktopclient/types.go b/internal/desktopclient/types.go index b054332d4..8d6fdcb17 100644 --- a/internal/desktopclient/types.go +++ b/internal/desktopclient/types.go @@ -14,7 +14,7 @@ type DbmsInfo struct { Name string `json:"name"` Description string `json:"description,omitempty"` Tags []string `json:"tags,omitempty"` - Project string `json:"project,omitempty"` + Projects []string `json:"projects,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ConnectionURI string `json:"connectionUri,omitempty"` RootPath string `json:"rootPath,omitempty"` @@ -68,7 +68,7 @@ type Connection struct { Name string `json:"name"` Description string `json:"description,omitempty"` Tags []string `json:"tags,omitempty"` - Project string `json:"project,omitempty"` + Projects []string `json:"projects,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ConnectionURI string `json:"connectionUri,omitempty"` CreatedAt string `json:"createdAt,omitempty"` @@ -89,14 +89,67 @@ type ConnectionCreateArgs struct { // ConnectionUpdateArgs are the optional inputs to UpdateConnection. Every // field is a pointer so the client can distinguish "caller didn't set this" -// (nil) from "caller wants to clear this" (non-nil empty string). The PATCH -// body contains ONLY the keys the caller populated. +// (nil) from "caller wants to clear this" (non-nil empty string / empty +// slice). The PATCH body contains ONLY the keys the caller populated. type ConnectionUpdateArgs struct { Name *string ConnectionURI *string Username *string Password *string Description *string + Tags *[]string + Projects *[]string +} + +// DbmsUpdateArgs are the optional inputs to UpdateDbms. Every field is a +// pointer so the client can distinguish "caller didn't set this" (nil) from +// "caller wants to clear this" (non-nil empty string / empty slice). The +// PATCH body contains ONLY the keys the caller populated. +type DbmsUpdateArgs struct { + Name *string + Description *string + Tags *[]string + Projects *[]string +} + +// Project mirrors one entry in the `GET /projects` response. `CreatedAt` is +// an optional unix-milliseconds timestamp Desktop omits for legacy entries. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt int64 `json:"createdAt,omitempty"` +} + +// ProjectsState is the full project catalog returned by every `/projects` +// route — reads AND mutations (POST/PATCH/DELETE all respond with the +// post-mutation state). `CurrentProject` names the project Desktop's UI has +// selected; omitted when nothing is selected. +type ProjectsState struct { + Projects []Project `json:"projects"` + CurrentProject string `json:"currentProject,omitempty"` +} + +// Tag mirrors one entry in the `GET /tags` response. `Color` is optional and, +// when present, is one of the string enum values "1".."12" (Desktop's palette +// index, NOT a hex colour). +type Tag struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +// TagFilter is the optional `filter` object on the tags state — the tag +// selection Desktop's UI filters the DBMS list by. +type TagFilter struct { + SelectedTagIds []string `json:"selectedTagIds"` +} + +// TagsState is the full tag catalog returned by every `/tags` route — reads +// AND mutations (POST/PATCH/DELETE all respond with the post-mutation +// state). +type TagsState struct { + Tags []Tag `json:"tags"` + Filter *TagFilter `json:"filter,omitempty"` } // DbmsPlugin is one entry from the relate `dbms-plugins` routes. `Version` is diff --git a/internal/skill/neo4jcli/bundle/references/desktop.md b/internal/skill/neo4jcli/bundle/references/desktop.md index 550069601..e400d1e92 100644 --- a/internal/skill/neo4jcli/bundle/references/desktop.md +++ b/internal/skill/neo4jcli/bundle/references/desktop.md @@ -19,14 +19,25 @@ - [neo4j-cli desktop dbms plugin uninstall](#neo4j-cli-desktop-dbms-plugin-uninstall) - [neo4j-cli desktop dbms start](#neo4j-cli-desktop-dbms-start) - [neo4j-cli desktop dbms stop](#neo4j-cli-desktop-dbms-stop) +- [neo4j-cli desktop dbms update](#neo4j-cli-desktop-dbms-update) - [neo4j-cli desktop dbms upgrade](#neo4j-cli-desktop-dbms-upgrade) - [neo4j-cli desktop doctor](#neo4j-cli-desktop-doctor) - [neo4j-cli desktop install](#neo4j-cli-desktop-install) - [neo4j-cli desktop list](#neo4j-cli-desktop-list) +- [neo4j-cli desktop project](#neo4j-cli-desktop-project) +- [neo4j-cli desktop project create](#neo4j-cli-desktop-project-create) +- [neo4j-cli desktop project delete](#neo4j-cli-desktop-project-delete) +- [neo4j-cli desktop project list](#neo4j-cli-desktop-project-list) +- [neo4j-cli desktop project update](#neo4j-cli-desktop-project-update) +- [neo4j-cli desktop tag](#neo4j-cli-desktop-tag) +- [neo4j-cli desktop tag create](#neo4j-cli-desktop-tag-create) +- [neo4j-cli desktop tag delete](#neo4j-cli-desktop-tag-delete) +- [neo4j-cli desktop tag list](#neo4j-cli-desktop-tag-list) +- [neo4j-cli desktop tag update](#neo4j-cli-desktop-tag-update) Manage DBMSes under a local Neo4j Desktop 2 install -Manage Neo4j Desktop 2 — local DBMSes (`dbms`), saved remote connections (`connection`), and install the Desktop app itself (`install`). `desktop list` shows DBMSes and saved connections together; use `desktop dbms list` or `desktop connection list` for single-resource views. Write commands (`dbms create/delete/start/stop`, `connection create/update/delete`, `install`) require `--rw`. +Manage Neo4j Desktop 2 — local DBMSes (`dbms`), saved remote connections (`connection`), the project catalog (`project`), the tag catalog (`tag`), and install the Desktop app itself (`install`). `desktop list` shows DBMSes and saved connections together; use `desktop dbms list` or `desktop connection list` for single-resource views. Write commands (`dbms create/update/delete/start/stop`, `connection create/update/delete`, `project create/update/delete`, `tag create/update/delete`, `install`) require `--rw`. Usage: `neo4j-cli desktop` @@ -129,7 +140,7 @@ neo4j-cli desktop connection list --port 44225 Update a saved remote DB connection registered with Neo4j Desktop 2 -Update a saved remote DB connection profile by id. Talks to Desktop's local relate API on http://localhost:/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`. `--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`. Find connection ids with `neo4j-cli desktop list`. +Update a saved remote DB connection profile by id. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. 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`. Usage: `neo4j-cli desktop connection update [flags]` @@ -140,6 +151,8 @@ Flags: | `--description` | string | - | New description for the saved connection. Pass an empty string to clear the existing description | | `--name` | string | - | New human-readable name for the saved connection | | `--password` | string | - | New password for the remote DB. Pass an empty value on a TTY to be prompted (no echo); fails on non-TTY | +| `--project` | stringSlice | [] | 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 | +| `--tags` | stringSlice | [] | 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 | | `--uri` | string | - | New Bolt URI for the remote DB (e.g. neo4j+s://abc.databases.neo4j.io) | | `--username` | string | - | New username used to authenticate against the remote DB | @@ -154,13 +167,16 @@ neo4j-cli desktop connection update f4e2f3c0-1111-2222-3333-444455556666 --uri n # 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 + +# 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 ``` ## neo4j-cli desktop dbms Manage local DBMSes under a Neo4j Desktop 2 install -Manage local Neo4j DBMSes running under a Neo4j Desktop 2 install — list, create, delete, start, stop, upgrade. Write commands (`create`, `delete`, `start`, `stop`, `upgrade`) require `--rw`. For a composed view of DBMSes plus saved remote connections see `neo4j-cli desktop list`. +Manage local Neo4j DBMSes running under a Neo4j Desktop 2 install — list, create, delete, start, stop, upgrade, update. Write commands (`create`, `delete`, `start`, `stop`, `upgrade`, `update`) require `--rw`. For a composed view of DBMSes plus saved remote connections see `neo4j-cli desktop list`. Usage: `neo4j-cli desktop dbms` @@ -454,6 +470,39 @@ neo4j-cli desktop dbms stop my-dbms-id --wait --rw neo4j-cli desktop dbms stop my-dbms-id --wait --format json --rw ``` +### neo4j-cli desktop dbms update + +Update a DBMS managed by the local Neo4j Desktop 2 install + +Update a DBMS managed by the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. At least one of `--name --description --project --tags` must be supplied; the PATCH body contains ONLY the keys you set, so empty-string is a legitimate update for `--description`. `--project` and `--tags` have REPLACE semantics: the values you pass become the entire set (re-run with the full list when adding one); pass an empty value (`--tags ""`) to clear the set. Each value is a Desktop catalog name or ID — names are resolved against Desktop's project/tag catalogs before the PATCH, a UUID passes through verbatim. Find DBMS ids with `neo4j-cli desktop list`. + +Usage: `neo4j-cli desktop dbms update [flags]` + +Flags: + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--description` | string | - | New description for the DBMS. Pass an empty string to clear the existing description | +| `--name` | string | - | New human-readable name for the DBMS | +| `--project` | stringSlice | [] | Replace the DBMS's project set with the given Desktop project names or IDs (comma-separated or repeated). Pass an empty value to clear. | +| `--tags` | stringSlice | [] | Replace the DBMS's tag set with the given Desktop tag names or IDs (comma-separated or repeated). Pass an empty value to clear. | + +Examples: + +``` +# Rename a DBMS +neo4j-cli desktop dbms update my-dbms-id --name my-renamed-dbms --rw + +# Replace the tag set (names resolve against Desktop's tag catalog) +neo4j-cli desktop dbms update my-dbms-id --tags prod,eu --rw + +# Move a DBMS into a project and emit the updated DbmsInfo as JSON +neo4j-cli desktop dbms update my-dbms-id --project "Customer 360" --format json --rw + +# Clear all tags from a DBMS +neo4j-cli desktop dbms update my-dbms-id --tags "" --rw +``` + ### neo4j-cli desktop dbms upgrade Upgrade a DBMS managed by the local Neo4j Desktop 2 install @@ -562,3 +611,211 @@ neo4j-cli desktop list --format json neo4j-cli desktop list --port 44225 ``` +## neo4j-cli desktop project + +Manage projects in the local Neo4j Desktop 2 install + +Manage the project catalog of the local Neo4j Desktop 2 install via its local relate API on http://localhost:/fastify/api — Desktop must be running. Projects are Desktop's grouping mechanism for local DBMSes and saved remote connections. Every read and mutation round-trips the full project catalog; `list` shows it, and `create`/`update`/`delete` print the affected entry. Write commands (`create`, `update`, `delete`) require `--rw`. + +Usage: `neo4j-cli desktop project` + +### neo4j-cli desktop project create + +Create a project in the local Neo4j Desktop 2 install + +Create a project in the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. Prints the created project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-create catalog state Desktop returns. + +Usage: `neo4j-cli desktop project create ` + +Examples: + +``` +# Create a Desktop project +neo4j-cli desktop project create my-project --rw + +# Create a Desktop project and emit the created entry as JSON +neo4j-cli desktop project create my-project --format json --rw +``` + +### neo4j-cli desktop project delete + +Delete a project from the local Neo4j Desktop 2 install + +Delete a project from the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. `` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively. Prints a minimal confirmation envelope carrying the removed id. + +Usage: `neo4j-cli desktop project delete [flags]` + +Flags: + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--force` | bool | false | Confirm the destructive action. Required together with --yes for non-TTY callers. | +| `--yes` | bool | false | Confirm the destructive action. Required together with --force for non-TTY callers. | + +Examples: + +``` +# Delete a Desktop project by exact name with an interactive y/N confirmation +neo4j-cli desktop project delete my-project --rw + +# Delete a Desktop project by UUID without prompting (scripts, CI, non-TTY shells) +neo4j-cli desktop project delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a Desktop project and emit a machine-readable confirmation for scripting +neo4j-cli desktop project delete my-project --yes --force --format json --rw +``` + +### neo4j-cli desktop project list + +List projects in the local Neo4j Desktop 2 install + +List the project catalog of the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. Each row carries `id`, `name` and `created_at`; `created_at` is Desktop's unix-milliseconds timestamp rendered as RFC3339 UTC (empty when Desktop omits it for legacy entries). `--format json` emits a JSON array of projects; `--format toon` mirrors the JSON shape. + +Usage: `neo4j-cli desktop project list` + +Examples: + +``` +# List Desktop projects as a table +neo4j-cli desktop project list + +# List Desktop projects as JSON (agent-friendly) +neo4j-cli desktop project list --format json + +# List Desktop projects against a pinned port instead of probing 44222..44232 +neo4j-cli desktop project list --port 44225 +``` + +### neo4j-cli desktop project update + +Rename a project in the local Neo4j Desktop 2 install + +Rename a project in the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. `` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). `--name` is required: Desktop's ProjectUpdateSchema demands a name on every PATCH. Prints the updated project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-update catalog state Desktop returns. + +Usage: `neo4j-cli desktop project update [flags]` + +Flags: + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--name` | string | - | (required) New name for the project | + +Examples: + +``` +# Rename a Desktop project addressed by its exact name +neo4j-cli desktop project update my-project --name my-renamed-project --rw + +# Rename a Desktop project addressed by UUID, emitting the updated entry as JSON +neo4j-cli desktop project update f4e2f3c0-1111-2222-3333-444455556666 --name my-renamed-project --format json --rw +``` + +## neo4j-cli desktop tag + +Manage the tag catalog of a Neo4j Desktop 2 install + +Manage the tag catalog of the local Neo4j Desktop 2 install — list, create, update, delete. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. Tags organise DBMSes and saved remote connections in Desktop's UI; a tag's color is one of Desktop's palette indices "1".."12" (not a hex colour). Write commands (`create`, `update`, `delete`) require `--rw`. + +Usage: `neo4j-cli desktop tag` + +### neo4j-cli desktop tag create + +Create a tag in the local Neo4j Desktop 2 install + +Create a tag in the tag catalog of the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. `--color` is optional and accepts only Desktop's palette indices "1".."12" (not a hex colour); when omitted the POST body carries no color key and Desktop picks a default. Prints the created tag (`id`, `name`, `color`) resolved from the post-create catalog state Desktop returns. + +Usage: `neo4j-cli desktop tag create [flags]` + +Flags: + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--color` | string | - | Optional tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the POST body when unset | + +Examples: + +``` +# Create a tag and let Desktop pick the default color +neo4j-cli desktop tag create production --rw + +# Create a tag with an explicit palette color +neo4j-cli desktop tag create staging --color 5 --rw + +# Create a tag and emit the created tag as JSON +neo4j-cli desktop tag create staging --color 5 --format json --rw +``` + +### neo4j-cli desktop tag delete + +Delete a tag from the local Neo4j Desktop 2 install + +Delete a tag from the tag catalog of the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively; the confirmation fires before any Desktop contact. Prints a minimal confirmation envelope carrying the removed id. + +Usage: `neo4j-cli desktop tag delete [flags]` + +Flags: + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--force` | bool | false | Confirm the destructive action. Required together with --yes for non-TTY callers. | +| `--yes` | bool | false | Confirm the destructive action. Required together with --force for non-TTY callers. | + +Examples: + +``` +# Delete a tag by name with an interactive y/N confirmation +neo4j-cli desktop tag delete staging --rw + +# Delete a tag by UUID without prompting (scripts, CI, non-TTY shells) +neo4j-cli desktop tag delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a tag and emit a machine-readable confirmation for scripting +neo4j-cli desktop tag delete staging --yes --force --format json --rw +``` + +### neo4j-cli desktop tag list + +List tags in the local Neo4j Desktop 2 install + +List the tag catalog of the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. One row per tag with `id`, `name` and `color` (Desktop's palette index "1".."12"; empty when the tag carries no color). `--format json` emits a JSON array of `Tag` objects. `--format toon` mirrors the JSON shape. + +Usage: `neo4j-cli desktop tag list` + +Examples: + +``` +# List tags as a table +neo4j-cli desktop tag list + +# List tags as JSON (full Tag payload, agent-friendly) +neo4j-cli desktop tag list --format json + +# List tags against a pinned port instead of probing 44222..44232 +neo4j-cli desktop tag list --port 44225 +``` + +### neo4j-cli desktop tag update + +Update a tag in the local Neo4j Desktop 2 install + +Update a tag in the tag catalog of the local Neo4j Desktop 2 install. Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). `--name` is required (Desktop's TagUpdateSchema demands it); `--color` accepts only Desktop's palette indices "1".."12" and is omitted from the PATCH body when unset. Prints the updated tag (`id`, `name`, `color`) resolved from the post-update catalog state Desktop returns. + +Usage: `neo4j-cli desktop tag update [flags]` + +Flags: + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--color` | string | - | New tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the PATCH body when unset | +| `--name` | string | - | (required) New name for the tag | + +Examples: + +``` +# Rename a tag selected by name +neo4j-cli desktop tag update staging --name pre-prod --rw + +# Rename and recolor a tag selected by UUID, emitting the updated tag as JSON +neo4j-cli desktop tag update f4e2f3c0-1111-2222-3333-444455556666 --name pre-prod --color 7 --format json --rw +``` + diff --git a/test/e2e/desktop_fixture/scenario.go b/test/e2e/desktop_fixture/scenario.go index 801d28cc9..1ed859487 100644 --- a/test/e2e/desktop_fixture/scenario.go +++ b/test/e2e/desktop_fixture/scenario.go @@ -88,7 +88,7 @@ func scenarioPutConnection(s *state, w http.ResponseWriter, r *http.Request) { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` - Project string `json:"project"` + Projects []string `json:"projects"` ConnectionURI string `json:"connectionUri"` Creds json.RawMessage `json:"creds"` } @@ -105,7 +105,7 @@ func scenarioPutConnection(s *state, w http.ResponseWriter, r *http.Request) { } s.connections[body.ID] = &connection{ ID: body.ID, Name: body.Name, Description: body.Description, - Project: body.Project, ConnectionURI: body.ConnectionURI, + Projects: body.Projects, ConnectionURI: body.ConnectionURI, } if body.Creds != nil { trimmed := strings.TrimSpace(string(body.Creds)) diff --git a/test/e2e/desktop_fixture/state.go b/test/e2e/desktop_fixture/state.go index 2f93d832a..a3b074fd0 100644 --- a/test/e2e/desktop_fixture/state.go +++ b/test/e2e/desktop_fixture/state.go @@ -133,11 +133,11 @@ type dbmsVersion struct { // The fixture round-trips every field; the production client only renders a // subset by default. type connection struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Project string `json:"project,omitempty"` - ConnectionURI string `json:"connectionUri"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Projects []string `json:"projects,omitempty"` + ConnectionURI string `json:"connectionUri"` } // creds is the wire-shape returned by `GET /fastify/api/credentials/:key`. From 3776854d21fbe70d6ba7df546d23ee7b471eee2f Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Tue, 6 Oct 2026 10:56:11 +0200 Subject: [PATCH 2/3] desktop-projects-tags/unsupported-catalog-error: friendly upgrade error when Desktop 2 lacks the projects/tags routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old Desktop 2 versions answer unknown /fastify/api/* routes with the SPA fallback (HTTP 200 + index.html), which surfaced as a raw JSON decode error. decodeState — the single shared decode point for all eight catalog methods and, via ListProjects/ListTags, both name resolvers — now detects a markup-shaped 2xx body and returns a clierr fatal error telling the user this Desktop version does not support projects and tags and to upgrade. Undecodable bodies that do not look like markup keep the detailed decode error. --- .../neo4j-cli-Minor-20261005-160845.yaml | 2 +- internal/desktopclient/projects_tags.go | 16 +++ internal/desktopclient/projects_tags_test.go | 115 ++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml b/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml index 12fe6a93c..3a2209223 100644 --- a/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml +++ b/.changes/unreleased/neo4j-cli-Minor-20261005-160845.yaml @@ -1,4 +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.' +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 diff --git a/internal/desktopclient/projects_tags.go b/internal/desktopclient/projects_tags.go index 23c6f1114..4314e732b 100644 --- a/internal/desktopclient/projects_tags.go +++ b/internal/desktopclient/projects_tags.go @@ -4,6 +4,7 @@ package desktopclient import ( + "bytes" "context" "encoding/json" "net/http" @@ -65,10 +66,25 @@ func (c *Client) DeleteProject(ctx context.Context, id string) (*ProjectsState, return decodeState[ProjectsState](body, "deleted project state") } +// User-visible message when a Desktop version predating the projects/tags +// routes answers them with the SPA fallback (HTTP 200 + index.html). +const errCatalogUnsupported = "This version of Neo4j Desktop 2 does not support projects and tags. Upgrade Neo4j Desktop 2 to the latest version and try again." + // decodeState is the shared JSON decoder for the full-catalog-state bodies // that every /projects and /tags route responds with. `what` names the // decoded payload for the error message (e.g. "created project state"). +// +// Desktop versions predating the projects/tags routes answer unknown +// /fastify/api/* routes with the SPA fallback: HTTP 200 with the index.html +// body. A 2xx body whose trimmed form starts with '<' is therefore treated +// as "this Desktop is too old" and gets the friendly upgrade message instead +// of a raw JSON decode error. Undecodable bodies that do NOT look like +// markup keep the detailed decode error — they indicate a genuine protocol +// break worth reporting, not a missing route. func decodeState[T any](body []byte, what string) (*T, error) { + if bytes.HasPrefix(bytes.TrimSpace(body), []byte("<")) { + return nil, clierr.NewFatalError("%s", errCatalogUnsupported) + } var out T if err := json.Unmarshal(body, &out); err != nil { return nil, clierr.NewFatalError("desktop: failed to decode %s: %s", what, err.Error()) diff --git a/internal/desktopclient/projects_tags_test.go b/internal/desktopclient/projects_tags_test.go index d6a4a093b..1bcc5bdfb 100644 --- a/internal/desktopclient/projects_tags_test.go +++ b/internal/desktopclient/projects_tags_test.go @@ -572,3 +572,118 @@ func TestNonEmptyStrings(t *testing.T) { }) } } + +// TestClient_ProjectTagEndpoints_UnsupportedDesktopVersion covers Desktop +// versions predating the projects/tags routes: they answer unknown +// /fastify/api/* routes with the SPA fallback (HTTP 200, text/html, +// index.html body). Every catalog method — and the name→ID resolvers, which +// fetch the catalog before any PATCH — must surface the friendly upgrade +// message, never a raw decode error or leaked HTML. +func TestClient_ProjectTagEndpoints_UnsupportedDesktopVersion(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + const spaFallback = "\nNeo4j Desktop
" + + cases := []struct { + name string + call func(cl *Client) error + }{ + {name: "ListProjects", call: func(cl *Client) error { _, err := cl.ListProjects(context.Background()); return err }}, + {name: "CreateProject", call: func(cl *Client) error { _, err := cl.CreateProject(context.Background(), "P"); return err }}, + {name: "UpdateProject", call: func(cl *Client) error { _, err := cl.UpdateProject(context.Background(), "p1", "P"); return err }}, + {name: "DeleteProject", call: func(cl *Client) error { _, err := cl.DeleteProject(context.Background(), "p1"); return err }}, + {name: "ListTags", call: func(cl *Client) error { _, err := cl.ListTags(context.Background()); return err }}, + {name: "CreateTag", call: func(cl *Client) error { _, err := cl.CreateTag(context.Background(), "T", ""); return err }}, + {name: "UpdateTag", call: func(cl *Client) error { _, err := cl.UpdateTag(context.Background(), "t1", "T", ""); return err }}, + {name: "DeleteTag", call: func(cl *Client) error { _, err := cl.DeleteTag(context.Background(), "t1"); return err }}, + { + name: "ResolveProjectIDs inherits the error", + call: func(cl *Client) error { + _, err := cl.ResolveProjectIDs(context.Background(), []string{"Prod"}) + return err + }, + }, + { + name: "ResolveTagIDs inherits the error", + call: func(cl *Client) error { _, err := cl.ResolveTagIDs(context.Background(), []string{"Prod"}); return err }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(spaFallback)) + }) + _ = srv + + cl, err := NewClient(probe, salt) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + err = tc.call(cl) + if err == nil { + t.Fatalf("expected unsupported-version error, got nil") + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 1 { + t.Fatalf("error = %v (%T), want clierr fatal error (exit 1)", err, err) + } + if !strings.Contains(ce.Message, "does not support projects and tags") || + !strings.Contains(ce.Message, "Upgrade Neo4j Desktop 2 to the latest version") { + t.Fatalf("message = %q, want friendly upgrade message", ce.Message) + } + if strings.Contains(ce.Message, "<") { + t.Fatalf("message must not leak response HTML, got %q", ce.Message) + } + if strings.Contains(ce.Message, "invalid character") { + t.Fatalf("message must not leak the raw decode error, got %q", ce.Message) + } + }) + } +} + +// TestClient_ProjectTagEndpoints_CorruptJSONKeepsDecodeError pins the decode +// policy: only bodies that look like markup (trimmed body starts with '<') +// get the friendly upgrade message; other undecodable bodies keep the +// detailed decode error, since they indicate a genuine protocol break worth +// reporting rather than a missing route. +func TestClient_ProjectTagEndpoints_CorruptJSONKeepsDecodeError(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + cases := []struct { + name string + body string + }{ + {name: "truncated JSON", body: `{"projects": [`}, + {name: "plain text", body: `not json at all`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tc.body)) + }) + _ = srv + + cl, err := NewClient(probe, salt) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, err = cl.ListProjects(context.Background()) + if err == nil { + t.Fatalf("expected decode error, got nil") + } + var ce *clierr.CLIError + if !errors.As(err, &ce) || ce.Code != 1 { + t.Fatalf("error = %v (%T), want clierr fatal error (exit 1)", err, err) + } + if !strings.Contains(ce.Message, "desktop: failed to decode project list") { + t.Fatalf("message = %q, want the detailed decode error", ce.Message) + } + if strings.Contains(ce.Message, "does not support projects and tags") { + t.Fatalf("non-markup body must not get the upgrade message, got %q", ce.Message) + } + }) + } +} From 1015ea04e5309f1301c01bed0ec4688a8bdc7b8e Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Thu, 15 Oct 2026 11:41:50 +0200 Subject: [PATCH 3/3] desktop-projects-tags/conventions-fix: move project/tag Spec data to spec.go, document catalogcmd exception --- AGENTS.md | 1 + internal/commands/desktop/project/project.go | 84 --------------- internal/commands/desktop/project/spec.go | 92 +++++++++++++++++ internal/commands/desktop/tag/spec.go | 102 +++++++++++++++++++ internal/commands/desktop/tag/tag.go | 94 ----------------- 5 files changed, 195 insertions(+), 178 deletions(-) create mode 100644 internal/commands/desktop/project/spec.go create mode 100644 internal/commands/desktop/tag/spec.go diff --git a/AGENTS.md b/AGENTS.md index 91ac3018e..dab835852 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Strict one-file-per-leaf layout under `internal/commands/aura//` and ` - Colocated `_test.go`; shared helpers in `_helpers_test.go`. - New leaf = `.go` + `_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 diff --git a/internal/commands/desktop/project/project.go b/internal/commands/desktop/project/project.go index d2fe2982e..6e16c91d0 100644 --- a/internal/commands/desktop/project/project.go +++ b/internal/commands/desktop/project/project.go @@ -6,95 +6,11 @@ package project import ( - "context" - "github.com/neo4j/cli/internal/clicfg" "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" - "github.com/neo4j/cli/internal/desktopclient" "github.com/spf13/cobra" ) -// spec declares the project catalog to the catalogcmd scaffold: the noun and -// output fields, the verbatim per-leaf help text, and hooks closing over the -// typed desktopclient project methods. Projects carry no color, so Color is -// nil and the generated leaves have no --color flag. -var spec = catalogcmd.Spec[desktopclient.Project, *desktopclient.ProjectsState]{ - Noun: "project", - Plural: "projects", - Fields: []string{"id", "name", "created_at"}, - UpdateVerb: "Rename", - - ListLong: "List the project catalog of the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "Each row carries `id`, `name` and `created_at`; `created_at` is Desktop's unix-milliseconds timestamp rendered as RFC3339 UTC (empty when Desktop omits it for legacy entries). " + - "`--format json` emits a JSON array of projects; `--format toon` mirrors the JSON shape.", - ListExample: `# List Desktop projects as a table -neo4j-cli desktop project list - -# List Desktop projects as JSON (agent-friendly) -neo4j-cli desktop project list --format json - -# List Desktop projects against a pinned port instead of probing 44222..44232 -neo4j-cli desktop project list --port 44225`, - CreateLong: "Create a project in the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "Prints the created project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-create catalog state Desktop returns.", - CreateExample: `# Create a Desktop project -neo4j-cli desktop project create my-project --rw - -# Create a Desktop project and emit the created entry as JSON -neo4j-cli desktop project create my-project --format json --rw`, - UpdateLong: "Rename a project in the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "`` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). " + - "`--name` is required: Desktop's ProjectUpdateSchema demands a name on every PATCH. " + - "Prints the updated project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-update catalog state Desktop returns.", - UpdateExample: `# Rename a Desktop project addressed by its exact name -neo4j-cli desktop project update my-project --name my-renamed-project --rw - -# Rename a Desktop project addressed by UUID, emitting the updated entry as JSON -neo4j-cli desktop project update f4e2f3c0-1111-2222-3333-444455556666 --name my-renamed-project --format json --rw`, - DeleteLong: "Delete a project from the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "`` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). " + - "Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively. " + - "Prints a minimal confirmation envelope carrying the removed id.", - DeleteExample: `# Delete a Desktop project by exact name with an interactive y/N confirmation -neo4j-cli desktop project delete my-project --rw - -# Delete a Desktop project by UUID without prompting (scripts, CI, non-TTY shells) -neo4j-cli desktop project delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw - -# Delete a Desktop project and emit a machine-readable confirmation for scripting -neo4j-cli desktop project delete my-project --yes --force --format json --rw`, - - List: func(ctx context.Context, c *desktopclient.Client) ([]desktopclient.Project, error) { - return c.ListProjects(ctx) - }, - Create: func(ctx context.Context, c *desktopclient.Client, name, _ string) (*desktopclient.ProjectsState, error) { - return c.CreateProject(ctx, name) - }, - Update: func(ctx context.Context, c *desktopclient.Client, id, name, _ string) (*desktopclient.ProjectsState, error) { - return c.UpdateProject(ctx, id, name) - }, - Delete: func(ctx context.Context, c *desktopclient.Client, id string) error { - _, err := c.DeleteProject(ctx, id) - return err - }, - Resolve: func(ctx context.Context, c *desktopclient.Client, nameOrID string) (string, error) { - ids, err := c.ResolveProjectIDs(ctx, []string{nameOrID}) - if err != nil { - return "", err - } - return ids[0], nil - }, - FindByName: desktopclient.FindProjectByName, - FindByID: desktopclient.FindProjectByID, - - RowJSON: func(p desktopclient.Project) any { return toProjectOutput(p) }, - RowValues: func(p desktopclient.Project) []string { return []string{p.ID, p.Name, formatCreatedAt(p.CreatedAt)} }, -} - // NewCmd returns the `desktop project` parent cobra command. func NewCmd(cfg *clicfg.Config) *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/commands/desktop/project/spec.go b/internal/commands/desktop/project/spec.go new file mode 100644 index 000000000..0e044f685 --- /dev/null +++ b/internal/commands/desktop/project/spec.go @@ -0,0 +1,92 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package project + +import ( + "context" + + "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" + "github.com/neo4j/cli/internal/desktopclient" +) + +// spec declares the project catalog to the catalogcmd scaffold: the noun and +// output fields, the verbatim per-leaf help text, and hooks closing over the +// typed desktopclient project methods. Projects carry no color, so Color is +// nil and the generated leaves have no --color flag. +var spec = catalogcmd.Spec[desktopclient.Project, *desktopclient.ProjectsState]{ + Noun: "project", + Plural: "projects", + Fields: []string{"id", "name", "created_at"}, + UpdateVerb: "Rename", + + ListLong: "List the project catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "Each row carries `id`, `name` and `created_at`; `created_at` is Desktop's unix-milliseconds timestamp rendered as RFC3339 UTC (empty when Desktop omits it for legacy entries). " + + "`--format json` emits a JSON array of projects; `--format toon` mirrors the JSON shape.", + ListExample: `# List Desktop projects as a table +neo4j-cli desktop project list + +# List Desktop projects as JSON (agent-friendly) +neo4j-cli desktop project list --format json + +# List Desktop projects against a pinned port instead of probing 44222..44232 +neo4j-cli desktop project list --port 44225`, + CreateLong: "Create a project in the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "Prints the created project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-create catalog state Desktop returns.", + CreateExample: `# Create a Desktop project +neo4j-cli desktop project create my-project --rw + +# Create a Desktop project and emit the created entry as JSON +neo4j-cli desktop project create my-project --format json --rw`, + UpdateLong: "Rename a project in the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "`` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). " + + "`--name` is required: Desktop's ProjectUpdateSchema demands a name on every PATCH. " + + "Prints the updated project (`id`, `name`, `created_at` as RFC3339 UTC) resolved from the post-update catalog state Desktop returns.", + UpdateExample: `# Rename a Desktop project addressed by its exact name +neo4j-cli desktop project update my-project --name my-renamed-project --rw + +# Rename a Desktop project addressed by UUID, emitting the updated entry as JSON +neo4j-cli desktop project update f4e2f3c0-1111-2222-3333-444455556666 --name my-renamed-project --format json --rw`, + DeleteLong: "Delete a project from the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "`` accepts a project UUID or an exact project name from the Desktop catalog (see `neo4j-cli desktop project list`). " + + "Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively. " + + "Prints a minimal confirmation envelope carrying the removed id.", + DeleteExample: `# Delete a Desktop project by exact name with an interactive y/N confirmation +neo4j-cli desktop project delete my-project --rw + +# Delete a Desktop project by UUID without prompting (scripts, CI, non-TTY shells) +neo4j-cli desktop project delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a Desktop project and emit a machine-readable confirmation for scripting +neo4j-cli desktop project delete my-project --yes --force --format json --rw`, + + List: func(ctx context.Context, c *desktopclient.Client) ([]desktopclient.Project, error) { + return c.ListProjects(ctx) + }, + Create: func(ctx context.Context, c *desktopclient.Client, name, _ string) (*desktopclient.ProjectsState, error) { + return c.CreateProject(ctx, name) + }, + Update: func(ctx context.Context, c *desktopclient.Client, id, name, _ string) (*desktopclient.ProjectsState, error) { + return c.UpdateProject(ctx, id, name) + }, + Delete: func(ctx context.Context, c *desktopclient.Client, id string) error { + _, err := c.DeleteProject(ctx, id) + return err + }, + Resolve: func(ctx context.Context, c *desktopclient.Client, nameOrID string) (string, error) { + ids, err := c.ResolveProjectIDs(ctx, []string{nameOrID}) + if err != nil { + return "", err + } + return ids[0], nil + }, + FindByName: desktopclient.FindProjectByName, + FindByID: desktopclient.FindProjectByID, + + RowJSON: func(p desktopclient.Project) any { return toProjectOutput(p) }, + RowValues: func(p desktopclient.Project) []string { return []string{p.ID, p.Name, formatCreatedAt(p.CreatedAt)} }, +} diff --git a/internal/commands/desktop/tag/spec.go b/internal/commands/desktop/tag/spec.go new file mode 100644 index 000000000..243d6f025 --- /dev/null +++ b/internal/commands/desktop/tag/spec.go @@ -0,0 +1,102 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package tag + +import ( + "context" + + "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" + "github.com/neo4j/cli/internal/desktopclient" +) + +// spec declares the tag catalog to the catalogcmd scaffold: the noun and +// output fields, the verbatim per-leaf help text, and hooks closing over the +// typed desktopclient tag methods. Tags carry an optional palette color, so +// Color is set and the generated create/update leaves take --color. +var spec = catalogcmd.Spec[desktopclient.Tag, *desktopclient.TagsState]{ + Noun: "tag", + Plural: "tags", + Fields: []string{"id", "name", "color"}, + UpdateVerb: "Update", + + ListLong: "List the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "One row per tag with `id`, `name` and `color` (Desktop's palette index \"1\"..\"12\"; empty when the tag carries no color). " + + "`--format json` emits a JSON array of `Tag` objects. " + + "`--format toon` mirrors the JSON shape.", + ListExample: `# List tags as a table +neo4j-cli desktop tag list + +# List tags as JSON (full Tag payload, agent-friendly) +neo4j-cli desktop tag list --format json + +# List tags against a pinned port instead of probing 44222..44232 +neo4j-cli desktop tag list --port 44225`, + CreateLong: "Create a tag in the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "`--color` is optional and accepts only Desktop's palette indices \"1\"..\"12\" (not a hex colour); when omitted the POST body carries no color key and Desktop picks a default. " + + "Prints the created tag (`id`, `name`, `color`) resolved from the post-create catalog state Desktop returns.", + CreateExample: `# Create a tag and let Desktop pick the default color +neo4j-cli desktop tag create production --rw + +# Create a tag with an explicit palette color +neo4j-cli desktop tag create staging --color 5 --rw + +# Create a tag and emit the created tag as JSON +neo4j-cli desktop tag create staging --color 5 --format json --rw`, + UpdateLong: "Update a tag in the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). " + + "`--name` is required (Desktop's TagUpdateSchema demands it); `--color` accepts only Desktop's palette indices \"1\"..\"12\" and is omitted from the PATCH body when unset. " + + "Prints the updated tag (`id`, `name`, `color`) resolved from the post-update catalog state Desktop returns.", + UpdateExample: `# Rename a tag selected by name +neo4j-cli desktop tag update staging --name pre-prod --rw + +# Rename and recolor a tag selected by UUID, emitting the updated tag as JSON +neo4j-cli desktop tag update f4e2f3c0-1111-2222-3333-444455556666 --name pre-prod --color 7 --format json --rw`, + DeleteLong: "Delete a tag from the tag catalog of the local Neo4j Desktop 2 install. " + + "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + + "The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). " + + "Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively; the confirmation fires before any Desktop contact. " + + "Prints a minimal confirmation envelope carrying the removed id.", + DeleteExample: `# Delete a tag by name with an interactive y/N confirmation +neo4j-cli desktop tag delete staging --rw + +# Delete a tag by UUID without prompting (scripts, CI, non-TTY shells) +neo4j-cli desktop tag delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw + +# Delete a tag and emit a machine-readable confirmation for scripting +neo4j-cli desktop tag delete staging --yes --force --format json --rw`, + + Color: &catalogcmd.ColorSpec{ + CreateUsage: `Optional tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the POST body when unset`, + UpdateUsage: `New tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the PATCH body when unset`, + }, + + List: func(ctx context.Context, c *desktopclient.Client) ([]desktopclient.Tag, error) { + return c.ListTags(ctx) + }, + Create: func(ctx context.Context, c *desktopclient.Client, name, color string) (*desktopclient.TagsState, error) { + return c.CreateTag(ctx, name, color) + }, + Update: func(ctx context.Context, c *desktopclient.Client, id, name, color string) (*desktopclient.TagsState, error) { + return c.UpdateTag(ctx, id, name, color) + }, + Delete: func(ctx context.Context, c *desktopclient.Client, id string) error { + _, err := c.DeleteTag(ctx, id) + return err + }, + Resolve: func(ctx context.Context, c *desktopclient.Client, nameOrID string) (string, error) { + ids, err := c.ResolveTagIDs(ctx, []string{nameOrID}) + if err != nil { + return "", err + } + return ids[0], nil + }, + FindByName: desktopclient.FindTagByName, + FindByID: desktopclient.FindTagByID, + + RowJSON: func(t desktopclient.Tag) any { return t }, + RowValues: func(t desktopclient.Tag) []string { return []string{t.ID, t.Name, t.Color} }, +} diff --git a/internal/commands/desktop/tag/tag.go b/internal/commands/desktop/tag/tag.go index c7c9a6123..c057b4331 100644 --- a/internal/commands/desktop/tag/tag.go +++ b/internal/commands/desktop/tag/tag.go @@ -6,105 +6,11 @@ package tag import ( - "context" - "github.com/neo4j/cli/internal/clicfg" "github.com/neo4j/cli/internal/commands/desktop/internal/catalogcmd" - "github.com/neo4j/cli/internal/desktopclient" "github.com/spf13/cobra" ) -// spec declares the tag catalog to the catalogcmd scaffold: the noun and -// output fields, the verbatim per-leaf help text, and hooks closing over the -// typed desktopclient tag methods. Tags carry an optional palette color, so -// Color is set and the generated create/update leaves take --color. -var spec = catalogcmd.Spec[desktopclient.Tag, *desktopclient.TagsState]{ - Noun: "tag", - Plural: "tags", - Fields: []string{"id", "name", "color"}, - UpdateVerb: "Update", - - ListLong: "List the tag catalog of the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "One row per tag with `id`, `name` and `color` (Desktop's palette index \"1\"..\"12\"; empty when the tag carries no color). " + - "`--format json` emits a JSON array of `Tag` objects. " + - "`--format toon` mirrors the JSON shape.", - ListExample: `# List tags as a table -neo4j-cli desktop tag list - -# List tags as JSON (full Tag payload, agent-friendly) -neo4j-cli desktop tag list --format json - -# List tags against a pinned port instead of probing 44222..44232 -neo4j-cli desktop tag list --port 44225`, - CreateLong: "Create a tag in the tag catalog of the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "`--color` is optional and accepts only Desktop's palette indices \"1\"..\"12\" (not a hex colour); when omitted the POST body carries no color key and Desktop picks a default. " + - "Prints the created tag (`id`, `name`, `color`) resolved from the post-create catalog state Desktop returns.", - CreateExample: `# Create a tag and let Desktop pick the default color -neo4j-cli desktop tag create production --rw - -# Create a tag with an explicit palette color -neo4j-cli desktop tag create staging --color 5 --rw - -# Create a tag and emit the created tag as JSON -neo4j-cli desktop tag create staging --color 5 --format json --rw`, - UpdateLong: "Update a tag in the tag catalog of the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). " + - "`--name` is required (Desktop's TagUpdateSchema demands it); `--color` accepts only Desktop's palette indices \"1\"..\"12\" and is omitted from the PATCH body when unset. " + - "Prints the updated tag (`id`, `name`, `color`) resolved from the post-update catalog state Desktop returns.", - UpdateExample: `# Rename a tag selected by name -neo4j-cli desktop tag update staging --name pre-prod --rw - -# Rename and recolor a tag selected by UUID, emitting the updated tag as JSON -neo4j-cli desktop tag update f4e2f3c0-1111-2222-3333-444455556666 --name pre-prod --color 7 --format json --rw`, - DeleteLong: "Delete a tag from the tag catalog of the local Neo4j Desktop 2 install. " + - "Talks to Desktop's local relate API on http://localhost:/fastify/api — Desktop must be running. " + - "The positional `` accepts a tag UUID or an exact tag name (find both with `neo4j-cli desktop tag list`). " + - "Destructive: requires `--yes --force` (or a `y` answer at the TTY prompt) when invoked non-interactively; the confirmation fires before any Desktop contact. " + - "Prints a minimal confirmation envelope carrying the removed id.", - DeleteExample: `# Delete a tag by name with an interactive y/N confirmation -neo4j-cli desktop tag delete staging --rw - -# Delete a tag by UUID without prompting (scripts, CI, non-TTY shells) -neo4j-cli desktop tag delete f4e2f3c0-1111-2222-3333-444455556666 --yes --force --rw - -# Delete a tag and emit a machine-readable confirmation for scripting -neo4j-cli desktop tag delete staging --yes --force --format json --rw`, - - Color: &catalogcmd.ColorSpec{ - CreateUsage: `Optional tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the POST body when unset`, - UpdateUsage: `New tag color: one of Desktop's palette indices "1".."12" (not a hex colour). Omitted from the PATCH body when unset`, - }, - - List: func(ctx context.Context, c *desktopclient.Client) ([]desktopclient.Tag, error) { - return c.ListTags(ctx) - }, - Create: func(ctx context.Context, c *desktopclient.Client, name, color string) (*desktopclient.TagsState, error) { - return c.CreateTag(ctx, name, color) - }, - Update: func(ctx context.Context, c *desktopclient.Client, id, name, color string) (*desktopclient.TagsState, error) { - return c.UpdateTag(ctx, id, name, color) - }, - Delete: func(ctx context.Context, c *desktopclient.Client, id string) error { - _, err := c.DeleteTag(ctx, id) - return err - }, - Resolve: func(ctx context.Context, c *desktopclient.Client, nameOrID string) (string, error) { - ids, err := c.ResolveTagIDs(ctx, []string{nameOrID}) - if err != nil { - return "", err - } - return ids[0], nil - }, - FindByName: desktopclient.FindTagByName, - FindByID: desktopclient.FindTagByID, - - RowJSON: func(t desktopclient.Tag) any { return t }, - RowValues: func(t desktopclient.Tag) []string { return []string{t.ID, t.Name, t.Color} }, -} - // NewCmd returns the `desktop tag` parent cobra command with all leaves // mounted. The desktop root's persistent --port flag is inherited; tag does // not re-register it.