neonctl 2.24.1 → 2.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -166,7 +166,7 @@ step to pick which branch to pin — the same `+ Create a new branch…` + lis
166
166
  ```bash
167
167
  ? Which organization would you like to link? › Personal Org (org-abc123)
168
168
  ? Which project would you like to link? › my-app (polished-snowflake-12345678)
169
- ? Which branch would you like to link? › main (br-main-branch-87654321)
169
+ ? Which branch would you like to link? › [default] main (br-main-branch-87654321)
170
170
  ```
171
171
 
172
172
  **Non-interactive (flags or `--params` JSON)** — for scripts and CI:
@@ -7,7 +7,7 @@ import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.j
7
7
  import { fillSingleProject } from '../utils/enrichers.js';
8
8
  import { looksLikeBranchId } from '../utils/formats.js';
9
9
  import { autoPullEnvAfterPin } from './env.js';
10
- import { applyPolicyOnCreate } from './config.js';
10
+ import { applyPolicyOnCreate, createBranchFromPolicyOnCheckout, } from './config.js';
11
11
  import { handler as linkHandler } from './link.js';
12
12
  // The positional is optional: omitting it in an interactive terminal opens a
13
13
  // branch picker. In non-interactive contexts a missing branch is an error.
@@ -52,7 +52,7 @@ export const handler = async (props) => {
52
52
  // (--project-id flag > .neon file > single-project auto-detect); when
53
53
  // nothing resolves, fall back to an interactive `neonctl link`.
54
54
  const projectId = await resolveProjectId(props);
55
- const { branchId, created } = await resolveBranchId(props, projectId);
55
+ const { branchId, created, policyApplied } = await resolveBranchId(props, projectId);
56
56
  const orgId = await resolveOrgId(props, projectId);
57
57
  // `checkout` is a thin helper over `set-context`. It fully "heals" the
58
58
  // context file: it always (re)writes `projectId`, `branchId`, and `orgId`
@@ -64,15 +64,17 @@ export const handler = async (props) => {
64
64
  branchId,
65
65
  });
66
66
  log.info('Checked out branch %s on project %s%s. Updated %s.', branchId, projectId, orgId ? ` (org ${orgId})` : '', props.contextFile);
67
- // Only when checkout just *created* the branch do we apply the local neon.ts policy,
68
- // so a new branch comes up with the declared settings/infra immediately. Checking out an
69
- // existing branch never reconciles it that's an explicit `neonctl deploy` / `config
70
- // apply`. No neon.ts on disk nothing to apply.
71
- if (created) {
67
+ // When checkout *created* the branch and a neon.ts exists, the branch was created straight
68
+ // from the policy (evaluated as a new branch) so its settings/infra are already applied
69
+ // see `policyApplied`. The fallback below covers the case where the branch was created bare
70
+ // (e.g. a policy-driven create wasn't possible); `applyPolicyOnCreate` is a no-op when there
71
+ // is no neon.ts on disk. Checking out an existing branch never reconciles it.
72
+ if (created && !policyApplied) {
72
73
  await applyPolicyOnCreate({
73
74
  projectId,
74
75
  branchId,
75
76
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
77
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
76
78
  });
77
79
  }
78
80
  // Bundle `env pull` so the branch-first loop is just link + checkout: the branch you
@@ -94,26 +96,27 @@ const resolveBranchId = async (props, projectId) => {
94
96
  'or run interactively to pick one from a list.',
95
97
  });
96
98
  if (picked.kind === 'existing') {
97
- return { branchId: picked.branchId, created: false };
99
+ return {
100
+ branchId: picked.branchId,
101
+ created: false,
102
+ policyApplied: false,
103
+ };
98
104
  }
99
105
  // The user chose "create a new branch" from the picker.
100
- return {
101
- branchId: await createBranch(props.apiClient, projectId, picked.name, branches),
102
- created: true,
103
- };
106
+ return createCheckoutBranch(props, projectId, picked.name, branches);
104
107
  }
105
108
  const ref = props.id;
106
109
  // A `br-…` value is an id; match strictly by id and never offer to create.
107
110
  if (looksLikeBranchId(ref)) {
108
111
  const byId = branches.find((b) => b.id === ref);
109
112
  if (byId) {
110
- return { branchId: byId.id, created: false };
113
+ return { branchId: byId.id, created: false, policyApplied: false };
111
114
  }
112
115
  throw new Error(notFoundMessage(ref, branches));
113
116
  }
114
117
  const byName = branches.find((b) => b.name === ref);
115
118
  if (byName) {
116
- return { branchId: byName.id, created: false };
119
+ return { branchId: byName.id, created: false, policyApplied: false };
117
120
  }
118
121
  // Name not found: offer to create it interactively, mirroring `branch create`.
119
122
  if (isCi() || !process.stdout.isTTY) {
@@ -129,9 +132,33 @@ const resolveBranchId = async (props, projectId) => {
129
132
  if (!create) {
130
133
  throw new Error(`Aborted: branch "${ref}" was not found and not created.`);
131
134
  }
135
+ return createCheckoutBranch(props, projectId, ref, branches);
136
+ };
137
+ /**
138
+ * Create the branch to check out. When a `neon.ts` exists, route through the policy-driven
139
+ * create so the new branch comes up branched from the policy's `parent` and configured with
140
+ * its declared TTL / compute / services (evaluated as a *new* branch). Otherwise fall back to
141
+ * a bare branch off the default — the handler then applies the policy (a no-op with no
142
+ * `neon.ts`).
143
+ */
144
+ const createCheckoutBranch = async (props, projectId, name, branches) => {
145
+ const fromPolicy = await createBranchFromPolicyOnCheckout({
146
+ projectId,
147
+ branchName: name,
148
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
149
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
150
+ });
151
+ if (fromPolicy) {
152
+ return {
153
+ branchId: fromPolicy.branchId,
154
+ created: true,
155
+ policyApplied: true,
156
+ };
157
+ }
132
158
  return {
133
- branchId: await createBranch(props.apiClient, projectId, ref, branches),
159
+ branchId: await createBranch(props.apiClient, projectId, name, branches),
134
160
  created: true,
161
+ policyApplied: false,
135
162
  };
136
163
  };
137
164
  const notFoundMessage = (ref, branches) => `Branch ${ref} not found.\nAvailable branches: ${branches
@@ -1,5 +1,5 @@
1
1
  import { resolveConfig } from '@neondatabase/config';
2
- import { apply, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
2
+ import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
3
3
  import { toNeonConfigView } from '../config_format.js';
4
4
  import { log } from '../log.js';
5
5
  import { loadEnvFileIntoProcess } from '../env_file.js';
@@ -107,6 +107,7 @@ export const status = async (props) => {
107
107
  projectId: props.projectId,
108
108
  branchId,
109
109
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
110
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
110
111
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
111
112
  });
112
113
  // The pulled `config` carries the branch's tuning inside a closure that JSON can't
@@ -141,6 +142,7 @@ export const planCmd = async (props) => {
141
142
  projectId: props.projectId,
142
143
  branchId,
143
144
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
145
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
144
146
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
145
147
  });
146
148
  reportPushResult(props, result, 'plan');
@@ -152,6 +154,7 @@ export const applyCmd = async (props) => {
152
154
  projectId: props.projectId,
153
155
  branchId,
154
156
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
157
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
155
158
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
156
159
  ...(props.updateExisting ? { updateExisting: true } : {}),
157
160
  ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
@@ -235,11 +238,16 @@ export const applyPolicyOnCreate = async (props) => {
235
238
  projectId: props.projectId,
236
239
  branchId: props.branchId,
237
240
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
241
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
238
242
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
239
243
  updateExisting: true,
240
244
  allowProtectedBranch: true,
241
245
  bundleFunction: neonctlBundler,
242
246
  });
247
+ logPolicyResult(result);
248
+ };
249
+ /** Log a one-line summary of what applying a `neon.ts` policy changed (or that nothing did). */
250
+ const logPolicyResult = (result) => {
243
251
  const changes = result.applied.filter((c) => c.action !== 'noop');
244
252
  if (changes.length === 0) {
245
253
  log.info('neon.ts applied — no changes were needed.');
@@ -247,3 +255,40 @@ export const applyPolicyOnCreate = async (props) => {
247
255
  }
248
256
  log.info('neon.ts applied — %d change%s: %s', changes.length, changes.length === 1 ? '' : 's', changes.map((c) => `${c.action} ${c.identifier}`).join(', '));
249
257
  };
258
+ /**
259
+ * Create a branch **from** the local `neon.ts` policy. Returns `null` when there is no
260
+ * `neon.ts` on the path from cwd up to the repo root, so `neonctl checkout` can fall back to a
261
+ * bare branch create.
262
+ *
263
+ * Unlike a bare create followed by {@link applyPolicyOnCreate}, this evaluates the policy for
264
+ * the **new** branch (`exists: false`): the runtime branches from the policy's `parent` and
265
+ * brings the branch up with its declared TTL / compute settings / services. That's what makes
266
+ * a policy keyed on `!branch.exists` (the common "only configure new branches" shape) take
267
+ * effect on the very first `checkout` — a bare create + `apply` always saw `exists: true` and
268
+ * skipped that block.
269
+ */
270
+ export const createBranchFromPolicyOnCheckout = async (props) => {
271
+ let config;
272
+ try {
273
+ ({ config } = await loadConfigFromFile({
274
+ ...(props.cwd ? { cwd: props.cwd } : {}),
275
+ }));
276
+ }
277
+ catch (err) {
278
+ const message = err instanceof Error ? err.message : String(err);
279
+ if (/Could not find a Neon config file/i.test(message))
280
+ return null;
281
+ throw err;
282
+ }
283
+ const { branchId, branchName, result } = await createBranchFromPolicy(config, {
284
+ projectId: props.projectId,
285
+ branchName: props.branchName,
286
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
287
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
288
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
289
+ bundleFunction: neonctlBundler,
290
+ });
291
+ log.info('Created branch %s (%s) from neon.ts policy.', branchName, branchId);
292
+ logPolicyResult(result);
293
+ return { branchId };
294
+ };
package/commands/dev.js CHANGED
@@ -56,6 +56,7 @@ const runSingleSource = async (props) => {
56
56
  ...(props.projectId ? { projectId: props.projectId } : {}),
57
57
  ...(branchId ? { branchId } : {}),
58
58
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
59
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
59
60
  });
60
61
  const unit = {
61
62
  slug: null,
@@ -93,6 +94,7 @@ const runFromConfig = async (props) => {
93
94
  ...(props.projectId ? { projectId: props.projectId } : {}),
94
95
  ...(branchId ? { branchId } : {}),
95
96
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
97
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
96
98
  });
97
99
  const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
98
100
  // Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
package/commands/env.js CHANGED
@@ -49,6 +49,7 @@ export const pull = async (props) => {
49
49
  projectId: props.projectId,
50
50
  branchId,
51
51
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
52
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
52
53
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
53
54
  });
54
55
  const neonVars = pickNeonVars(vars);
package/dev/env.js CHANGED
@@ -2,6 +2,12 @@ import { loadConfigFromFile, } from '@neondatabase/config';
2
2
  import { plan, pullConfig, } from '@neondatabase/config-runtime';
3
3
  import { fetchEnv, toEntries } from '@neondatabase/env';
4
4
  import { log } from '../log.js';
5
+ /** The API-targeting options every runtime call forwards from the context. */
6
+ const apiOptions = (ctx) => ({
7
+ ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
8
+ ...(ctx.apiHost ? { apiHost: ctx.apiHost } : {}),
9
+ ...(ctx.api ? { api: ctx.api } : {}),
10
+ });
5
11
  /**
6
12
  * Thrown when a `neon.ts` policy declares a branch-level resource (Neon Auth,
7
13
  * Data API, a bucket, the AI Gateway) that the linked remote branch does not
@@ -70,8 +76,7 @@ export const resolveNeonEnvVars = async (ctx) => {
70
76
  const pulled = await pullConfig({
71
77
  projectId: ctx.projectId,
72
78
  branchId: ctx.branchId,
73
- ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
74
- ...(ctx.api ? { api: ctx.api } : {}),
79
+ ...apiOptions(ctx),
75
80
  });
76
81
  // `pulled.config` is already a `Config` (static auth/dataApi toggles + a branch
77
82
  // tuning closure), so it feeds straight into fetchEnv — no wrapping needed.
@@ -149,8 +154,7 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
149
154
  const result = await plan(config, {
150
155
  projectId: ctx.projectId,
151
156
  branchId: ctx.branchId,
152
- ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
153
- ...(ctx.api ? { api: ctx.api } : {}),
157
+ ...apiOptions(ctx),
154
158
  });
155
159
  const missing = result.applied.filter(isMissingResource);
156
160
  if (missing.length === 0)
@@ -174,8 +178,7 @@ const fetchAndProject = async (config, ctx) => {
174
178
  const env = await fetchEnv(config, {
175
179
  projectId: ctx.projectId,
176
180
  branchId: ctx.branchId,
177
- ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
178
- ...(ctx.api ? { api: ctx.api } : {}),
181
+ ...apiOptions(ctx),
179
182
  });
180
183
  return toEntries(env);
181
184
  };
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git+ssh://git@github.com/neondatabase/neonctl.git"
6
6
  },
7
7
  "type": "module",
8
- "version": "2.24.1",
8
+ "version": "2.24.2",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -59,9 +59,9 @@
59
59
  "dependencies": {
60
60
  "@hono/node-server": "2.0.4",
61
61
  "@neondatabase/api-client": "2.7.1",
62
- "@neondatabase/config": "0.4.0",
63
- "@neondatabase/config-runtime": "0.4.0",
64
- "@neondatabase/env": "0.3.0",
62
+ "@neondatabase/config": "0.4.2",
63
+ "@neondatabase/config-runtime": "0.4.2",
64
+ "@neondatabase/env": "0.3.2",
65
65
  "@segment/analytics-node": "1.3.0",
66
66
  "axios": "1.7.2",
67
67
  "axios-debug-log": "1.0.0",
@@ -5,6 +5,21 @@ import { log } from '../log.js';
5
5
  import { isCi } from '../env.js';
6
6
  /** Sentinel `value` for the "create a new branch" choice (no branch id can collide). */
7
7
  const CREATE_BRANCH_CHOICE = Symbol('create-branch');
8
+ /**
9
+ * Render a branch's display name with the same word labels as `neonctl branch list`
10
+ * (`[default]`, `[protected]`) instead of symbols, so the picker reads clearly.
11
+ */
12
+ const branchLabel = (branch) => {
13
+ const labels = [];
14
+ if (branch.default) {
15
+ labels.push('[default]');
16
+ }
17
+ if (branch.protected) {
18
+ labels.push('[protected]');
19
+ }
20
+ labels.push(branch.name);
21
+ return labels.join(' ');
22
+ };
8
23
  /**
9
24
  * Prompt the user to pick a branch from `branches`, with a "+ Create a new branch…" option
10
25
  * pinned to the top (mirroring the project/org pickers). The default selection is the
@@ -27,7 +42,7 @@ export const pickBranchInteractively = async (branches, opts) => {
27
42
  choices: [
28
43
  { title: '+ Create a new branch…', value: CREATE_BRANCH_CHOICE },
29
44
  ...branches.map((b) => ({
30
- title: `${b.default ? '✱ ' : ''}${b.name} (${b.id})`,
45
+ title: `${branchLabel(b)} (${b.id})`,
31
46
  value: b.id,
32
47
  })),
33
48
  ],