neonctl 2.24.0 → 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.
@@ -1,13 +1,13 @@
1
- import { EndpointType } from '@neondatabase/api-client';
2
1
  import { isAxiosError } from 'axios';
3
2
  import prompts from 'prompts';
4
- import { retryOnLock } from '../api.js';
5
3
  import { applyContext, readContextFile } from '../context.js';
6
4
  import { isCi } from '../env.js';
7
5
  import { log } from '../log.js';
6
+ import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
8
7
  import { fillSingleProject } from '../utils/enrichers.js';
9
8
  import { looksLikeBranchId } from '../utils/formats.js';
10
- import { applyPolicyOnCreate } from './config.js';
9
+ import { autoPullEnvAfterPin } from './env.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.
@@ -24,6 +24,13 @@ export const builder = (argv) => argv
24
24
  describe: 'Project ID',
25
25
  type: 'string',
26
26
  },
27
+ 'env-pull': {
28
+ describe: "Pull the branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
29
+ 'checkout. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
30
+ 'runtime with `neon-env run` / `neon dev`).',
31
+ type: 'boolean',
32
+ default: true,
33
+ },
27
34
  })
28
35
  .example([
29
36
  [
@@ -45,7 +52,7 @@ export const handler = async (props) => {
45
52
  // (--project-id flag > .neon file > single-project auto-detect); when
46
53
  // nothing resolves, fall back to an interactive `neonctl link`.
47
54
  const projectId = await resolveProjectId(props);
48
- const { branchId, created } = await resolveBranchId(props, projectId);
55
+ const { branchId, created, policyApplied } = await resolveBranchId(props, projectId);
49
56
  const orgId = await resolveOrgId(props, projectId);
50
57
  // `checkout` is a thin helper over `set-context`. It fully "heals" the
51
58
  // context file: it always (re)writes `projectId`, `branchId`, and `orgId`
@@ -57,44 +64,59 @@ export const handler = async (props) => {
57
64
  branchId,
58
65
  });
59
66
  log.info('Checked out branch %s on project %s%s. Updated %s.', branchId, projectId, orgId ? ` (org ${orgId})` : '', props.contextFile);
60
- // Only when checkout just *created* the branch do we apply the local neon.ts policy,
61
- // so a new branch comes up with the declared settings/infra immediately. Checking out an
62
- // existing branch never reconciles it that's an explicit `neonctl deploy` / `config
63
- // apply`. No neon.ts on disk nothing to apply.
64
- 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) {
65
73
  await applyPolicyOnCreate({
66
74
  projectId,
67
75
  branchId,
68
76
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
77
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
69
78
  });
70
79
  }
80
+ // Bundle `env pull` so the branch-first loop is just link + checkout: the branch you
81
+ // checked out is immediately usable for local dev. `--no-env-pull` opts out.
82
+ await autoPullEnvAfterPin({
83
+ ...props,
84
+ projectId,
85
+ branch: branchId,
86
+ envPull: props.envPull,
87
+ });
71
88
  };
72
89
  const resolveBranchId = async (props, projectId) => {
73
90
  const branches = (await props.apiClient.listProjectBranches({ projectId }))
74
91
  .data.branches;
75
92
  if (!props.id) {
76
- const picked = await pickBranchInteractively(branches);
93
+ const picked = await pickBranchInteractively(branches, {
94
+ message: 'Which branch would you like to check out?',
95
+ nonInteractiveMessage: 'No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
96
+ 'or run interactively to pick one from a list.',
97
+ });
77
98
  if (picked.kind === 'existing') {
78
- return { branchId: picked.branchId, created: false };
99
+ return {
100
+ branchId: picked.branchId,
101
+ created: false,
102
+ policyApplied: false,
103
+ };
79
104
  }
80
105
  // The user chose "create a new branch" from the picker.
81
- return {
82
- branchId: await createBranch(props, projectId, picked.name, branches),
83
- created: true,
84
- };
106
+ return createCheckoutBranch(props, projectId, picked.name, branches);
85
107
  }
86
108
  const ref = props.id;
87
109
  // A `br-…` value is an id; match strictly by id and never offer to create.
88
110
  if (looksLikeBranchId(ref)) {
89
111
  const byId = branches.find((b) => b.id === ref);
90
112
  if (byId) {
91
- return { branchId: byId.id, created: false };
113
+ return { branchId: byId.id, created: false, policyApplied: false };
92
114
  }
93
115
  throw new Error(notFoundMessage(ref, branches));
94
116
  }
95
117
  const byName = branches.find((b) => b.name === ref);
96
118
  if (byName) {
97
- return { branchId: byName.id, created: false };
119
+ return { branchId: byName.id, created: false, policyApplied: false };
98
120
  }
99
121
  // Name not found: offer to create it interactively, mirroring `branch create`.
100
122
  if (isCi() || !process.stdout.isTTY) {
@@ -110,90 +132,38 @@ const resolveBranchId = async (props, projectId) => {
110
132
  if (!create) {
111
133
  throw new Error(`Aborted: branch "${ref}" was not found and not created.`);
112
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
+ }
113
158
  return {
114
- branchId: await createBranch(props, projectId, ref, branches),
159
+ branchId: await createBranch(props.apiClient, projectId, name, branches),
115
160
  created: true,
161
+ policyApplied: false,
116
162
  };
117
163
  };
118
164
  const notFoundMessage = (ref, branches) => `Branch ${ref} not found.\nAvailable branches: ${branches
119
165
  .map((b) => b.name)
120
166
  .join(', ')}`;
121
- /** Sentinel `value` for the "create a new branch" choice (no branch id can collide). */
122
- const CREATE_BRANCH_CHOICE = Symbol('create-branch');
123
- const pickBranchInteractively = async (branches) => {
124
- if (isCi() || !process.stdout.isTTY) {
125
- throw new Error('No branch specified. Pass a branch name or id (e.g. `neonctl checkout main`), ' +
126
- 'or run interactively to pick one from a list.');
127
- }
128
- // The default selection is the project's default branch when there are branches to
129
- // show; the create option sits at the top, so offset the default index by one.
130
- const defaultBranchIndex = branches.findIndex((b) => b.default);
131
- const initial = defaultBranchIndex >= 0 ? defaultBranchIndex + 1 : 0;
132
- const { choice } = await prompts({
133
- type: 'select',
134
- name: 'choice',
135
- message: 'Which branch would you like to check out?',
136
- choices: [
137
- { title: '+ Create a new branch…', value: CREATE_BRANCH_CHOICE },
138
- ...branches.map((b) => ({
139
- title: `${b.default ? '✱ ' : ''}${b.name} (${b.id})`,
140
- value: b.id,
141
- })),
142
- ],
143
- initial,
144
- });
145
- if (choice === undefined) {
146
- throw new Error('Aborted: no branch selected.');
147
- }
148
- if (choice === CREATE_BRANCH_CHOICE) {
149
- return { kind: 'create', name: await promptNewBranchName(branches) };
150
- }
151
- return { kind: 'existing', branchId: choice };
152
- };
153
- /**
154
- * Prompt for a new branch name, rejecting empty input and names already taken on the
155
- * project (so we never silently check out a different, pre-existing branch).
156
- */
157
- const promptNewBranchName = async (branches) => {
158
- const existing = new Set(branches.map((b) => b.name));
159
- const { name } = await prompts({
160
- type: 'text',
161
- name: 'name',
162
- message: 'New branch name:',
163
- validate: (value) => {
164
- const trimmed = value.trim();
165
- if (trimmed === '')
166
- return 'Branch name cannot be empty.';
167
- if (existing.has(trimmed))
168
- return `A branch named "${trimmed}" already exists.`;
169
- return true;
170
- },
171
- });
172
- const trimmed = typeof name === 'string' ? name.trim() : '';
173
- if (trimmed === '') {
174
- throw new Error('Aborted: no branch name provided.');
175
- }
176
- return trimmed;
177
- };
178
- /**
179
- * Create a branch with the same defaults as `neonctl branch create --name <name>`:
180
- * branched from the project's default branch with a read-write compute endpoint.
181
- */
182
- const createBranch = async (props, projectId, name, branches) => {
183
- const defaultBranch = branches.find((b) => b.default);
184
- if (!defaultBranch) {
185
- throw new Error('No default branch found');
186
- }
187
- const { data } = await retryOnLock(() => props.apiClient.createProjectBranch(projectId, {
188
- branch: { name, parent_id: defaultBranch.id },
189
- endpoints: [{ type: EndpointType.ReadWrite }],
190
- }));
191
- if (defaultBranch.protected) {
192
- log.warning('The parent branch is protected; a unique role password has been generated for the new branch.');
193
- }
194
- log.info('Created branch %s (%s).', data.branch.name, data.branch.id);
195
- return data.branch.id;
196
- };
197
167
  /**
198
168
  * Resolve the org id to heal into the context file.
199
169
  *
@@ -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
@@ -51,11 +51,12 @@ const runSingleSource = async (props) => {
51
51
  throw new Error(`Source file not found: ${source}`);
52
52
  }
53
53
  const branchId = await resolveBranchId(props);
54
- const neonEnv = await resolveDevEnv({
54
+ const { vars: neonEnv, skipped } = await resolveDevEnv({
55
55
  cwd: process.cwd(),
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,
@@ -63,10 +64,13 @@ const runSingleSource = async (props) => {
63
64
  bundleDir: join(process.cwd(), 'node_modules', '.neon-dev'),
64
65
  childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
65
66
  label: null,
67
+ envSummary: { neon: Object.keys(neonEnv), fn: [] },
66
68
  };
67
69
  // No config reload in single-source mode: there's exactly one file to serve, and
68
70
  // nothing to add or remove. neon.ts hot-reload is config-mode only.
69
- await runSupervisor([unit]);
71
+ await runSupervisor([unit], {
72
+ ...(skipped ? { envNote: skipped.reason } : {}),
73
+ });
70
74
  };
71
75
  /**
72
76
  * Multi-function mode: serve every function declared in neon.ts. Requires a neon.ts
@@ -85,11 +89,12 @@ const runFromConfig = async (props) => {
85
89
  throw new Error('neon.ts has no functions to serve. Add at least one under ' +
86
90
  '`preview.functions`, or pass --source <path>.');
87
91
  }
88
- const neonEnv = await resolveDevEnv({
92
+ const { vars: neonEnv, skipped } = await resolveDevEnv({
89
93
  cwd: process.cwd(),
90
94
  ...(props.projectId ? { projectId: props.projectId } : {}),
91
95
  ...(branchId ? { branchId } : {}),
92
96
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
97
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
93
98
  });
94
99
  const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
95
100
  // Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
@@ -102,7 +107,10 @@ const runFromConfig = async (props) => {
102
107
  return null;
103
108
  return planFunctionsToUnits(re.functions, neonEnv, searchBase);
104
109
  };
105
- await runSupervisor(units, { configPath, replan });
110
+ await runSupervisor(units, {
111
+ reload: { configPath, replan },
112
+ ...(skipped ? { envNote: skipped.reason } : {}),
113
+ });
106
114
  };
107
115
  /**
108
116
  * Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
@@ -174,6 +182,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
174
182
  bundleDir: join(process.cwd(), 'node_modules', '.neon-dev', fn.slug),
175
183
  childEnv,
176
184
  label: fn.slug,
185
+ envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
177
186
  // Signature of the function's *own* neon.ts config (NOT the dynamically-chosen search
178
187
  // base) so reconcile can tell a real change from a no-op save. A search-mode function
179
188
  // re-planned with a different base must hash identically, or it would be needlessly
@@ -221,7 +230,8 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
221
230
  * stayed the same. A function whose config (env/port/portless/source) changed is restarted
222
231
  * in place; siblings are untouched.
223
232
  */
224
- const runSupervisor = async (units, reload) => {
233
+ const runSupervisor = async (units, options = {}) => {
234
+ const { reload, envNote } = options;
225
235
  if (hasPortlessUnit(units)) {
226
236
  assertPortlessAvailable();
227
237
  }
@@ -310,7 +320,7 @@ const runSupervisor = async (units, reload) => {
310
320
  await Promise.all(running.map((r) => stopUnit(r)));
311
321
  throw new Error('No function started. See the output above for details.');
312
322
  }
313
- printBanner(running);
323
+ printBanner(running, envNote);
314
324
  // Config mode only: watch neon.ts and reconcile the live unit set when it changes.
315
325
  // Reconciles are serialized: a burst of saves (editor write-then-format) must not run
316
326
  // overlapping diffs against the mutating `running` array. A trailing run coalesces the
@@ -448,7 +458,10 @@ const reconcileOnce = async (running, replan, ops) => {
448
458
  await Promise.all(added.map((r) => ops.startUnit(r)));
449
459
  for (const r of added) {
450
460
  if (r.status === 'ready') {
451
- logUnit(r.unit, chalk.green('ready') + ` ${urlFor(r.boundPort)}`);
461
+ const env = formatEnvSummary(r.unit.envSummary);
462
+ logUnit(r.unit, chalk.green('ready') +
463
+ ` ${urlFor(r.boundPort)}` +
464
+ (env ? chalk.dim(` ${env}`) : ''));
452
465
  }
453
466
  }
454
467
  }
@@ -513,15 +526,13 @@ const spawnSyncCheck = (bin) => {
513
526
  const writeBundle = async (source, bundleDir) => {
514
527
  const files = await bundleEntry(source);
515
528
  mkdirSync(bundleDir, { recursive: true });
516
- // The bundle is ESM (`format: 'esm'`), but it's written into a `.js` file under the
517
- // user's node_modules where Node, finding no `"type"`, would treat `.js` as CommonJS
518
- // and throw `Unexpected token 'export'`. Drop a `package.json` marker so Node runs it as
519
- // ESM. (A bare `out.mjs` would also work but breaks the `out.js.map` sourcemap link.)
520
- writeFileSync(join(bundleDir, 'package.json'), '{"type":"module"}\n');
529
+ // bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
530
+ // it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
531
+ // points the sourcemap link at `index.mjs.map` for us.
521
532
  for (const [name, contents] of Object.entries(files)) {
522
533
  writeFileSync(join(bundleDir, name), contents);
523
534
  }
524
- return join(bundleDir, 'out.js');
535
+ return join(bundleDir, 'index.mjs');
525
536
  };
526
537
  const urlFor = (port) => port === null ? chalk.red('not running') : `http://localhost:${port}`;
527
538
  const waitForReady = (child) => new Promise((resolveReady) => {
@@ -567,7 +578,7 @@ const pipeChildOutput = (child, label) => {
567
578
  forward('stdout');
568
579
  forward('stderr');
569
580
  };
570
- const printBanner = (running) => {
581
+ const printBanner = (running, envNote) => {
571
582
  log.info('');
572
583
  log.info(chalk.green.bold(' Neon Functions dev server'));
573
584
  log.info('');
@@ -575,9 +586,34 @@ const printBanner = (running) => {
575
586
  const name = r.unit.label ?? 'function';
576
587
  const url = urlFor(r.boundPort);
577
588
  log.info(` ${chalk.dim(name.padEnd(20))} ${url}`);
589
+ const env = formatEnvSummary(r.unit.envSummary);
590
+ if (env)
591
+ log.info(` ${' '.repeat(20)} ${chalk.dim(env)}`);
592
+ }
593
+ if (envNote) {
594
+ log.info('');
595
+ log.info(` ${chalk.yellow('!')} ${chalk.dim(`Neon env: ${envNote}`)}`);
578
596
  }
579
597
  log.info('');
580
598
  };
599
+ /**
600
+ * Render a unit's injected env into one transparent line for the banner, e.g.
601
+ * `env: DATABASE_URL, DATABASE_URL_UNPOOLED · neon.ts: RESEND_API_KEY`. Var **names** only
602
+ * (never values — they're secrets). Returns `''` when nothing is injected, so the caller can
603
+ * skip the line. Exported for unit testing.
604
+ */
605
+ export const formatEnvSummary = (summary) => {
606
+ if (!summary)
607
+ return '';
608
+ const parts = [];
609
+ if (summary.neon.length > 0) {
610
+ parts.push(`env: ${[...summary.neon].sort().join(', ')}`);
611
+ }
612
+ if (summary.fn.length > 0) {
613
+ parts.push(`neon.ts: ${[...summary.fn].sort().join(', ')}`);
614
+ }
615
+ return parts.join(' · ');
616
+ };
581
617
  const logUnit = (unit, message) => {
582
618
  const prefix = unit.label ? chalk.dim(`[${unit.label}] `) : '';
583
619
  log.info(`${prefix}${message}`);
package/commands/env.js CHANGED
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
2
3
  import { log } from '../log.js';
3
4
  import { resolveNeonEnvVars } from '../dev/env.js';
@@ -5,6 +6,13 @@ import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
5
6
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
6
7
  export const command = 'env';
7
8
  export const describe = "Manage a branch's Neon env variables locally";
9
+ /**
10
+ * Shown (to stderr) when `link` / `checkout` skip the bundled env pull because the user passed
11
+ * `--no-env-pull`. Names the two ways to get the branch's vars without an on-disk file written
12
+ * eagerly: an explicit `neonctl env pull`, or runtime injection via `neon-env run`.
13
+ */
14
+ export const ENV_PULL_SKIPPED_HINT = 'Skipped env pull (--no-env-pull). Run `neonctl env pull` to write this branch’s env vars ' +
15
+ '(DATABASE_URL, …) into a local .env, or inject them at runtime with `neon-env run -- <your dev command>`.';
8
16
  export const builder = (argv) => argv
9
17
  .usage('$0 env <sub-command> [options]')
10
18
  .options({
@@ -23,7 +31,9 @@ export const builder = (argv) => argv
23
31
  },
24
32
  })
25
33
  .example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
26
- .example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), (args) => pull(args))
34
+ .example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), async (args) => {
35
+ await pull(args);
36
+ })
27
37
  .demandCommand(1);
28
38
  export const handler = (args) => args;
29
39
  /** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
@@ -39,17 +49,62 @@ export const pull = async (props) => {
39
49
  projectId: props.projectId,
40
50
  branchId,
41
51
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
52
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
42
53
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
43
54
  });
44
55
  const neonVars = pickNeonVars(vars);
45
56
  if (Object.keys(neonVars).length === 0) {
46
57
  log.info('No Neon env variables to pull for this branch (no DATABASE_URL or ' +
47
58
  'enabled Auth / Data API).');
48
- return;
59
+ return { status: 'empty' };
49
60
  }
50
61
  const targetPath = resolveEnvFilePath(cwd, props.file);
51
62
  const { written } = mergeEnvFile(targetPath, neonVars);
52
63
  log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
64
+ return { status: 'written', written, file: targetPath };
65
+ };
66
+ /**
67
+ * Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
68
+ * `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
69
+ *
70
+ * On by default; `--no-env-pull` opts out (e.g. when env is injected at runtime via
71
+ * `neon-env run` / `neon dev`, or to keep secrets out of the working tree). The pin is the
72
+ * command's primary effect and has already succeeded by the time this runs, so a pull failure
73
+ * degrades to a warning rather than failing the command. Returns what happened so
74
+ * `link --agent` can fold an accurate note into its JSON message.
75
+ */
76
+ export const autoPullEnvAfterPin = async (props) => {
77
+ if (!props.envPull) {
78
+ log.info(chalk.dim(ENV_PULL_SKIPPED_HINT));
79
+ return { status: 'skipped' };
80
+ }
81
+ try {
82
+ return await pull(props);
83
+ }
84
+ catch (err) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ log.warning('Branch pinned, but pulling its Neon env vars failed: %s\n' +
87
+ 'Run `neonctl env pull` once resolved (e.g. `neonctl deploy` if a declared service ' +
88
+ 'is missing), or inject them at runtime with `neon-env run -- <your dev command>`.', message);
89
+ return { status: 'failed', message };
90
+ }
91
+ };
92
+ /**
93
+ * Render the one-line env-pull note appended to `link --agent`'s JSON `message`, so an agent
94
+ * reading the structured output knows whether its branch env is already on disk.
95
+ */
96
+ export const renderAgentPullNote = (result) => {
97
+ switch (result.status) {
98
+ case 'written':
99
+ return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? '' : 's'} into ${result.file}.`;
100
+ case 'empty':
101
+ return ' No Neon env vars to pull for this branch yet.';
102
+ case 'skipped':
103
+ return (' Skipped env pull (--no-env-pull); run `neonctl env pull` later, ' +
104
+ 'or inject env at runtime with `neon-env run -- <your dev command>`.');
105
+ case 'failed':
106
+ return ` Could not pull env vars (${result.message}); run \`neonctl env pull\` once resolved.`;
107
+ }
53
108
  };
54
109
  /**
55
110
  * Keep only the recognized Neon variables from the resolved set, so a stray inherited
package/commands/index.js CHANGED
@@ -21,6 +21,7 @@ import * as dev from './dev.js';
21
21
  import * as config from './config.js';
22
22
  import * as deploy from './deploy.js';
23
23
  import * as env from './env.js';
24
+ import * as bucket from './bucket.js';
24
25
  export default [
25
26
  auth,
26
27
  users,
@@ -45,4 +46,5 @@ export default [
45
46
  config,
46
47
  deploy,
47
48
  env,
49
+ bucket,
48
50
  ];