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 +30 -1
- package/dist/commands/config.js +126 -65
- package/dist/commands/diff.js +222 -0
- package/dist/commands/env.js +15 -0
- package/dist/commands/index.js +2 -0
- package/dist/index.js +3 -0
- package/dist/utils/ai_gateway_notice.js +173 -0
- package/dist/utils/config_diff.js +179 -0
- package/dist/utils/git_diff.js +90 -0
- package/package.json +6 -6
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
|
|
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` |
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
392
|
-
* are empty — and always closes with the list of services the policy
|
|
393
|
-
* that produces no plan step (Postgres, or the credential-gated AI
|
|
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
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
+
};
|
package/dist/commands/env.js
CHANGED
|
@@ -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
|
/**
|
package/dist/commands/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
55
|
-
"@neon/config": "0.9.
|
|
56
|
-
"@neon/config-runtime": "0.9.
|
|
57
|
-
"@neon/env": "0.11.
|
|
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"
|