neonctl 2.31.1 → 2.33.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,14 +1,17 @@
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
+ import { getApiClient } from "../api.js";
6
7
  import { toNeonConfigView } from "../config_format.js";
7
8
  import { contextBranch, readContextFile } from "../context.js";
8
9
  import { isCi } from "../env.js";
9
10
  import { loadEnvFileIntoProcess } from "../env_file.js";
10
11
  import { log } from "../log.js";
12
+ import { assertAiGatewayProvisionable, warnAiGateway, } from "../utils/ai_gateway_notice.js";
11
13
  import { announceTargetBranch } from "../utils/branch_notice.js";
14
+ import { renderAppliedChanges, renderBranchSettingConflicts, } from "../utils/config_diff.js";
12
15
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
13
16
  import { bundleEntry } from "../utils/esbuild.js";
14
17
  import { addDependenciesArgs, resolvePackageManager, runCommand, } from "../utils/package_manager.js";
@@ -23,19 +26,6 @@ import { autoPullEnvAfterPin } from "./env.js";
23
26
  */
24
27
  const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
25
28
  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
29
  /**
40
30
  * Shared `--env` flag for `config plan|apply` and `deploy`. Loads a `.env` into
41
31
  * `process.env` before the policy is evaluated.
@@ -323,23 +313,56 @@ export const planCmd = async (props) => {
323
313
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
324
314
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
325
315
  });
326
- reportPushResult(props, result, "plan", utilizedServices(config));
316
+ const services = utilizedServices(config);
317
+ reportPushResult(props, result, "plan", services);
318
+ // `plan` is a dry run and never pulls credentials, so it can only offer the plan-based
319
+ // (Free) AI Gateway notice — the reduced-model-set check needs a live gateway token,
320
+ // which `apply`/`checkout`/`env pull` get via the bundled env pull. Best-effort.
321
+ if (services.includes("AI Gateway")) {
322
+ await warnAiGateway({
323
+ apiClient: props.apiClient,
324
+ projectId: props.projectId,
325
+ branchId,
326
+ });
327
+ }
327
328
  };
328
329
  export const applyCmd = async (props) => {
329
330
  const config = await loadConfig(props);
330
331
  const branch = await resolveBranchRef(props);
331
332
  announceTargetBranch(props, branch, "Applying to branch");
332
333
  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
- });
334
+ // The AI Gateway can't serve on the Free plan, so refuse to provision it up front rather
335
+ // than write a credential that won't work. Only when the policy actually enables the
336
+ // gateway; best-effort on the plan lookup (a transient failure never blocks a paid user).
337
+ if (utilizedServices(config).includes("AI Gateway")) {
338
+ await assertAiGatewayProvisionable({
339
+ apiClient: props.apiClient,
340
+ projectId: props.projectId,
341
+ });
342
+ }
343
+ let result;
344
+ try {
345
+ result = await apply(config, {
346
+ projectId: props.projectId,
347
+ branchId,
348
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
349
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
350
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
351
+ ...(props.updateExisting ? { updateExisting: true } : {}),
352
+ ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
353
+ bundleFunction: neonctlBundler,
354
+ });
355
+ }
356
+ catch (err) {
357
+ // Drift without `--update-existing` throws with the conflicting fields attached.
358
+ // Render them as the same git-style before→after diff, then fail with a concise
359
+ // message (the detailed diff above replaces the library's long multi-line text).
360
+ if (err instanceof PushConflictError) {
361
+ reportConflicts(props, err.conflicts);
362
+ throw new Error("Branch settings conflict with the policy. Re-run with --update-existing to apply the changes shown above.");
363
+ }
364
+ throw err;
365
+ }
343
366
  reportPushResult(props, result, "apply", utilizedServices(config));
344
367
  // After a successful apply/deploy, write the branch's Neon env vars to a local .env —
345
368
  // the same bundled convenience as `link` / `checkout`, so the branch is immediately
@@ -388,56 +411,44 @@ const utilizedServices = (config) => {
388
411
  /**
389
412
  * Render a {@link PushResult}. JSON/YAML output emits the raw result (plus a `services`
390
413
  * 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.
414
+ * (dropping noops) and any blocking conflicts as a `git diff`-style report, or a "nothing to
415
+ * do" line when both are empty — and always closes with the list of services the policy
416
+ * utilizes so a service that produces no plan step (Postgres, or the credential-gated AI
417
+ * Gateway) isn't mistaken for being missing from the plan above.
418
+ *
419
+ * The diff is asymmetric on purpose (see the CLI's `neon diff`): **service** changes are
420
+ * additions with no "before", so they list as `+`/`~` lines; **branch setting** changes have
421
+ * a natural before→after, so conflicts render as a sorted `current → desired` diff. Planned
422
+ * branch updates (under `--update-existing`) carry only the new value, so they render
423
+ * desired-only for now (the previous value isn't threaded through the runtime yet).
395
424
  */
396
425
  const reportPushResult = (props, result, mode, services) => {
397
426
  if (props.output === "json" || props.output === "yaml") {
398
427
  writer(props).end({ ...result, services }, { fields: [] });
399
428
  return;
400
429
  }
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
- }));
430
+ const appliedChanges = result.applied.filter((change) => change.action !== "noop");
415
431
  // Deployed functions carry their invocation URL in the change details — collect them so
416
432
  // we can list where to call each function without digging through the raw details blob.
417
433
  // Keyed by slug so a function never shows twice.
418
434
  const functionUrlBySlug = new Map();
419
- for (const change of result.applied) {
420
- if (change.action === "noop")
421
- continue;
435
+ for (const change of appliedChanges) {
422
436
  const slug = change.details?.slug;
423
437
  const invocationUrl = change.details?.invocationUrl;
424
438
  if (typeof slug === "string" && typeof invocationUrl === "string") {
425
439
  functionUrlBySlug.set(slug, invocationUrl);
426
440
  }
427
441
  }
442
+ // chalk self-detects TTY/NO_COLOR; `--no-color` (props.color === false) forces plain.
443
+ const color = props.color !== false;
428
444
  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();
445
+ // Conflicts never reach here in the CLI: `plan` runs with updateExisting on, and a bare
446
+ // `apply` throws PushConflictError (rendered by reportConflicts). So an empty applied set
447
+ // is the whole story here.
448
+ const noChanges = appliedChanges.length === 0;
449
+ const appliedText = renderAppliedChanges(appliedChanges, mode === "plan" ? "Planned changes" : "Applied changes", { color });
450
+ if (appliedText)
451
+ out.text(`${appliedText}\n`);
441
452
  // Function URLs are a plain list rather than a table: an invocation URL can be 70+ chars,
442
453
  // which makes any bordered table overflow and wrap awkwardly in a normal terminal. A list
443
454
  // lets each URL reflow on its own line, and stays copy-pasteable.
@@ -452,15 +463,51 @@ const reportPushResult = (props, result, mode, services) => {
452
463
  log.info(`No changes — branch ${result.branchName} already matches the policy.`);
453
464
  }
454
465
  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.");
466
+ };
467
+ /**
468
+ * Render the branch-setting {@link ConflictReport}s a bare `apply` refused to override (drift
469
+ * without `--update-existing`) as the git-style before→after diff. JSON/YAML output emits the
470
+ * structured conflicts so it can be piped; the human path prints the sorted diff followed by
471
+ * any conflict whose fix is *not* `--update-existing` (e.g. an immutable "no endpoint" case),
472
+ * so nothing the library's error message carried is lost.
473
+ */
474
+ const reportConflicts = (props, conflicts) => {
475
+ if (props.output === "json" || props.output === "yaml") {
476
+ writer(props).end({ conflicts }, { fields: [] });
477
+ return;
478
+ }
479
+ const out = writer(props);
480
+ const text = renderBranchSettingConflicts([...conflicts], {
481
+ color: props.color !== false,
482
+ });
483
+ if (text)
484
+ out.text(`${text}\n`);
485
+ for (const conflict of conflicts) {
486
+ if (!/updateExisting/i.test(conflict.reason)) {
487
+ out.text(` ! ${conflict.field}: ${conflict.reason}\n`);
488
+ }
457
489
  }
458
490
  };
459
- const stringify = (value) => value === undefined
460
- ? ""
461
- : typeof value === "string"
462
- ? value
463
- : JSON.stringify(value);
491
+ /**
492
+ * Block provisioning the AI Gateway on a Free plan from the `checkout` policy paths, which
493
+ * carry raw credentials (`apiKey`/`apiHost`) rather than the CLI's api client. Builds a client
494
+ * from them and defers to {@link assertAiGatewayProvisionable}. Skipped when a `runtimeApi` is
495
+ * injected (tests) or no `apiKey` is available; the interactive commands always have a key.
496
+ */
497
+ const assertAiGatewayProvisionableFromCreds = async (props) => {
498
+ if (props.runtimeApi || !props.apiKey)
499
+ return;
500
+ if (!utilizedServices(props.config).includes("AI Gateway"))
501
+ return;
502
+ const apiClient = getApiClient({
503
+ apiKey: props.apiKey,
504
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
505
+ });
506
+ await assertAiGatewayProvisionable({
507
+ apiClient,
508
+ projectId: props.projectId,
509
+ });
510
+ };
464
511
  /**
465
512
  * Apply a `neon.ts` policy to a **freshly created** branch (used by `neonctl checkout`
466
513
  * when it creates a branch). No-op when there is no `neon.ts` on the path from cwd up to
@@ -483,6 +530,13 @@ export const applyPolicyOnCreate = async (props) => {
483
530
  return;
484
531
  throw err;
485
532
  }
533
+ await assertAiGatewayProvisionableFromCreds({
534
+ projectId: props.projectId,
535
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
536
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
537
+ ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
538
+ config,
539
+ });
486
540
  log.info("Applying neon.ts policy to the new branch…");
487
541
  const result = await apply(config, {
488
542
  projectId: props.projectId,
@@ -530,6 +584,13 @@ export const createBranchFromPolicyOnCheckout = async (props) => {
530
584
  return null;
531
585
  throw err;
532
586
  }
587
+ await assertAiGatewayProvisionableFromCreds({
588
+ projectId: props.projectId,
589
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
590
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
591
+ ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
592
+ config,
593
+ });
533
594
  const { branchId, branchName, result } = await createBranchFromPolicy(config, {
534
595
  projectId: props.projectId,
535
596
  branchName: props.branchName,
@@ -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
+ };
@@ -4,6 +4,7 @@ import chalk from "chalk";
4
4
  import { resolveNeonEnvVars } from "../dev/env.js";
5
5
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
6
6
  import { log } from "../log.js";
7
+ import { warnAiGateway } from "../utils/ai_gateway_notice.js";
7
8
  import { announceTargetBranch } from "../utils/branch_notice.js";
8
9
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
9
10
  export const command = "env";
@@ -104,6 +105,20 @@ export const pull = async (props, opts = {}) => {
104
105
  if (removed.length > 0) {
105
106
  log.info("Removed %d stale Neon variable%s not enabled on this branch: %s", removed.length, removed.length === 1 ? "" : "s", removed.join(", "));
106
107
  }
108
+ // When the branch has the AI Gateway enabled, the pulled credentials always work, but
109
+ // serving is plan-gated and the model set can be reduced on the beta — surface that as a
110
+ // courtesy notice (best-effort; never fails the pull). The freshly pulled token lets us
111
+ // probe the branch's own /v1/models to detect a reduced catalog.
112
+ const gatewayBaseUrl = neonVars.NEON_AI_GATEWAY_BASE_URL;
113
+ const gatewayToken = neonVars.NEON_AI_GATEWAY_TOKEN;
114
+ if (gatewayBaseUrl && gatewayToken) {
115
+ await warnAiGateway({
116
+ apiClient: props.apiClient,
117
+ projectId: props.projectId,
118
+ branchId,
119
+ gateway: { baseUrl: gatewayBaseUrl, token: gatewayToken },
120
+ });
121
+ }
107
122
  return { status: "written", written, file: targetPath };
108
123
  };
109
124
  /**
@@ -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,173 @@
1
+ import { log } from "../log.js";
2
+ /**
3
+ * Friendly guidance shown when a branch enables the AI Gateway (`preview.aiGateway`).
4
+ *
5
+ * The gateway is credential-gated, not provisioned: enabling it always mints a working
6
+ * branch credential, so `apply` / `checkout` / `env pull` succeed regardless of plan. The
7
+ * two things that *do* gate the gateway happen at serving time and are invisible in the
8
+ * provisioning result, so we surface them as a courtesy notice instead:
9
+ *
10
+ * - **Free plan** — credentials provision, but the gateway does not *serve* model requests.
11
+ * The account needs to upgrade to a paid plan.
12
+ * - **Reduced model set** — on a paid plan, an account still ramping up on the beta gets a
13
+ * trimmed model catalog (flagship models are missing from `/v1/models`). They can request
14
+ * access to more models.
15
+ *
16
+ * This is deliberately phrased for the user: it never mentions account "verification" or any
17
+ * other internal gating mechanism.
18
+ */
19
+ /**
20
+ * `BillingSubscriptionType` values that mean the account is on a Free plan (the gateway
21
+ * won't serve requests). Everything else — `launch`, `scale`, `business`, the `*_v3`
22
+ * variants, marketplace plans — is treated as paid.
23
+ */
24
+ const FREE_SUBSCRIPTION_TYPES = new Set(["free_v2", "free_v3"]);
25
+ /**
26
+ * The Neon Console billing page to upgrade a Free-plan account so the gateway can serve. It's
27
+ * org-scoped when the project belongs to an org; projects on a personal account (which is
28
+ * where Free plans usually live) have no org id, so we fall back to the account-level page.
29
+ */
30
+ export const aiGatewayUpgradeUrl = (orgId) => orgId
31
+ ? `https://console.neon.tech/app/${orgId}/billing`
32
+ : "https://console.neon.tech/app/billing";
33
+ /**
34
+ * The branch's AI Gateway page in the Neon Console — where a paid user with a reduced model
35
+ * set requests access to more models. It's branch-scoped, so it's built from the linked
36
+ * project and branch ids.
37
+ */
38
+ export const aiGatewayModelsUrl = (projectId, branchId) => `https://console.neon.tech/app/projects/${projectId}/branches/${branchId}/ai-gateway`;
39
+ export const isFreePlan = (subscriptionType) => subscriptionType !== undefined &&
40
+ FREE_SUBSCRIPTION_TYPES.has(subscriptionType);
41
+ /**
42
+ * Whether a model catalog includes at least one flagship model. Flagship models (Anthropic
43
+ * Opus, OpenAI Codex / `*-pro`) are the first to be held back for an account still ramping
44
+ * up on the beta, so their total absence from a non-empty catalog is the signal that the
45
+ * account has a reduced model set. Matched by id substring so it survives model version
46
+ * bumps (e.g. `claude-opus-4-8`).
47
+ */
48
+ export const hasFlagshipModels = (modelIds) => modelIds.some((id) => id.includes("opus") || id.includes("codex") || id.endsWith("-pro"));
49
+ /**
50
+ * The message shown when a `neon.ts` that enables the AI Gateway is applied on a Free plan.
51
+ * Provisioning is refused up front (see {@link assertAiGatewayProvisionable}) because the
52
+ * gateway won't serve model requests until the account is on a paid plan. `upgradeUrl` is the
53
+ * org-scoped Console billing page (see {@link aiGatewayUpgradeUrl}).
54
+ */
55
+ export const freePlanBlockMessage = (upgradeUrl) => "This neon.ts enables the AI Gateway, which isn't available on the Free plan — the " +
56
+ "gateway won't serve model requests. Upgrade to a paid plan and re-run, or remove " +
57
+ `\`preview.aiGateway\` from neon.ts. Upgrade here: ${upgradeUrl}`;
58
+ /**
59
+ * Build the AI Gateway courtesy notice for an account's plan and (optionally) its live model
60
+ * catalog, or `null` when nothing needs saying.
61
+ *
62
+ * `modelIds` is `undefined` when the catalog wasn't probed (e.g. a dry-run `plan`, or a probe
63
+ * that failed); in that case only the plan-based (Free) notice can be produced — never a
64
+ * false "reduced models" warning. `upgradeUrl` is the org-scoped Console billing page
65
+ * (Free notice); `moreModelsUrl` is the branch's Console AI Gateway page
66
+ * (see {@link aiGatewayModelsUrl}), shown when the catalog is reduced.
67
+ */
68
+ export const buildAiGatewayNotice = ({ subscriptionType, modelIds, upgradeUrl, moreModelsUrl, }) => {
69
+ if (isFreePlan(subscriptionType)) {
70
+ return {
71
+ level: "warning",
72
+ message: "AI Gateway is enabled, but the gateway does not serve model requests on the " +
73
+ `Free plan. Upgrade to a paid plan to start making requests: ${upgradeUrl}`,
74
+ };
75
+ }
76
+ if (modelIds !== undefined &&
77
+ modelIds.length > 0 &&
78
+ !hasFlagshipModels(modelIds)) {
79
+ return {
80
+ level: "warning",
81
+ message: "AI Gateway is in public beta and not every model is enabled for your account " +
82
+ "yet, so some models are missing from the catalog. Request access to more " +
83
+ `models here: ${moreModelsUrl}`,
84
+ };
85
+ }
86
+ return null;
87
+ };
88
+ const isRecord = (value) => typeof value === "object" && value !== null;
89
+ /** Pull the string `id`s out of an OpenAI-compatible `{ object: "list", data: [...] }` body. */
90
+ const extractModelIds = (body) => {
91
+ if (!isRecord(body) || !Array.isArray(body.data))
92
+ return null;
93
+ const ids = [];
94
+ for (const entry of body.data) {
95
+ if (isRecord(entry) && typeof entry.id === "string") {
96
+ ids.push(entry.id);
97
+ }
98
+ }
99
+ return ids;
100
+ };
101
+ /**
102
+ * `GET {baseUrl}/v1/models` → the served model ids, or `null` if the catalog can't be read
103
+ * (network / HTTP / parse failure). Returning `null` keeps the notice silent rather than
104
+ * risking a false "reduced models" warning. `/v1/models` is served only on the unified
105
+ * dialect (the `/openai/v1` Responses dialect returns 404).
106
+ */
107
+ export const fetchGatewayModelIds = async (baseUrl, token) => {
108
+ try {
109
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/models`, {
110
+ headers: { Authorization: `Bearer ${token}` },
111
+ });
112
+ if (!res.ok)
113
+ return null;
114
+ return extractModelIds(await res.json());
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ };
120
+ /**
121
+ * Refuse to provision the AI Gateway on a Free plan. Called before the `neon.ts` lifecycle
122
+ * commands provision a branch (`config apply` / `deploy`, `checkout`), so a Free-plan user
123
+ * gets a clear "upgrade first" error instead of a credential that can't serve requests.
124
+ *
125
+ * Best-effort on the plan lookup: if the plan can't be determined (network / API failure) we
126
+ * do NOT block, so a transient error never wrongly refuses a paid user's deploy. Only a
127
+ * positively-identified Free plan throws.
128
+ */
129
+ export const assertAiGatewayProvisionable = async (params) => {
130
+ let subscriptionType;
131
+ let orgId;
132
+ try {
133
+ const { data } = await params.apiClient.getProject(params.projectId);
134
+ subscriptionType = data.project.owner?.subscription_type;
135
+ orgId = data.project.org_id ?? undefined;
136
+ }
137
+ catch {
138
+ return; // Can't determine the plan — don't block.
139
+ }
140
+ if (isFreePlan(subscriptionType)) {
141
+ throw new Error(freePlanBlockMessage(aiGatewayUpgradeUrl(orgId)));
142
+ }
143
+ };
144
+ /**
145
+ * Resolve the account's plan (and, when gateway credentials are on hand, its live model
146
+ * catalog) and print the AI Gateway courtesy notice — used by the `neon.ts` lifecycle and
147
+ * `env pull` whenever a branch has the gateway enabled.
148
+ *
149
+ * Pass `gateway` (base URL + token) to enable the reduced-model-set check; omit it (e.g. a
150
+ * dry-run `plan`) to get only the Free-plan notice. This is best-effort: any failure while
151
+ * fetching the plan or catalog is swallowed so it can never break the underlying command.
152
+ */
153
+ export const warnAiGateway = async (params) => {
154
+ try {
155
+ const { data } = await params.apiClient.getProject(params.projectId);
156
+ const subscriptionType = data.project.owner?.subscription_type;
157
+ const orgId = data.project.org_id ?? undefined;
158
+ const modelIds = params.gateway
159
+ ? ((await fetchGatewayModelIds(params.gateway.baseUrl, params.gateway.token)) ?? undefined)
160
+ : undefined;
161
+ const notice = buildAiGatewayNotice({
162
+ subscriptionType,
163
+ modelIds,
164
+ upgradeUrl: aiGatewayUpgradeUrl(orgId),
165
+ moreModelsUrl: aiGatewayModelsUrl(params.projectId, params.branchId),
166
+ });
167
+ if (notice)
168
+ log.warning(notice.message);
169
+ }
170
+ catch {
171
+ // A courtesy notice must never break the command that triggered it.
172
+ }
173
+ };
@@ -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.33.0",
4
4
  "description": "CLI tool for Neon Serverless Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -42,7 +42,7 @@
42
42
  "cliui": "8.0.1",
43
43
  "diff": "5.2.0",
44
44
  "fflate": "^0.8.3",
45
- "neon-init": "0.20.0",
45
+ "neon-init": "0.20.1",
46
46
  "open": "^10.2.0",
47
47
  "openid-client": "6.8.1",
48
48
  "pg-protocol": "^1.14.0",
@@ -51,10 +51,10 @@
51
51
  "which": "3.0.1",
52
52
  "yaml": "^2.9.0",
53
53
  "yargs": "17.7.2",
54
- "@neon/sdk": "1.1.0",
55
- "@neon/config": "0.9.2",
56
- "@neon/config-runtime": "0.9.2",
57
- "@neon/env": "0.11.1"
54
+ "@neon/sdk": "1.1.1",
55
+ "@neon/config": "0.9.3",
56
+ "@neon/config-runtime": "0.9.3",
57
+ "@neon/env": "0.11.2"
58
58
  },
59
59
  "optionalDependencies": {
60
60
  "esbuild": "0.28.1"