neonctl 2.36.1 → 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.
- package/dist/commands/checkout.js +37 -5
- package/dist/commands/config.js +37 -16
- package/dist/commands/env.js +10 -1
- package/dist/context.js +43 -4
- package/dist/utils/config_diff.js +9 -3
- package/package.json +6 -6
|
@@ -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
|
-
|
|
82
|
-
await
|
|
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 {
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
-
/**
|
|
554
|
-
|
|
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 —
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
};
|
package/dist/commands/env.js
CHANGED
|
@@ -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
|
|
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`
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
|
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`,
|
|
101
|
-
* the previous value isn't threaded through in
|
|
102
|
-
* `field → desired` (no red "before"). Object
|
|
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.
|
|
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/
|
|
55
|
-
"@neon/config": "0.
|
|
56
|
-
"@neon/env": "0.11.
|
|
57
|
-
"@neon/
|
|
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
|
}
|