neonctl 2.31.1 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -347,6 +347,34 @@ $ cat .neon
347
347
 
348
348
  After pinning the branch, `checkout` also runs [`env pull`](#env-pull) by default, so the branch's Neon env vars are written to your local `.env` and you can start building right away — the branch-first loop is just `link` + `checkout`. Pass `--no-env-pull` to skip it (for example when env is injected at runtime via `neon-env run` / `neon dev`, or to keep secrets out of the working tree). A pull failure never undoes the checkout: the branch stays pinned and the failure is surfaced as a warning pointing you at `neon env pull` (or `neon deploy` if a `neon.ts`-declared service is missing).
349
349
 
350
+ ### diff
351
+
352
+ `diff [compare-branch]` prints a **git-style** schema diff between the branch you're on and another branch — the top-level companion to `checkout`. It reads the branch pinned in `.neon` as the side under review (the `+++` side) and compares it against the branch you name (the `---`, reference side), so `+` lines are what your current branch adds on top of the reference:
353
+
354
+ ```bash
355
+ # On feature/add-comments (pinned in .neon), see how it differs from main:
356
+ $ neon diff main
357
+ → Comparing schema main → feature/add-comments
358
+ diff --neon database neondb
359
+ --- main (br-crimson-snow-12345678)
360
+ +++ feature/add-comments (br-dry-salad-87654321)
361
+ @@ -63,7 +64,8 @@
362
+ CREATE TABLE public.users (
363
+ id integer NOT NULL,
364
+ email text NOT NULL,
365
+ - created_at timestamp with time zone DEFAULT now()
366
+ + created_at timestamp with time zone DEFAULT now(),
367
+ + display_name text
368
+ );
369
+ ```
370
+
371
+ - **`compare-branch`** is optional. Omit it to compare the current branch against its **parent** (`neon diff` answers "what did I change since branching?"). It accepts a branch **name** or `br-…` **id**.
372
+ - **`--branch, -b <name|id>`** overrides the side under review instead of reading `.neon` — e.g. `neon diff main --branch feature/checkout` diffs an explicit branch against `main`.
373
+ - **`--database, --db <name>`** limits the diff to one database; by default every database on the current branch is compared (each rendered as its own `diff --neon database <name>` block). A database missing on the reference side shows as fully added.
374
+ - **`--output json|yaml`** emits a structured result per database (`{ database, base_branch, compare_branch, has_changes, diff }`) for scripting; the default renders the colorized git-style diff (respecting `--no-color` and non-TTY pipes).
375
+
376
+ The human-readable summary line goes to stderr and the diff body to stdout, so `neon diff main > changes.patch` captures just the diff. When the schemas match, `diff` prints `No schema differences …` and writes nothing to stdout. For history-aware comparisons (a branch against its own past state at a timestamp or LSN), use [`branches schema-diff`](https://neon.com/docs/reference/cli-branches#schema-diff).
377
+
350
378
  ### env pull
351
379
 
352
380
  `env pull` writes the linked branch's Neon environment variables into a local dotenv file: an existing `.env` if you have one, otherwise `.env.local` (override with `--file <path>`). Only Neon-managed keys (`DATABASE_URL`, `DATABASE_URL_UNPOOLED`, and the Neon Auth / Data API URLs when those services are enabled) are written; any other lines in the file are preserved. The branch comes from the closest `.neon` file, so no `--branch` is needed (pass `--branch <id|name>` to target another branch).
@@ -420,7 +448,7 @@ The branch is chosen with `--branch <id|name>`; without it the project's default
420
448
  - `--update-existing` — auto-confirm overriding existing remote settings on the branch. Without it, drift on settings already present remotely (compute, TTL, `protected`) is reported as a **conflict** and `apply` makes no changes until you resolve it or pass this flag.
421
449
  - `--allow-protected` — auto-confirm applying to a branch Neon marks as protected. Without it, `apply` refuses to touch a protected branch.
422
450
 
423
- **Output**: `status` prints the project, branch, and reverse-engineered config; `plan` / `apply` print the planned/applied changes and any conflicts as tables. Pass `--output json` (or `--output yaml`) to emit the full machine-readable result (`PushResult`) for piping into other tools or CI.
451
+ **Output**: `status` prints the project, branch, and reverse-engineered config. `plan` / `apply` render a **`git diff`-style report** (matching [`neon diff`](#diff)): service changes (Neon Auth, Data API, buckets, functions) list as green `+` additions, while **branch setting changes** (TTL, `protected`, compute) show grouped under a `~ <branch>` header, one sorted `field → value` line each. A bare `apply` that hits drift on settings already present remotely prints those as a sorted **before→after** diff (`current → desired`, old in red / new in green) and exits non-zero until you pass `--update-existing`. Pass `--output json` (or `--output yaml`) to emit the full machine-readable result (`PushResult`) instead, for piping into other tools or CI.
424
452
 
425
453
  **`config status --current-branch`** (alias `neon status --current-branch`) prints _only_ the branch pinned in the local `.neon` file — no network, no auth, no analytics — and exits non-zero when none is pinned. This behavior lets it safely drive a shell prompt. Example [starship](https://starship.rs) segment:
426
454
 
@@ -495,6 +523,7 @@ The target directory must be empty unless you pass `--force` (a lone `.git` is i
495
523
  | set-context | | Deprecated; use `link` |
496
524
  | env | `pull` | Manage a branch's env vars |
497
525
  | checkout | | Pin a branch in `.neon` |
526
+ | diff | | Git-style schema diff vs a branch |
498
527
  | [link](https://neon.com/docs/reference/cli-link) | | Link a directory to a project |
499
528
  | config | `status`, `plan`, `apply` | Drive a branch from `neon.ts` |
500
529
  | deploy | | Alias for `config apply` |
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { resolveConfig } from "@neon/config";
4
- import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from "@neon/config-runtime";
4
+ import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, PushConflictError, plan, } from "@neon/config-runtime";
5
5
  import chalk from "chalk";
6
6
  import { toNeonConfigView } from "../config_format.js";
7
7
  import { contextBranch, readContextFile } from "../context.js";
@@ -9,6 +9,7 @@ import { isCi } from "../env.js";
9
9
  import { loadEnvFileIntoProcess } from "../env_file.js";
10
10
  import { log } from "../log.js";
11
11
  import { announceTargetBranch } from "../utils/branch_notice.js";
12
+ import { renderAppliedChanges, renderBranchSettingConflicts, } from "../utils/config_diff.js";
12
13
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
13
14
  import { bundleEntry } from "../utils/esbuild.js";
14
15
  import { addDependenciesArgs, resolvePackageManager, runCommand, } from "../utils/package_manager.js";
@@ -23,19 +24,6 @@ import { autoPullEnvAfterPin } from "./env.js";
23
24
  */
24
25
  const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
25
26
  const INSPECT_FIELDS = ["project", "branch", "config"];
26
- // Deliberately minimal: action/kind/identifier are short and fixed-ish, so the table can
27
- // never overflow. Per-change `details` (a function's long invocationUrl in particular) are
28
- // intentionally NOT a column — they used to be JSON-stringified into a cell and blew the
29
- // table past 190 cols. Function URLs are printed below as a plain list (see reportPushResult),
30
- // and the full details are still available via `--output json`.
31
- const APPLIED_FIELDS = ["action", "kind", "identifier"];
32
- const CONFLICT_FIELDS = [
33
- "identifier",
34
- "field",
35
- "current",
36
- "desired",
37
- "reason",
38
- ];
39
27
  /**
40
28
  * Shared `--env` flag for `config plan|apply` and `deploy`. Loads a `.env` into
41
29
  * `process.env` before the policy is evaluated.
@@ -330,16 +318,29 @@ export const applyCmd = async (props) => {
330
318
  const branch = await resolveBranchRef(props);
331
319
  announceTargetBranch(props, branch, "Applying to branch");
332
320
  const branchId = branch.branchId;
333
- const result = await apply(config, {
334
- projectId: props.projectId,
335
- branchId,
336
- ...(props.apiKey ? { apiKey: props.apiKey } : {}),
337
- ...(props.apiHost ? { apiHost: props.apiHost } : {}),
338
- ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
339
- ...(props.updateExisting ? { updateExisting: true } : {}),
340
- ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
341
- bundleFunction: neonctlBundler,
342
- });
321
+ let result;
322
+ try {
323
+ result = await apply(config, {
324
+ projectId: props.projectId,
325
+ branchId,
326
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
327
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
328
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
329
+ ...(props.updateExisting ? { updateExisting: true } : {}),
330
+ ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
331
+ bundleFunction: neonctlBundler,
332
+ });
333
+ }
334
+ catch (err) {
335
+ // Drift without `--update-existing` throws with the conflicting fields attached.
336
+ // Render them as the same git-style before→after diff, then fail with a concise
337
+ // message (the detailed diff above replaces the library's long multi-line text).
338
+ if (err instanceof PushConflictError) {
339
+ reportConflicts(props, err.conflicts);
340
+ throw new Error("Branch settings conflict with the policy. Re-run with --update-existing to apply the changes shown above.");
341
+ }
342
+ throw err;
343
+ }
343
344
  reportPushResult(props, result, "apply", utilizedServices(config));
344
345
  // After a successful apply/deploy, write the branch's Neon env vars to a local .env —
345
346
  // the same bundled convenience as `link` / `checkout`, so the branch is immediately
@@ -388,56 +389,44 @@ const utilizedServices = (config) => {
388
389
  /**
389
390
  * Render a {@link PushResult}. JSON/YAML output emits the raw result (plus a `services`
390
391
  * summary) verbatim so it can be piped; the human-readable path renders the actual changes
391
- * (dropping noops) and any blocking conflicts as tables, or a "nothing to do" line when both
392
- * are empty — and always closes with the list of services the policy utilizes so a service
393
- * that produces no plan step (Postgres, or the credential-gated AI Gateway) isn't mistaken
394
- * for being missing from the plan above.
392
+ * (dropping noops) and any blocking conflicts as a `git diff`-style report, or a "nothing to
393
+ * do" line when both are empty — and always closes with the list of services the policy
394
+ * utilizes so a service that produces no plan step (Postgres, or the credential-gated AI
395
+ * Gateway) isn't mistaken for being missing from the plan above.
396
+ *
397
+ * The diff is asymmetric on purpose (see the CLI's `neon diff`): **service** changes are
398
+ * additions with no "before", so they list as `+`/`~` lines; **branch setting** changes have
399
+ * a natural before→after, so conflicts render as a sorted `current → desired` diff. Planned
400
+ * branch updates (under `--update-existing`) carry only the new value, so they render
401
+ * desired-only for now (the previous value isn't threaded through the runtime yet).
395
402
  */
396
403
  const reportPushResult = (props, result, mode, services) => {
397
404
  if (props.output === "json" || props.output === "yaml") {
398
405
  writer(props).end({ ...result, services }, { fields: [] });
399
406
  return;
400
407
  }
401
- const changes = result.applied
402
- .filter((change) => change.action !== "noop")
403
- .map((change) => ({
404
- action: change.action,
405
- kind: change.kind,
406
- identifier: change.identifier,
407
- }));
408
- const conflicts = result.conflicts.map((conflict) => ({
409
- identifier: conflict.identifier,
410
- field: conflict.field,
411
- current: stringify(conflict.current),
412
- desired: stringify(conflict.desired),
413
- reason: conflict.reason,
414
- }));
408
+ const appliedChanges = result.applied.filter((change) => change.action !== "noop");
415
409
  // Deployed functions carry their invocation URL in the change details — collect them so
416
410
  // we can list where to call each function without digging through the raw details blob.
417
411
  // Keyed by slug so a function never shows twice.
418
412
  const functionUrlBySlug = new Map();
419
- for (const change of result.applied) {
420
- if (change.action === "noop")
421
- continue;
413
+ for (const change of appliedChanges) {
422
414
  const slug = change.details?.slug;
423
415
  const invocationUrl = change.details?.invocationUrl;
424
416
  if (typeof slug === "string" && typeof invocationUrl === "string") {
425
417
  functionUrlBySlug.set(slug, invocationUrl);
426
418
  }
427
419
  }
420
+ // chalk self-detects TTY/NO_COLOR; `--no-color` (props.color === false) forces plain.
421
+ const color = props.color !== false;
428
422
  const out = writer(props);
429
- const noChanges = changes.length === 0 && conflicts.length === 0;
430
- if (changes.length > 0) {
431
- out.write(changes, {
432
- fields: APPLIED_FIELDS,
433
- title: mode === "plan" ? "Planned changes" : "Applied changes",
434
- });
435
- }
436
- if (conflicts.length > 0) {
437
- out.write(conflicts, { fields: CONFLICT_FIELDS, title: "Conflicts" });
438
- }
439
- // Flush any tables, then append the lists/summary so they read directly below them.
440
- out.end();
423
+ // Conflicts never reach here in the CLI: `plan` runs with updateExisting on, and a bare
424
+ // `apply` throws PushConflictError (rendered by reportConflicts). So an empty applied set
425
+ // is the whole story here.
426
+ const noChanges = appliedChanges.length === 0;
427
+ const appliedText = renderAppliedChanges(appliedChanges, mode === "plan" ? "Planned changes" : "Applied changes", { color });
428
+ if (appliedText)
429
+ out.text(`${appliedText}\n`);
441
430
  // Function URLs are a plain list rather than a table: an invocation URL can be 70+ chars,
442
431
  // which makes any bordered table overflow and wrap awkwardly in a normal terminal. A list
443
432
  // lets each URL reflow on its own line, and stays copy-pasteable.
@@ -452,15 +441,31 @@ const reportPushResult = (props, result, mode, services) => {
452
441
  log.info(`No changes — branch ${result.branchName} already matches the policy.`);
453
442
  }
454
443
  out.text(`\nUtilized services: ${services.join(", ")}\n`);
455
- if (conflicts.length > 0) {
456
- log.info("Resolve the conflicts above, or re-run with --update-existing to override the current remote settings.");
444
+ };
445
+ /**
446
+ * Render the branch-setting {@link ConflictReport}s a bare `apply` refused to override (drift
447
+ * without `--update-existing`) as the git-style before→after diff. JSON/YAML output emits the
448
+ * structured conflicts so it can be piped; the human path prints the sorted diff followed by
449
+ * any conflict whose fix is *not* `--update-existing` (e.g. an immutable "no endpoint" case),
450
+ * so nothing the library's error message carried is lost.
451
+ */
452
+ const reportConflicts = (props, conflicts) => {
453
+ if (props.output === "json" || props.output === "yaml") {
454
+ writer(props).end({ conflicts }, { fields: [] });
455
+ return;
456
+ }
457
+ const out = writer(props);
458
+ const text = renderBranchSettingConflicts([...conflicts], {
459
+ color: props.color !== false,
460
+ });
461
+ if (text)
462
+ out.text(`${text}\n`);
463
+ for (const conflict of conflicts) {
464
+ if (!/updateExisting/i.test(conflict.reason)) {
465
+ out.text(` ! ${conflict.field}: ${conflict.reason}\n`);
466
+ }
457
467
  }
458
468
  };
459
- const stringify = (value) => value === undefined
460
- ? ""
461
- : typeof value === "string"
462
- ? value
463
- : JSON.stringify(value);
464
469
  /**
465
470
  * Apply a `neon.ts` policy to a **freshly created** branch (used by `neonctl checkout`
466
471
  * when it creates a branch). No-op when there is no `neon.ts` on the path from cwd up to
@@ -0,0 +1,222 @@
1
+ import chalk from "chalk";
2
+ import { isNeonApiError } from "../api.js";
3
+ import { log } from "../log.js";
4
+ import { fillSingleProject } from "../utils/enrichers.js";
5
+ import { looksLikeBranchId } from "../utils/formats.js";
6
+ import { renderDatabaseSchemaDiff, renderSchemaDiffReport, } from "../utils/git_diff.js";
7
+ import { writer } from "../writer.js";
8
+ // A top-level shortcut for `branches schema-diff`, framed like `git diff`: it
9
+ // compares the branch you're on (pinned in `.neon`, or `--branch`) against the
10
+ // branch you name, and prints a git-style unified schema diff. Because it has a
11
+ // handler but no subcommands, `diff` is also listed in `NO_SUBCOMMANDS_VERBS`
12
+ // (see index.ts) so a bare `neon diff main` isn't intercepted by the help
13
+ // fallback.
14
+ export const command = "diff [compare-branch]";
15
+ export const describe = "Show a git-style schema diff between the current branch and another branch";
16
+ export const builder = (argv) => argv
17
+ .usage("$0 diff [compare-branch] [options]")
18
+ .positional("compare-branch", {
19
+ describe: "Branch name or id to compare against (the reference / '---' side). " +
20
+ "Defaults to the current branch's parent.",
21
+ type: "string",
22
+ })
23
+ .options({
24
+ "project-id": {
25
+ describe: "Project ID",
26
+ type: "string",
27
+ },
28
+ branch: {
29
+ alias: "b",
30
+ describe: "The branch to review (the '+++' side). Defaults to the branch " +
31
+ "pinned in the local context (.neon).",
32
+ type: "string",
33
+ },
34
+ database: {
35
+ alias: "db",
36
+ describe: "Limit the diff to a single database. Defaults to every database on the current branch.",
37
+ type: "string",
38
+ },
39
+ })
40
+ .middleware(fillSingleProject)
41
+ .middleware((args) => {
42
+ // The positional arrives as `compare-branch`; surface it under the
43
+ // camelCase name the handler reads, and mirror it to `branchId` for
44
+ // analytics (same pattern as the `branches` command group).
45
+ const compareBranch = args["compare-branch"];
46
+ if (typeof compareBranch === "string") {
47
+ args.compareBranch = compareBranch;
48
+ }
49
+ })
50
+ .example([
51
+ [
52
+ "$0 diff main",
53
+ "Diff the current branch's schema against the main branch",
54
+ ],
55
+ [
56
+ "$0 diff",
57
+ "Diff the current branch's schema against its parent branch",
58
+ ],
59
+ [
60
+ "$0 diff main --branch feature/checkout",
61
+ "Diff an explicit branch against main (ignoring the .neon context)",
62
+ ],
63
+ [
64
+ "$0 diff main --db neondb",
65
+ "Diff only the neondb database against main",
66
+ ],
67
+ ]);
68
+ export const handler = async (props) => {
69
+ const branches = (await props.apiClient.listProjectBranches({
70
+ projectId: props.projectId,
71
+ })).data.branches;
72
+ const after = resolveAfterBranch(branches, props.branch);
73
+ const before = resolveBeforeBranch(branches, after, props.compareBranch);
74
+ if (before.branchId === after.branchId) {
75
+ throw new Error(`Nothing to compare: both sides resolve to branch ${after.branchName} (${after.branchId}).`);
76
+ }
77
+ const databases = await resolveDatabases(props, after);
78
+ const diffs = [];
79
+ for (const database of databases) {
80
+ const [beforeSql, afterSql] = await Promise.all([
81
+ fetchSchemaSql(props, before.branchId, database),
82
+ fetchSchemaSql(props, after.branchId, database),
83
+ ]);
84
+ diffs.push({
85
+ database,
86
+ before: { ...before, sql: beforeSql },
87
+ after: { ...after, sql: afterSql },
88
+ });
89
+ }
90
+ if (props.output === "json" || props.output === "yaml") {
91
+ writeStructured(props, diffs);
92
+ return;
93
+ }
94
+ log.info("%s Comparing schema %s → %s", chalk.dim("→"), chalk.red(`${before.branchName}`), chalk.green(`${after.branchName}`));
95
+ const { hasChanges, text } = renderSchemaDiffReport(diffs, {
96
+ color: props.color !== false,
97
+ });
98
+ if (!hasChanges) {
99
+ log.info("No schema differences between %s and %s.", before.branchName, after.branchName);
100
+ return;
101
+ }
102
+ writer(props).text(`${text}\n`);
103
+ };
104
+ /**
105
+ * Resolve the branch under review (`+++` side). Prefers the explicit
106
+ * `branch`/`--branch` value (name or `br-…` id), falling back to the project's
107
+ * default branch. An unknown `br-…` id is trusted as-is (it may be too new to
108
+ * appear in the listing); an unknown *name* is a hard error.
109
+ */
110
+ const resolveAfterBranch = (branches, ref) => {
111
+ if (ref) {
112
+ return resolveRef(branches, ref);
113
+ }
114
+ const def = branches.find((b) => b.default);
115
+ if (!def) {
116
+ throw new Error("No branch specified and no default branch found. Pass --branch <name|id>.");
117
+ }
118
+ return { branchId: def.id, branchName: def.name ?? def.id };
119
+ };
120
+ /**
121
+ * Resolve the reference branch (`---` side). Uses the `compare-branch`
122
+ * positional when given, otherwise the parent of the branch under review — so a
123
+ * bare `neon diff` answers "what did I change since branching?".
124
+ */
125
+ const resolveBeforeBranch = (branches, after, ref) => {
126
+ if (ref) {
127
+ return resolveRef(branches, ref);
128
+ }
129
+ const afterBranch = branches.find((b) => b.id === after.branchId);
130
+ const parentId = afterBranch?.parent_id;
131
+ if (!parentId) {
132
+ throw new Error(`Branch "${after.branchName}" has no parent to compare against. ` +
133
+ "Pass a branch to compare with, e.g. `neon diff main`.");
134
+ }
135
+ const parent = branches.find((b) => b.id === parentId);
136
+ return {
137
+ branchId: parentId,
138
+ branchName: parent?.name ?? parentId,
139
+ };
140
+ };
141
+ /** Resolve a branch reference (name or `br-…` id) against the fetched listing. */
142
+ const resolveRef = (branches, ref) => {
143
+ const found = looksLikeBranchId(ref)
144
+ ? branches.find((b) => b.id === ref)
145
+ : branches.find((b) => b.name === ref);
146
+ if (found) {
147
+ return { branchId: found.id, branchName: found.name ?? found.id };
148
+ }
149
+ // A `br-…` id absent from the listing is still usable as an id; only an
150
+ // unresolved name is a genuine error (mirrors resolveBranchRef in enrichers).
151
+ if (looksLikeBranchId(ref)) {
152
+ return { branchId: ref, branchName: ref };
153
+ }
154
+ throw new Error(`Branch ${ref} not found.\nAvailable branches: ${branches
155
+ .map((b) => b.name)
156
+ .join(", ")}`);
157
+ };
158
+ /**
159
+ * The databases to diff: the one passed via `--database` (validated against the
160
+ * branch under review), or every database on that branch when none is given.
161
+ */
162
+ const resolveDatabases = async (props, after) => {
163
+ const databases = (await props.apiClient.listProjectBranchDatabases(props.projectId, after.branchId)).data.databases;
164
+ if (props.database !== undefined) {
165
+ if (!databases.find((d) => d.name === props.database)) {
166
+ throw new Error(`Database "${props.database}" not found on branch ${after.branchName}. ` +
167
+ `Available: ${databases.map((d) => d.name).join(", ")}`);
168
+ }
169
+ return [props.database];
170
+ }
171
+ if (databases.length === 0) {
172
+ throw new Error(`No databases found on branch ${after.branchName} (${after.branchId}).`);
173
+ }
174
+ return databases.map((d) => d.name);
175
+ };
176
+ /**
177
+ * Fetch a branch database's `CREATE …` SQL. A database absent from the branch
178
+ * (404) yields an empty schema, so the diff shows it as fully added/removed
179
+ * rather than failing — the natural outcome when a database exists on only one
180
+ * side of the comparison.
181
+ */
182
+ const fetchSchemaSql = async (props, branchId, database) => {
183
+ try {
184
+ const { data } = await props.apiClient.getProjectBranchSchema({
185
+ projectId: props.projectId,
186
+ branchId,
187
+ db_name: database,
188
+ });
189
+ return data.sql ?? "";
190
+ }
191
+ catch (err) {
192
+ if (isNeonApiError(err) && err.status === 404) {
193
+ log.debug("diff: database %s not found on branch %s; treating schema as empty", database, branchId);
194
+ return "";
195
+ }
196
+ throw err;
197
+ }
198
+ };
199
+ /** Machine-readable output for `--output json|yaml`: one entry per database. */
200
+ const writeStructured = (props, diffs) => {
201
+ const report = diffs.map((diff) => {
202
+ const rendered = renderDatabaseSchemaDiff(diff, { color: false });
203
+ return {
204
+ database: diff.database,
205
+ base_branch: diff.before.branchName,
206
+ base_branch_id: diff.before.branchId,
207
+ compare_branch: diff.after.branchName,
208
+ compare_branch_id: diff.after.branchId,
209
+ has_changes: rendered.hasChanges,
210
+ diff: rendered.text,
211
+ };
212
+ });
213
+ writer(props).end(report, {
214
+ fields: [
215
+ "database",
216
+ "base_branch",
217
+ "compare_branch",
218
+ "has_changes",
219
+ "diff",
220
+ ],
221
+ });
222
+ };
@@ -10,6 +10,7 @@ import * as dataApi from "./data_api.js";
10
10
  import * as databases from "./databases.js";
11
11
  import * as deploy from "./deploy.js";
12
12
  import * as dev from "./dev.js";
13
+ import * as diff from "./diff.js";
13
14
  import * as env from "./env.js";
14
15
  import * as functions from "./functions.js";
15
16
  import * as init from "./init.js";
@@ -47,6 +48,7 @@ export default [
47
48
  dataApi,
48
49
  functions,
49
50
  dev,
51
+ diff,
50
52
  config,
51
53
  status,
52
54
  deploy,
package/dist/index.js CHANGED
@@ -31,6 +31,9 @@ const NO_SUBCOMMANDS_VERBS = [
31
31
  "init",
32
32
  "dev",
33
33
  "deploy",
34
+ // `diff <compare-branch>` has a handler but no subcommands (like `status`),
35
+ // so the help-fallback middleware must not intercept a bare `neon diff main`.
36
+ "diff",
34
37
  "bootstrap",
35
38
  // alias of `config status`
36
39
  "status",
@@ -0,0 +1,179 @@
1
+ import chalk from "chalk";
2
+ const UNSET = "(unset)";
3
+ /**
4
+ * Shared palette for the config diff, matching `neon diff`'s `git diff` styling
5
+ * (bold headers, red "before", green "after", dim connective glyphs). When
6
+ * `color` is false every entry is the identity function, so the identical layout
7
+ * renders without ANSI codes for `--no-color`, non-TTY pipes, and test snapshots.
8
+ */
9
+ const palette = (color) => {
10
+ const id = (s) => s;
11
+ if (!color) {
12
+ return { title: id, group: id, added: id, removed: id, arrow: id };
13
+ }
14
+ return {
15
+ title: (s) => chalk.bold(s),
16
+ group: (s) => chalk.bold(s),
17
+ added: (s) => chalk.green(s),
18
+ removed: (s) => chalk.red(s),
19
+ arrow: (s) => chalk.dim(s),
20
+ };
21
+ };
22
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
23
+ const stringifyValue = (value) => {
24
+ if (value === undefined || value === null || value === "")
25
+ return UNSET;
26
+ if (typeof value === "string")
27
+ return value;
28
+ if (typeof value === "boolean" || typeof value === "number") {
29
+ return String(value);
30
+ }
31
+ return JSON.stringify(value);
32
+ };
33
+ /**
34
+ * Expand one policy field into flat {@link FieldChange}s. When either side is a
35
+ * plain object (compute settings, Data API settings) it splits into one
36
+ * `field.key` entry per key across both sides, so the diff reads as sorted
37
+ * scalar lines (`computeSettings.autoscalingLimitMaxCu 2 → 4`) rather than an
38
+ * opaque JSON blob. Scalars produce a single entry.
39
+ */
40
+ const expandField = (field, current, desired) => {
41
+ if (isPlainObject(current) || isPlainObject(desired)) {
42
+ const cur = isPlainObject(current) ? current : {};
43
+ const des = isPlainObject(desired) ? desired : {};
44
+ const keys = Array.from(new Set([...Object.keys(cur), ...Object.keys(des)])).sort();
45
+ return keys.map((key) => ({
46
+ field: `${field}.${key}`,
47
+ ...(key in cur ? { current: stringifyValue(cur[key]) } : {}),
48
+ desired: stringifyValue(des[key]),
49
+ }));
50
+ }
51
+ return [
52
+ {
53
+ field,
54
+ ...(current !== undefined
55
+ ? { current: stringifyValue(current) }
56
+ : {}),
57
+ desired: stringifyValue(desired),
58
+ },
59
+ ];
60
+ };
61
+ /** Render the indented `field current → desired` lines for one branch group. */
62
+ const renderFieldLines = (fields, paint) => {
63
+ const width = fields.reduce((max, f) => Math.max(max, f.field.length), 0);
64
+ return fields.map((f) => {
65
+ const name = f.field.padEnd(width);
66
+ const arrow = paint.arrow("→");
67
+ return f.current !== undefined
68
+ ? ` ${name} ${paint.removed(f.current)} ${arrow} ${paint.added(f.desired)}`
69
+ : ` ${name} ${arrow} ${paint.added(f.desired)}`;
70
+ });
71
+ };
72
+ /** Group field changes by branch, each block sorted alphabetically by field. */
73
+ const renderBranchGroups = (byBranch, paint) => {
74
+ const lines = [];
75
+ const groups = [...byBranch.entries()].sort((a, b) => a[0].localeCompare(b[0]));
76
+ for (const [branch, fields] of groups) {
77
+ const sorted = [...fields].sort((a, b) => a.field.localeCompare(b.field));
78
+ lines.push(` ${paint.group(`~ ${branch}`)}`);
79
+ lines.push(...renderFieldLines(sorted, paint));
80
+ }
81
+ return lines;
82
+ };
83
+ /** Friendly label for a service change identifier (`bucket:x` → `bucket x`). */
84
+ const serviceLabel = (identifier) => {
85
+ if (identifier === "auth")
86
+ return "Neon Auth";
87
+ if (identifier === "dataApi")
88
+ return "Data API";
89
+ if (identifier.startsWith("bucket:")) {
90
+ return `bucket ${identifier.slice("bucket:".length)}`;
91
+ }
92
+ if (identifier.startsWith("function:")) {
93
+ return `function ${identifier.slice("function:".length)}`;
94
+ }
95
+ return identifier;
96
+ };
97
+ /**
98
+ * The desired-only field changes for an applied/planned **branch** update. The
99
+ * synthesized `AppliedChange.details` carry the new value keyed by `field`
100
+ * (`ttl`→`expiresAt`, `protected`→`protected`, `computeSettings`→`settings`);
101
+ * the previous value isn't threaded through in Phase 1, so these render as
102
+ * `field → desired` (no red "before"). Object settings expand into sub-fields.
103
+ */
104
+ const appliedBranchFields = (change) => {
105
+ const details = change.details ?? {};
106
+ const field = typeof details.field === "string" ? details.field : "setting";
107
+ switch (field) {
108
+ case "ttl":
109
+ return expandField("ttl", undefined, details.expiresAt);
110
+ case "protected":
111
+ return expandField("protected", undefined, details.protected);
112
+ case "computeSettings":
113
+ return expandField("computeSettings", undefined, details.settings);
114
+ default:
115
+ return expandField(field, undefined, details.settings);
116
+ }
117
+ };
118
+ /**
119
+ * Render the applied (or planned) changes as a `git diff`-style list:
120
+ *
121
+ * - **Service changes** (auth / Data API / buckets / functions) are additions
122
+ * with no meaningful "before", so they list as green `+ <label>` lines
123
+ * (`~ <label>` for the lone Data API settings *update*).
124
+ * - **Branch setting changes** group per branch and render as sorted
125
+ * `field → value` lines under a `~ <branch>` header.
126
+ *
127
+ * Returns "" when there is nothing to show. Pure — callers own the heading
128
+ * spacing and any surrounding summary.
129
+ */
130
+ export const renderAppliedChanges = (changes, title, opts) => {
131
+ if (changes.length === 0)
132
+ return "";
133
+ const paint = palette(opts.color);
134
+ const lines = [paint.title(title)];
135
+ const services = changes
136
+ .filter((c) => c.kind === "service")
137
+ .sort((a, b) => a.identifier.localeCompare(b.identifier));
138
+ for (const service of services) {
139
+ const label = serviceLabel(service.identifier);
140
+ lines.push(service.action === "create"
141
+ ? ` ${paint.added(`+ ${label}`)}`
142
+ : ` ${paint.arrow(`~ ${label}`)}`);
143
+ }
144
+ const byBranch = new Map();
145
+ for (const change of changes.filter((c) => c.kind === "branch")) {
146
+ const existing = byBranch.get(change.identifier) ?? [];
147
+ byBranch.set(change.identifier, [
148
+ ...existing,
149
+ ...appliedBranchFields(change),
150
+ ]);
151
+ }
152
+ lines.push(...renderBranchGroups(byBranch, paint));
153
+ return lines.join("\n");
154
+ };
155
+ /**
156
+ * Render branch-setting **conflicts** (drift the policy wants to change but that
157
+ * needs `--update-existing`) as a `git diff`-style before→after report: grouped
158
+ * per branch, sorted by field, `current → desired` with the old value in red and
159
+ * the new in green. Conflicts already carry both sides, so this is the fullest
160
+ * form of the diff. Returns "" when there are no conflicts.
161
+ */
162
+ export const renderBranchSettingConflicts = (conflicts, opts) => {
163
+ if (conflicts.length === 0)
164
+ return "";
165
+ const paint = palette(opts.color);
166
+ const byBranch = new Map();
167
+ for (const conflict of conflicts) {
168
+ const existing = byBranch.get(conflict.identifier) ?? [];
169
+ byBranch.set(conflict.identifier, [
170
+ ...existing,
171
+ ...expandField(conflict.field, conflict.current, conflict.desired),
172
+ ]);
173
+ }
174
+ const lines = [
175
+ paint.title("Branch settings differ (re-run with --update-existing to apply)"),
176
+ ...renderBranchGroups(byBranch, paint),
177
+ ];
178
+ return lines.join("\n");
179
+ };
@@ -0,0 +1,90 @@
1
+ import chalk from "chalk";
2
+ import { structuredPatch } from "diff";
3
+ /**
4
+ * Color palette for the rendered diff. When `color` is false every entry is the
5
+ * identity function, so the exact same layout is emitted without ANSI codes
6
+ * (for `--no-color`, non-TTY pipes, and stable test snapshots).
7
+ */
8
+ const palette = (color) => {
9
+ const id = (s) => s;
10
+ if (!color) {
11
+ return {
12
+ header: id,
13
+ removedFile: id,
14
+ addedFile: id,
15
+ hunk: id,
16
+ added: id,
17
+ removed: id,
18
+ noNewline: id,
19
+ };
20
+ }
21
+ return {
22
+ header: (s) => chalk.bold(s),
23
+ removedFile: (s) => chalk.bold.red(s),
24
+ addedFile: (s) => chalk.bold.green(s),
25
+ hunk: (s) => chalk.cyan(s),
26
+ added: (s) => chalk.green(s),
27
+ removed: (s) => chalk.red(s),
28
+ noNewline: (s) => chalk.dim(s),
29
+ };
30
+ };
31
+ const branchLabel = (schema) => `${schema.branchName} (${schema.branchId})`;
32
+ /**
33
+ * Render a single database's schema comparison as a git-style unified diff.
34
+ *
35
+ * Pure: given the two schemas it computes hunks with `diff`'s `structuredPatch`
36
+ * and lays them out like `git diff` — a bold header, red `---` / green `+++`
37
+ * branch lines, cyan `@@` hunk headers, and green/red line bodies. Returns
38
+ * `hasChanges: false` with empty text when the two schemas are identical.
39
+ */
40
+ export const renderDatabaseSchemaDiff = (diff, opts) => {
41
+ const patch = structuredPatch(diff.database, diff.database, diff.before.sql, diff.after.sql, "", "", { context: 3 });
42
+ if (patch.hunks.length === 0) {
43
+ return { database: diff.database, hasChanges: false, text: "" };
44
+ }
45
+ const paint = palette(opts.color);
46
+ const lines = [
47
+ paint.header(`diff --neon database ${diff.database}`),
48
+ paint.removedFile(`--- ${branchLabel(diff.before)}`),
49
+ paint.addedFile(`+++ ${branchLabel(diff.after)}`),
50
+ ];
51
+ for (const hunk of patch.hunks) {
52
+ lines.push(paint.hunk(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`));
53
+ for (const line of hunk.lines) {
54
+ // `structuredPatch` prefixes every line: '+' added, '-' removed,
55
+ // ' ' unchanged context, and '\' for the "No newline at end of file"
56
+ // marker. Color the first three; dim the marker; leave context plain.
57
+ if (line.startsWith("+")) {
58
+ lines.push(paint.added(line));
59
+ }
60
+ else if (line.startsWith("-")) {
61
+ lines.push(paint.removed(line));
62
+ }
63
+ else if (line.startsWith("\\")) {
64
+ lines.push(paint.noNewline(line));
65
+ }
66
+ else {
67
+ lines.push(line);
68
+ }
69
+ }
70
+ }
71
+ return {
72
+ database: diff.database,
73
+ hasChanges: true,
74
+ text: lines.join("\n"),
75
+ };
76
+ };
77
+ /**
78
+ * Render every database's diff into one report, keeping only databases whose
79
+ * schema actually changed. `hasChanges` is false when nothing changed across
80
+ * all databases, letting the caller print a single "no differences" note.
81
+ */
82
+ export const renderSchemaDiffReport = (diffs, opts) => {
83
+ const rendered = diffs.map((diff) => renderDatabaseSchemaDiff(diff, opts));
84
+ const changed = rendered.filter((r) => r.hasChanges);
85
+ return {
86
+ hasChanges: changed.length > 0,
87
+ text: changed.map((r) => r.text).join("\n\n"),
88
+ changedDatabases: changed.map((r) => r.database),
89
+ };
90
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neonctl",
3
- "version": "2.31.1",
3
+ "version": "2.32.0",
4
4
  "description": "CLI tool for Neon Serverless Postgres",
5
5
  "keywords": [
6
6
  "neon",