neonctl 2.36.2 → 2.37.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.
@@ -60,7 +60,7 @@ export const handler = async (props) => {
60
60
  // (--project-id flag > .neon file > single-project auto-detect); when
61
61
  // nothing resolves, fall back to an interactive `neonctl link`.
62
62
  const projectId = await resolveProjectId(props);
63
- const { branchId, branchName, created, policyApplied } = await resolveBranchId(props, projectId);
63
+ const { branchId, branchName, created, policyApplied, policyFailure } = await resolveBranchId(props, projectId);
64
64
  const orgId = await resolveOrgId(props, projectId);
65
65
  // `checkout` is a thin helper over `link`. It fully "heals" the context file:
66
66
  // it always (re)writes `projectId`, `branch`, and `orgId` (when the project
@@ -78,14 +78,17 @@ export const handler = async (props) => {
78
78
  // see `policyApplied`. The fallback below covers the case where the branch was created bare
79
79
  // (e.g. a policy-driven create wasn't possible); `applyPolicyOnCreate` is a no-op when there
80
80
  // is no neon.ts on disk. Checking out an existing branch never reconciles it.
81
- if (created && !policyApplied) {
82
- await applyPolicyOnCreate({
81
+ const failure = created && !policyApplied
82
+ ? await applyPolicyOrDescribeFailure({
83
83
  projectId,
84
84
  branchId,
85
85
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
86
86
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
87
- });
88
- }
87
+ ...(props.color !== undefined
88
+ ? { color: props.color }
89
+ : {}),
90
+ })
91
+ : policyFailure;
89
92
  // Bundle `env pull` so the branch-first loop is just link + checkout: the branch you
90
93
  // checked out is immediately usable for local dev. `--no-env-pull` opts out.
91
94
  await autoPullEnvAfterPin({
@@ -94,6 +97,31 @@ export const handler = async (props) => {
94
97
  branch: branchId,
95
98
  envPull: props.envPull,
96
99
  });
100
+ // A policy that didn't fully apply is reported last, after the pin and the env pull, so the
101
+ // created branch is left in a consistent, usable, re-runnable state: the failure is what
102
+ // needs fixing, not the checkout. Still a non-zero exit — the branch does not match the
103
+ // policy, and `checkout` will not reconcile it on a second run.
104
+ if (failure) {
105
+ throw new Error([
106
+ `Branch ${branchName} (${branchId}) was created and checked out, but applying neon.ts to it failed: ${failure}`,
107
+ "The branch is usable but does not match the policy, and `neonctl checkout` never reconciles a branch that already exists.",
108
+ `Fix the cause above, then run \`neonctl deploy --update-existing\` to apply the policy to it — or, if your policy only configures new branches (keyed on \`!branch.exists\`), delete the branch and check it out again: \`neonctl branches delete ${branchName}\` then \`neonctl checkout ${branchName}\`.`,
109
+ ].join("\n"));
110
+ }
111
+ };
112
+ /**
113
+ * Apply the policy to a branch `checkout` just created bare, returning the failure message
114
+ * instead of throwing it. The branch and the context pin already stand at this point, so a
115
+ * failed apply must not abort the rest of the checkout — the handler reports it at the end.
116
+ */
117
+ const applyPolicyOrDescribeFailure = async (props) => {
118
+ try {
119
+ await applyPolicyOnCreate(props);
120
+ return undefined;
121
+ }
122
+ catch (err) {
123
+ return err instanceof Error ? err.message : String(err);
124
+ }
97
125
  };
98
126
  const resolveBranchId = async (props, projectId) => {
99
127
  const branches = (await props.apiClient.listProjectBranches({ projectId }))
@@ -168,6 +196,7 @@ const createCheckoutBranch = async (props, projectId, name, branches) => {
168
196
  branchName: name,
169
197
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
170
198
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
199
+ ...(props.color !== undefined ? { color: props.color } : {}),
171
200
  });
172
201
  if (fromPolicy) {
173
202
  return {
@@ -175,6 +204,9 @@ const createCheckoutBranch = async (props, projectId, name, branches) => {
175
204
  branchName: name,
176
205
  created: true,
177
206
  policyApplied: true,
207
+ ...(fromPolicy.policyFailure
208
+ ? { policyFailure: fromPolicy.policyFailure }
209
+ : {}),
178
210
  };
179
211
  }
180
212
  return {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { resolveConfig } from "@neon/config";
4
- import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, PushConflictError, plan, } from "@neon/config-runtime";
4
+ import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranchCreateError, loadConfigFromFile, PushConflictError, plan, } from "@neon/config-runtime";
5
5
  import chalk from "chalk";
6
6
  import { getApiClient } from "../api.js";
7
7
  import { toNeonConfigView } from "../config_format.js";
@@ -548,16 +548,21 @@ export const applyPolicyOnCreate = async (props) => {
548
548
  allowProtectedBranch: true,
549
549
  bundleFunction: neonctlBundler,
550
550
  });
551
- logPolicyResult(result);
551
+ logPolicyResult(result, { color: props.color !== false });
552
552
  };
553
- /** Log a one-line summary of what applying a `neon.ts` policy changed (or that nothing did). */
554
- const logPolicyResult = (result) => {
553
+ /**
554
+ * Report what applying a `neon.ts` policy changed, using the same `field → value` diff
555
+ * `deploy` prints (see {@link renderAppliedChanges}) rather than a bare list of change
556
+ * identifiers — the identifier alone repeats the branch name once per change and never says
557
+ * *what* was applied, which is the only interesting part on a freshly created branch.
558
+ */
559
+ const logPolicyResult = (result, opts) => {
555
560
  const changes = result.applied.filter((c) => c.action !== "noop");
556
561
  if (changes.length === 0) {
557
562
  log.info("neon.ts applied — no changes were needed.");
558
563
  return;
559
564
  }
560
- log.info("neon.ts applied — %d change%s: %s", changes.length, changes.length === 1 ? "" : "s", changes.map((c) => `${c.action} ${c.identifier}`).join(", "));
565
+ log.info("%s", renderAppliedChanges(changes, `neon.ts applied — ${changes.length} change${changes.length === 1 ? "" : "s"}:`, opts));
561
566
  };
562
567
  /**
563
568
  * Create a branch **from** the local `neon.ts` policy. Returns `null` when there is no
@@ -570,6 +575,10 @@ const logPolicyResult = (result) => {
570
575
  * a policy keyed on `!branch.exists` (the common "only configure new branches" shape) take
571
576
  * effect on the very first `checkout` — a bare create + `apply` always saw `exists: true` and
572
577
  * skipped that block.
578
+ *
579
+ * A branch that was created but whose policy failed to apply is reported through
580
+ * `policyFailure` rather than thrown: the branch is real, so `checkout` still needs to pin it
581
+ * (see the handler) instead of leaving it stranded behind an unchanged `.neon`.
573
582
  */
574
583
  export const createBranchFromPolicyOnCheckout = async (props) => {
575
584
  let config;
@@ -591,15 +600,27 @@ export const createBranchFromPolicyOnCheckout = async (props) => {
591
600
  ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
592
601
  config,
593
602
  });
594
- const { branchId, branchName, result } = await createBranchFromPolicy(config, {
595
- projectId: props.projectId,
596
- branchName: props.branchName,
597
- ...(props.apiKey ? { apiKey: props.apiKey } : {}),
598
- ...(props.apiHost ? { apiHost: props.apiHost } : {}),
599
- ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
600
- bundleFunction: neonctlBundler,
601
- });
602
- log.info("Created branch %s (%s) from neon.ts policy.", branchName, branchId);
603
- logPolicyResult(result);
604
- return { branchId };
603
+ try {
604
+ const { branchId, branchName, result } = await createBranchFromPolicy(config, {
605
+ projectId: props.projectId,
606
+ branchName: props.branchName,
607
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
608
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
609
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
610
+ bundleFunction: neonctlBundler,
611
+ });
612
+ log.info("Created branch %s (%s) from neon.ts policy.", branchName, branchId);
613
+ logPolicyResult(result, { color: props.color !== false });
614
+ return { branchId };
615
+ }
616
+ catch (err) {
617
+ // The branch exists but its policy didn't fully apply. Hand the id back so checkout
618
+ // pins it and reports the failure with the remediation, rather than aborting with an
619
+ // unpinned context and a branch the next `checkout` would silently accept as-is.
620
+ if (isPartialBranchCreateError(err)) {
621
+ log.info("Created branch %s (%s) from neon.ts policy.", err.branchName, err.branchId);
622
+ return { branchId: err.branchId, policyFailure: err.reason };
623
+ }
624
+ throw err;
625
+ }
605
626
  };
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { NEON_ENV_VAR_KEYS } from "@neon/env";
3
3
  import chalk from "chalk";
4
+ import { ensureGitignored } from "../context.js";
4
5
  import { resolveNeonEnvVars } from "../dev/env.js";
5
6
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
6
7
  import { log } from "../log.js";
@@ -76,7 +77,8 @@ export const pull = async (props, opts = {}) => {
76
77
  // keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
77
78
  // API returns exactly once — instead of minting a fresh credential on every pull.
78
79
  const targetPath = resolveEnvFilePath(cwd, props.file);
79
- const existingEnv = existsSync(targetPath) ? readEnvFile(targetPath) : {};
80
+ const fileExisted = existsSync(targetPath);
81
+ const existingEnv = fileExisted ? readEnvFile(targetPath) : {};
80
82
  // Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
81
83
  // pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
82
84
  // `env pull` is an explicit action, so it should error rather than write nothing.
@@ -105,6 +107,13 @@ export const pull = async (props, opts = {}) => {
105
107
  if (removed.length > 0) {
106
108
  log.info("Removed %d stale Neon variable%s not enabled on this branch: %s", removed.length, removed.length === 1 ? "" : "s", removed.join(", "));
107
109
  }
110
+ // A dotenv file *we* create holds live branch credentials (DATABASE_URL, Auth keys, service
111
+ // tokens), so ignore it the same way the `.neon` context file is — otherwise a fresh repo is
112
+ // one `git add -A` away from committing them. Only on creation: re-adding the entry on every
113
+ // pull would fight a user who deliberately un-ignored a file they want to commit.
114
+ if (!fileExisted) {
115
+ ensureGitignored(targetPath);
116
+ }
108
117
  // When the branch has the AI Gateway enabled, the pulled credentials always work, but
109
118
  // serving is plan-gated and the model set can be reduced on the beta — surface that as a
110
119
  // courtesy notice (best-effort; never fails the pull). The freshly pulled token lets us
package/dist/context.js CHANGED
@@ -153,9 +153,12 @@ export const setContext = (file, context) => {
153
153
  });
154
154
  };
155
155
  /**
156
- * Make sure the `.gitignore` next to `file` lists the file's basename
157
- * (currently always `.neon`). Creates the `.gitignore` if it doesn't exist,
158
- * or appends `.neon` if it's missing — never duplicates an existing entry.
156
+ * Make sure the `.gitignore` next to `file` covers the file's basename — used for the `.neon`
157
+ * context file and for a `.env` we create (both carry credentials that must not be committed).
158
+ * Creates the `.gitignore` if it doesn't exist, otherwise appends the entry only when nothing
159
+ * there already covers it: an exact line, or a basename glob such as `.env*` / `*.local`
160
+ * (see {@link gitignoreCovers}), so a repo that already ignores env files doesn't collect a
161
+ * redundant line per pull.
159
162
  *
160
163
  * Best-effort: a failure here (e.g. read-only filesystem) is logged at debug
161
164
  * level and swallowed; persisting the context file is the primary goal and
@@ -188,5 +191,41 @@ const basenameOf = (file) => {
188
191
  return parts[parts.length - 1] || CONTEXT_FILE;
189
192
  };
190
193
  const hasGitignoreEntry = (content, entry) => {
191
- return content.split(/\r?\n/).some((line) => line.trim() === entry);
194
+ return content
195
+ .split(/\r?\n/)
196
+ .some((line) => gitignoreCovers(line.trim(), entry));
197
+ };
198
+ /**
199
+ * Whether a single `.gitignore` line already ignores `entry` (a bare basename like `.neon` or
200
+ * `.env.local`).
201
+ *
202
+ * Deliberately narrow: an exact match, or a glob **without a path separator** — the
203
+ * `.env*` / `*.local` / `.env.?` shapes that repos actually use for env files — matched
204
+ * against the whole basename. Everything else returns false, which at worst appends an entry
205
+ * git already covers (harmless) rather than skipping one it doesn't (a committed credential).
206
+ * That's why path-scoped patterns (`config/.env`), negations (`!.env.local`), character
207
+ * classes, and comments are all treated as "does not cover".
208
+ */
209
+ const gitignoreCovers = (line, entry) => {
210
+ if (line === "" || line.startsWith("#") || line.startsWith("!")) {
211
+ return false;
212
+ }
213
+ // A trailing slash marks a directory-only pattern; the leading one anchors to the
214
+ // .gitignore's own directory, which is exactly where `entry` lives.
215
+ const pattern = line.replace(/\/$/, "").replace(/^\//, "");
216
+ if (pattern === entry)
217
+ return true;
218
+ if (pattern.includes("/") || pattern.includes("["))
219
+ return false;
220
+ if (!pattern.includes("*") && !pattern.includes("?"))
221
+ return false;
222
+ return globToRegExp(pattern).test(entry);
223
+ };
224
+ /** Compile a separator-free `.gitignore` glob (`*` / `?` only) into an anchored RegExp. */
225
+ const globToRegExp = (pattern) => {
226
+ const source = pattern
227
+ .replace(/[.+^${}()|\\]/g, "\\$&")
228
+ .replace(/\*/g, "[^/]*")
229
+ .replace(/\?/g, "[^/]");
230
+ return new RegExp(`^${source}$`);
192
231
  };
@@ -97,14 +97,20 @@ const serviceLabel = (identifier) => {
97
97
  /**
98
98
  * The desired-only field changes for an applied/planned **branch** update. The
99
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.
100
+ * (`parent`→`parent`, `ttl`→`expiresAt`, `protected`→`protected`,
101
+ * `computeSettings`→`settings`); the previous value isn't threaded through in
102
+ * Phase 1, so these render as `field → desired` (no red "before"). Object
103
+ * settings expand into sub-fields.
104
+ *
105
+ * `parent` only ever arrives from a branch creation (it cannot be changed
106
+ * afterwards), where it reports the parent the policy named.
103
107
  */
104
108
  const appliedBranchFields = (change) => {
105
109
  const details = change.details ?? {};
106
110
  const field = typeof details.field === "string" ? details.field : "setting";
107
111
  switch (field) {
112
+ case "parent":
113
+ return expandField("parent", undefined, details.parent);
108
114
  case "ttl":
109
115
  return expandField("ttl", undefined, details.expiresAt);
110
116
  case "protected":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neonctl",
3
- "version": "2.36.2",
3
+ "version": "2.37.0",
4
4
  "description": "CLI tool for Neon Serverless Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -51,10 +51,10 @@
51
51
  "which": "3.0.1",
52
52
  "yaml": "^2.9.0",
53
53
  "yargs": "17.7.2",
54
- "@neon/config": "0.9.6",
55
- "@neon/config-runtime": "0.9.7",
56
- "@neon/sdk": "1.3.0",
57
- "@neon/env": "0.11.7"
54
+ "@neon/config": "0.10.0",
55
+ "@neon/config-runtime": "0.10.0",
56
+ "@neon/env": "0.11.8",
57
+ "@neon/sdk": "1.3.0"
58
58
  },
59
59
  "optionalDependencies": {
60
60
  "esbuild": "0.28.1"
@@ -111,7 +111,7 @@
111
111
  "typecheck": "tsc --noEmit",
112
112
  "lint": "pnpm typecheck && biome check src",
113
113
  "lint:fix": "pnpm typecheck && biome check src --write",
114
- "test": "pnpm build && vitest run",
114
+ "test": "pnpm --filter neonctl... build && vitest run",
115
115
  "test:ci": "pnpm build && vitest run",
116
116
  "test:conformance": "vitest run --config tests/psql-conformance/vitest.config.ts"
117
117
  }