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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changes/unreleased/neo4j-cli-Patch-20261014-093059.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: neo4j-cli
kind: Patch
body: 'Fixed `desktop list` and `desktop connection list` failing against Neo4j Desktop 2.3, which reports a connection''s `createdAt` as a numeric timestamp instead of a string.'
time: 2026-10-14T09:30:59+00:00
11 changes: 10 additions & 1 deletion internal/commands/desktop/connection/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestConnectionList_JSON_RendersConnectionArray(t *testing.T) {
return
}
_, _ = w.Write([]byte(`[
{"id":"c1","name":"aura-dev","connectionUri":"neo4j+s://abc.databases.neo4j.io"},
{"id":"c1","name":"aura-dev","connectionUri":"neo4j+s://abc.databases.neo4j.io","createdAt":1747843200000},
{"id":"c2","name":"self-hosted","connectionUri":"neo4j://10.0.0.1:7687"}
]`))
})
Expand All @@ -102,6 +102,15 @@ func TestConnectionList_JSON_RendersConnectionArray(t *testing.T) {
if got[0]["id"] != "c1" || got[1]["id"] != "c2" {
t.Fatalf("expected ids [c1 c2], got %v", got)
}
// Desktop 2.3's numeric createdAt renders as RFC3339 UTC; the key is
// omitted entirely when Desktop does not report one.
wantCreatedAt := time.UnixMilli(1747843200000).UTC().Format(time.RFC3339)
if got[0]["created_at"] != wantCreatedAt {
t.Fatalf("expected created_at=%q, got %v", wantCreatedAt, got[0]["created_at"])
}
if _, ok := got[1]["created_at"]; ok {
t.Fatalf("expected created_at omitted when absent on the wire, got %v", got[1])
}
}

func TestConnectionList_EmptyJSON_RendersEmptyArrayNotNull(t *testing.T) {
Expand Down
11 changes: 10 additions & 1 deletion internal/commands/desktop/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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","projects":["my-proj"]},
{"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","projects":["my-proj"],"createdAt":1747843200000},
{"id":"c2","name":"Sandbox","connectionUri":"neo4j+s://xyz.databases.neo4j.io"}
]`
h.withHandler(defaultListHandler(t, slim, perID, connections))
Expand Down Expand Up @@ -197,6 +197,15 @@ func TestList_JSON_BothPopulated(t *testing.T) {
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"])
}
// Desktop 2.3's numeric createdAt decodes and renders as RFC3339 UTC;
// the key is omitted entirely when Desktop does not report one.
wantCreatedAt := time.UnixMilli(1747843200000).UTC().Format(time.RFC3339)
if out.Connections[0]["created_at"] != wantCreatedAt {
t.Fatalf("expected created_at=%q, got %v", wantCreatedAt, out.Connections[0]["created_at"])
}
if _, ok := out.Connections[1]["created_at"]; ok {
t.Fatalf("expected created_at omitted when absent on the wire, got %v", out.Connections[1])
}
}

func TestList_JSON_DbmssOnly(t *testing.T) {
Expand Down
13 changes: 1 addition & 12 deletions internal/commands/desktop/project/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
package project

import (
"time"

"github.com/neo4j/cli/internal/desktopclient"
)

Expand All @@ -21,14 +19,5 @@ type projectOutput struct {
}

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)
return projectOutput{ID: p.ID, Name: p.Name, CreatedAt: desktopclient.FormatUnixMsRFC3339(p.CreatedAt)}
}
6 changes: 4 additions & 2 deletions internal/commands/desktop/project/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ neo4j-cli desktop project delete my-project --yes --force --format json --rw`,
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)} },
RowJSON: func(p desktopclient.Project) any { return toProjectOutput(p) },
RowValues: func(p desktopclient.Project) []string {
return []string{p.ID, p.Name, desktopclient.FormatUnixMsRFC3339(p.CreatedAt)}
},
}
45 changes: 45 additions & 0 deletions internal/desktopclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,51 @@ func TestClient_ListConnections_SendsAuthHeadersAndDecodes(t *testing.T) {
}
}

func TestClient_ListConnections_CreatedAtDecode(t *testing.T) {
// Desktop 2.3 changed the createdAt wire field from an RFC3339 string to
// a numeric unix-milliseconds epoch; older Desktop versions omit the
// field entirely. Both shapes must decode.
tests := []struct {
name string
payload string
want int64
}{
{
name: "numeric createdAt (Desktop 2.3)",
payload: `[{"id":"c1","name":"aura","connectionUri":"neo4j+s://abc.databases.neo4j.io","createdAt":1747843200000}]`,
want: 1747843200000,
},
{
name: "createdAt absent (older Desktop)",
payload: `[{"id":"c1","name":"aura","connectionUri":"neo4j+s://abc.databases.neo4j.io"}]`,
want: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(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(tc.payload))
})
_ = srv

cl, _ := NewClient(probe, salt)
got, err := cl.ListConnections(context.Background())
if err != nil {
t.Fatalf("ListConnections: %v", err)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].CreatedAt != tc.want {
t.Fatalf("CreatedAt = %d, want %d", got[0].CreatedAt, tc.want)
}
})
}
}

func TestClient_ListConnections_EmptyResponse(t *testing.T) {
const salt, clientID = "salt", "cid"
pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC))
Expand Down
33 changes: 31 additions & 2 deletions internal/desktopclient/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

package desktopclient

import "time"

// This file holds OUTPUT projections only. The wire structs in types.go keep
// Desktop's camelCase tags because they both parse Desktop's HTTP responses and
// serialize request bodies. These projections re-tag the CLI-rendered output as
Expand Down Expand Up @@ -41,6 +43,8 @@ func DbmsInfoOutputs(items []DbmsInfo) []DbmsInfoOutput {
}

// ConnectionOutput is the snake_case CLI-rendered projection of Connection.
// CreatedAt is Desktop's unix-milliseconds timestamp rendered as RFC3339 UTC,
// omitted when Desktop does not report one.
type ConnectionOutput struct {
ID string `json:"id"`
Name string `json:"name"`
Expand All @@ -53,8 +57,33 @@ type ConnectionOutput struct {
ManifestPath string `json:"manifest_path,omitempty"`
}

// ToOutput projects a Connection onto its snake_case render shape.
func (c Connection) ToOutput() ConnectionOutput { return ConnectionOutput(c) }
// ToOutput projects a Connection onto its snake_case render shape. Unlike
// DbmsInfo this is NOT a straight struct conversion: the wire's int64
// unix-milliseconds CreatedAt is rendered as an RFC3339 UTC string.
func (c Connection) ToOutput() ConnectionOutput {
return ConnectionOutput{
ID: c.ID,
Name: c.Name,
Description: c.Description,
Tags: c.Tags,
Projects: c.Projects,
Metadata: c.Metadata,
ConnectionURI: c.ConnectionURI,
CreatedAt: FormatUnixMsRFC3339(c.CreatedAt),
ManifestPath: c.ManifestPath,
}
}

// FormatUnixMsRFC3339 renders a unix-milliseconds timestamp as RFC3339 UTC;
// a non-positive (zero/absent) timestamp renders as "" so `omitempty` drops
// the key. This is the single unix-ms → RFC3339 formatter shared by every CLI
// projection of a Desktop timestamp.
func FormatUnixMsRFC3339(unixMs int64) string {
if unixMs <= 0 {
return ""
}
return time.UnixMilli(unixMs).UTC().Format(time.RFC3339)
}

// ConnectionOutputs projects a slice, returning a non-nil empty slice for nil
// input so JSON renders `[]` rather than `null`.
Expand Down
76 changes: 76 additions & 0 deletions internal/desktopclient/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) "Neo4j"
// Neo4j Sweden AB [http://neo4j.com]

package desktopclient

import (
"encoding/json"
"testing"
"time"
)

func TestFormatUnixMsRFC3339(t *testing.T) {
tests := []struct {
name string
unixMs int64
want string
}{
{name: "positive unix-ms renders RFC3339 UTC", unixMs: 1747843200000, want: "2025-05-21T16:00:00Z"},
{name: "zero renders empty", unixMs: 0, want: ""},
{name: "negative renders empty", unixMs: -1, want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := FormatUnixMsRFC3339(tc.unixMs); got != tc.want {
t.Fatalf("FormatUnixMsRFC3339(%d) = %q, want %q", tc.unixMs, got, tc.want)
}
})
}
}

func TestConnectionOutputs_CreatedAtRendering(t *testing.T) {
// Desktop 2.3 reports createdAt as unix-milliseconds; the CLI renders it
// as RFC3339 UTC under `created_at`, omitted when Desktop does not report
// one (older Desktop versions / legacy entries).
const unixMs = 1747843200000
tests := []struct {
name string
connection Connection
wantKey bool
}{
{
name: "numeric createdAt renders RFC3339 UTC",
connection: Connection{ID: "c1", Name: "aura", ConnectionURI: "neo4j+s://abc.databases.neo4j.io", CreatedAt: unixMs},
wantKey: true,
},
{
name: "absent createdAt is omitted",
connection: Connection{ID: "c2", Name: "local", ConnectionURI: "neo4j://localhost:7687"},
wantKey: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
raw, err := json.Marshal(ConnectionOutputs([]Connection{tc.connection}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got []map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v (raw: %s)", err, raw)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
gotVal, ok := got[0]["created_at"]
if ok != tc.wantKey {
t.Fatalf("created_at presence = %v, want %v (raw: %s)", ok, tc.wantKey, raw)
}
if tc.wantKey {
if want := time.UnixMilli(unixMs).UTC().Format(time.RFC3339); gotVal != want {
t.Fatalf("created_at = %v, want %q", gotVal, want)
}
}
})
}
}
5 changes: 3 additions & 2 deletions internal/desktopclient/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ type Credentials struct {
// Connection is a saved remote DB connection profile (Aura URI / remote Neo4j
// endpoint) the user has registered with Desktop. Optional fields are
// `omitempty` so they disappear from `--format json` output when Desktop
// doesn't populate them.
// doesn't populate them. `CreatedAt` is an optional unix-milliseconds
// timestamp (Desktop 2.3+); older Desktop versions omit it.
type Connection struct {
ID string `json:"id"`
Name string `json:"name"`
Expand All @@ -71,7 +72,7 @@ type Connection struct {
Projects []string `json:"projects,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ConnectionURI string `json:"connectionUri,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
ManifestPath string `json:"manifestPath,omitempty"`
}

Expand Down
Loading