neonctl 2.24.0 → 2.24.1

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/commands/dev.js CHANGED
@@ -51,7 +51,7 @@ 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 } : {}),
@@ -63,10 +63,13 @@ const runSingleSource = async (props) => {
63
63
  bundleDir: join(process.cwd(), 'node_modules', '.neon-dev'),
64
64
  childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
65
65
  label: null,
66
+ envSummary: { neon: Object.keys(neonEnv), fn: [] },
66
67
  };
67
68
  // No config reload in single-source mode: there's exactly one file to serve, and
68
69
  // nothing to add or remove. neon.ts hot-reload is config-mode only.
69
- await runSupervisor([unit]);
70
+ await runSupervisor([unit], {
71
+ ...(skipped ? { envNote: skipped.reason } : {}),
72
+ });
70
73
  };
71
74
  /**
72
75
  * Multi-function mode: serve every function declared in neon.ts. Requires a neon.ts
@@ -85,7 +88,7 @@ const runFromConfig = async (props) => {
85
88
  throw new Error('neon.ts has no functions to serve. Add at least one under ' +
86
89
  '`preview.functions`, or pass --source <path>.');
87
90
  }
88
- const neonEnv = await resolveDevEnv({
91
+ const { vars: neonEnv, skipped } = await resolveDevEnv({
89
92
  cwd: process.cwd(),
90
93
  ...(props.projectId ? { projectId: props.projectId } : {}),
91
94
  ...(branchId ? { branchId } : {}),
@@ -102,7 +105,10 @@ const runFromConfig = async (props) => {
102
105
  return null;
103
106
  return planFunctionsToUnits(re.functions, neonEnv, searchBase);
104
107
  };
105
- await runSupervisor(units, { configPath, replan });
108
+ await runSupervisor(units, {
109
+ reload: { configPath, replan },
110
+ ...(skipped ? { envNote: skipped.reason } : {}),
111
+ });
106
112
  };
107
113
  /**
108
114
  * Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
@@ -174,6 +180,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
174
180
  bundleDir: join(process.cwd(), 'node_modules', '.neon-dev', fn.slug),
175
181
  childEnv,
176
182
  label: fn.slug,
183
+ envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
177
184
  // Signature of the function's *own* neon.ts config (NOT the dynamically-chosen search
178
185
  // base) so reconcile can tell a real change from a no-op save. A search-mode function
179
186
  // re-planned with a different base must hash identically, or it would be needlessly
@@ -221,7 +228,8 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
221
228
  * stayed the same. A function whose config (env/port/portless/source) changed is restarted
222
229
  * in place; siblings are untouched.
223
230
  */
224
- const runSupervisor = async (units, reload) => {
231
+ const runSupervisor = async (units, options = {}) => {
232
+ const { reload, envNote } = options;
225
233
  if (hasPortlessUnit(units)) {
226
234
  assertPortlessAvailable();
227
235
  }
@@ -310,7 +318,7 @@ const runSupervisor = async (units, reload) => {
310
318
  await Promise.all(running.map((r) => stopUnit(r)));
311
319
  throw new Error('No function started. See the output above for details.');
312
320
  }
313
- printBanner(running);
321
+ printBanner(running, envNote);
314
322
  // Config mode only: watch neon.ts and reconcile the live unit set when it changes.
315
323
  // Reconciles are serialized: a burst of saves (editor write-then-format) must not run
316
324
  // overlapping diffs against the mutating `running` array. A trailing run coalesces the
@@ -448,7 +456,10 @@ const reconcileOnce = async (running, replan, ops) => {
448
456
  await Promise.all(added.map((r) => ops.startUnit(r)));
449
457
  for (const r of added) {
450
458
  if (r.status === 'ready') {
451
- logUnit(r.unit, chalk.green('ready') + ` ${urlFor(r.boundPort)}`);
459
+ const env = formatEnvSummary(r.unit.envSummary);
460
+ logUnit(r.unit, chalk.green('ready') +
461
+ ` ${urlFor(r.boundPort)}` +
462
+ (env ? chalk.dim(` ${env}`) : ''));
452
463
  }
453
464
  }
454
465
  }
@@ -513,15 +524,13 @@ const spawnSyncCheck = (bin) => {
513
524
  const writeBundle = async (source, bundleDir) => {
514
525
  const files = await bundleEntry(source);
515
526
  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');
527
+ // bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
528
+ // it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
529
+ // points the sourcemap link at `index.mjs.map` for us.
521
530
  for (const [name, contents] of Object.entries(files)) {
522
531
  writeFileSync(join(bundleDir, name), contents);
523
532
  }
524
- return join(bundleDir, 'out.js');
533
+ return join(bundleDir, 'index.mjs');
525
534
  };
526
535
  const urlFor = (port) => port === null ? chalk.red('not running') : `http://localhost:${port}`;
527
536
  const waitForReady = (child) => new Promise((resolveReady) => {
@@ -567,7 +576,7 @@ const pipeChildOutput = (child, label) => {
567
576
  forward('stdout');
568
577
  forward('stderr');
569
578
  };
570
- const printBanner = (running) => {
579
+ const printBanner = (running, envNote) => {
571
580
  log.info('');
572
581
  log.info(chalk.green.bold(' Neon Functions dev server'));
573
582
  log.info('');
@@ -575,9 +584,34 @@ const printBanner = (running) => {
575
584
  const name = r.unit.label ?? 'function';
576
585
  const url = urlFor(r.boundPort);
577
586
  log.info(` ${chalk.dim(name.padEnd(20))} ${url}`);
587
+ const env = formatEnvSummary(r.unit.envSummary);
588
+ if (env)
589
+ log.info(` ${' '.repeat(20)} ${chalk.dim(env)}`);
590
+ }
591
+ if (envNote) {
592
+ log.info('');
593
+ log.info(` ${chalk.yellow('!')} ${chalk.dim(`Neon env: ${envNote}`)}`);
578
594
  }
579
595
  log.info('');
580
596
  };
597
+ /**
598
+ * Render a unit's injected env into one transparent line for the banner, e.g.
599
+ * `env: DATABASE_URL, DATABASE_URL_UNPOOLED · neon.ts: RESEND_API_KEY`. Var **names** only
600
+ * (never values — they're secrets). Returns `''` when nothing is injected, so the caller can
601
+ * skip the line. Exported for unit testing.
602
+ */
603
+ export const formatEnvSummary = (summary) => {
604
+ if (!summary)
605
+ return '';
606
+ const parts = [];
607
+ if (summary.neon.length > 0) {
608
+ parts.push(`env: ${[...summary.neon].sort().join(', ')}`);
609
+ }
610
+ if (summary.fn.length > 0) {
611
+ parts.push(`neon.ts: ${[...summary.fn].sort().join(', ')}`);
612
+ }
613
+ return parts.join(' · ');
614
+ };
581
615
  const logUnit = (unit, message) => {
582
616
  const prefix = unit.label ? chalk.dim(`[${unit.label}] `) : '';
583
617
  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. */
@@ -45,11 +55,55 @@ export const pull = async (props) => {
45
55
  if (Object.keys(neonVars).length === 0) {
46
56
  log.info('No Neon env variables to pull for this branch (no DATABASE_URL or ' +
47
57
  'enabled Auth / Data API).');
48
- return;
58
+ return { status: 'empty' };
49
59
  }
50
60
  const targetPath = resolveEnvFilePath(cwd, props.file);
51
61
  const { written } = mergeEnvFile(targetPath, neonVars);
52
62
  log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
63
+ return { status: 'written', written, file: targetPath };
64
+ };
65
+ /**
66
+ * Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
67
+ * `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
68
+ *
69
+ * On by default; `--no-env-pull` opts out (e.g. when env is injected at runtime via
70
+ * `neon-env run` / `neon dev`, or to keep secrets out of the working tree). The pin is the
71
+ * command's primary effect and has already succeeded by the time this runs, so a pull failure
72
+ * degrades to a warning rather than failing the command. Returns what happened so
73
+ * `link --agent` can fold an accurate note into its JSON message.
74
+ */
75
+ export const autoPullEnvAfterPin = async (props) => {
76
+ if (!props.envPull) {
77
+ log.info(chalk.dim(ENV_PULL_SKIPPED_HINT));
78
+ return { status: 'skipped' };
79
+ }
80
+ try {
81
+ return await pull(props);
82
+ }
83
+ catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ log.warning('Branch pinned, but pulling its Neon env vars failed: %s\n' +
86
+ 'Run `neonctl env pull` once resolved (e.g. `neonctl deploy` if a declared service ' +
87
+ 'is missing), or inject them at runtime with `neon-env run -- <your dev command>`.', message);
88
+ return { status: 'failed', message };
89
+ }
90
+ };
91
+ /**
92
+ * Render the one-line env-pull note appended to `link --agent`'s JSON `message`, so an agent
93
+ * reading the structured output knows whether its branch env is already on disk.
94
+ */
95
+ export const renderAgentPullNote = (result) => {
96
+ switch (result.status) {
97
+ case 'written':
98
+ return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? '' : 's'} into ${result.file}.`;
99
+ case 'empty':
100
+ return ' No Neon env vars to pull for this branch yet.';
101
+ case 'skipped':
102
+ return (' Skipped env pull (--no-env-pull); run `neonctl env pull` later, ' +
103
+ 'or inject env at runtime with `neon-env run -- <your dev command>`.');
104
+ case 'failed':
105
+ return ` Could not pull env vars (${result.message}); run \`neonctl env pull\` once resolved.`;
106
+ }
53
107
  };
54
108
  /**
55
109
  * 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
  ];
package/commands/link.js CHANGED
@@ -3,6 +3,8 @@ import prompts from 'prompts';
3
3
  import { applyContext, readContextFile } from '../context.js';
4
4
  import { isCi } from '../env.js';
5
5
  import { log } from '../log.js';
6
+ import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
7
+ import { autoPullEnvAfterPin, renderAgentPullNote } from './env.js';
6
8
  import { REGIONS } from './projects.js';
7
9
  const PROJECTS_LIST_LIMIT = 100;
8
10
  const CREATE_NEW_SENTINEL = '__create_new__';
@@ -40,6 +42,13 @@ export const builder = (argv) => argv.usage('$0 link [options]').options({
40
42
  type: 'boolean',
41
43
  default: false,
42
44
  },
45
+ 'env-pull': {
46
+ describe: "Pull the linked branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
47
+ 'linking. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
48
+ 'runtime with `neon-env run` / `neon dev`).',
49
+ type: 'boolean',
50
+ default: true,
51
+ },
43
52
  });
44
53
  export const handler = async (props) => {
45
54
  if (props.agent) {
@@ -134,7 +143,7 @@ const runNonInteractive = async (props, inputs) => {
134
143
  projectId: inputs.projectId,
135
144
  branchId,
136
145
  });
137
- printHumanSummary(props, {
146
+ await finalizeHumanLink(props, {
138
147
  contextFile: props.contextFile,
139
148
  orgId,
140
149
  projectId: inputs.projectId,
@@ -153,7 +162,7 @@ const runNonInteractive = async (props, inputs) => {
153
162
  projectId: created.project.id,
154
163
  branchId: created.branchId,
155
164
  });
156
- printHumanSummary(props, {
165
+ await finalizeHumanLink(props, {
157
166
  contextFile: props.contextFile,
158
167
  orgId,
159
168
  projectId: created.project.id,
@@ -187,13 +196,13 @@ const runInteractive = async (props, inputs) => {
187
196
  orgId = await promptOrgFromList(orgResolution.orgs);
188
197
  }
189
198
  if (inputs.projectId) {
190
- const branchId = await resolveDefaultBranchId(props, inputs.projectId);
199
+ const branchId = await resolveInteractiveBranchId(props, inputs.projectId);
191
200
  applyContext(props.contextFile, {
192
201
  orgId,
193
202
  projectId: inputs.projectId,
194
203
  branchId,
195
204
  });
196
- printHumanSummary(props, {
205
+ await finalizeHumanLink(props, {
197
206
  contextFile: props.contextFile,
198
207
  orgId,
199
208
  projectId: inputs.projectId,
@@ -213,7 +222,7 @@ const runInteractive = async (props, inputs) => {
213
222
  projectId: created.project.id,
214
223
  branchId: created.branchId,
215
224
  });
216
- printHumanSummary(props, {
225
+ await finalizeHumanLink(props, {
217
226
  contextFile: props.contextFile,
218
227
  orgId,
219
228
  projectId: created.project.id,
@@ -228,13 +237,13 @@ const runInteractive = async (props, inputs) => {
228
237
  const projects = await listAllProjects(props, orgId);
229
238
  const action = await promptProjectChoice(projects, inputs.projectName);
230
239
  if (action.type === 'existing') {
231
- const branchId = await resolveDefaultBranchId(props, action.projectId);
240
+ const branchId = await resolveInteractiveBranchId(props, action.projectId);
232
241
  applyContext(props.contextFile, {
233
242
  orgId,
234
243
  projectId: action.projectId,
235
244
  branchId,
236
245
  });
237
- printHumanSummary(props, {
246
+ await finalizeHumanLink(props, {
238
247
  contextFile: props.contextFile,
239
248
  orgId,
240
249
  projectId: action.projectId,
@@ -257,7 +266,7 @@ const runInteractive = async (props, inputs) => {
257
266
  projectId: created.project.id,
258
267
  branchId: created.branchId,
259
268
  });
260
- printHumanSummary(props, {
269
+ await finalizeHumanLink(props, {
261
270
  contextFile: props.contextFile,
262
271
  orgId,
263
272
  projectId: created.project.id,
@@ -382,12 +391,18 @@ const runAgent = async (props, inputs) => {
382
391
  if (projectId) {
383
392
  const branchId = await resolveDefaultBranchId(props, projectId);
384
393
  applyContext(props.contextFile, { orgId, projectId, branchId });
394
+ const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
395
+ ...props,
396
+ projectId,
397
+ branch: branchId,
398
+ envPull: props.envPull,
399
+ }));
385
400
  emitAgent({
386
401
  status: 'linked',
387
402
  context_file: props.contextFile,
388
403
  context: { orgId, projectId, branchId },
389
404
  project: { id: projectId },
390
- message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}.`,
405
+ message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}.${pullNote}`,
391
406
  });
392
407
  return;
393
408
  }
@@ -416,6 +431,12 @@ const runAgent = async (props, inputs) => {
416
431
  projectId: created.project.id,
417
432
  branchId: created.branchId,
418
433
  });
434
+ const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
435
+ ...props,
436
+ projectId: created.project.id,
437
+ branch: created.branchId,
438
+ envPull: props.envPull,
439
+ }));
419
440
  emitAgent({
420
441
  status: 'linked',
421
442
  context_file: props.contextFile,
@@ -429,7 +450,7 @@ const runAgent = async (props, inputs) => {
429
450
  name: created.project.name,
430
451
  region_id: created.project.region_id,
431
452
  },
432
- message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.`,
453
+ message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.${pullNote}`,
433
454
  });
434
455
  return;
435
456
  }
@@ -591,6 +612,33 @@ const resolveDefaultBranchId = async (props, projectId) => {
591
612
  }
592
613
  return branch.id;
593
614
  };
615
+ /**
616
+ * Resolve which branch to pin for an interactively-chosen project. When the project has a
617
+ * single branch there is nothing to choose, so we pin it silently. Otherwise we offer the
618
+ * shared branch picker (the same "+ Create a new branch…" + list as `neonctl checkout`),
619
+ * creating the branch when the user opts to. This makes `link` a full org → project →
620
+ * branch flow instead of always pinning the default branch.
621
+ */
622
+ const resolveInteractiveBranchId = async (props, projectId) => {
623
+ const { data } = await props.apiClient.listProjectBranches({ projectId });
624
+ const branches = data.branches;
625
+ if (branches.length <= 1) {
626
+ const only = branches.find((b) => b.default) ?? branches[0];
627
+ if (!only) {
628
+ throw new Error(`Could not find a default branch for project ${projectId}.`);
629
+ }
630
+ return only.id;
631
+ }
632
+ const picked = await pickBranchInteractively(branches, {
633
+ message: 'Which branch would you like to link?',
634
+ nonInteractiveMessage: 'No branch could be selected without an interactive terminal. ' +
635
+ 'Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.',
636
+ });
637
+ if (picked.kind === 'existing') {
638
+ return picked.branchId;
639
+ }
640
+ return createBranch(props.apiClient, projectId, picked.name, branches);
641
+ };
594
642
  const fetchRegions = async (props) => {
595
643
  try {
596
644
  const { data } = await props.apiClient.getActiveRegions();
@@ -648,6 +696,20 @@ const printHumanSummary = (_props, summary) => {
648
696
  lines.push('');
649
697
  process.stdout.write(`${lines.join('\n')}\n`);
650
698
  };
699
+ /**
700
+ * Print the link summary, then run the bundled `env pull` so a human `link` ends with the
701
+ * branch's connection string already on disk — the branch-first loop is just link + checkout.
702
+ * `--no-env-pull` opts out (env pull's own status / skip hint is logged to stderr).
703
+ */
704
+ const finalizeHumanLink = async (props, summary) => {
705
+ printHumanSummary(props, summary);
706
+ await autoPullEnvAfterPin({
707
+ ...props,
708
+ projectId: summary.projectId,
709
+ branch: summary.branchId,
710
+ envPull: props.envPull,
711
+ });
712
+ };
651
713
  const onPromptState = (state) => {
652
714
  if (state.aborted) {
653
715
  process.stdout.write('\x1B[?25h');
package/dev/env.js CHANGED
@@ -82,24 +82,40 @@ export const resolveNeonEnvVars = async (ctx) => {
82
82
  };
83
83
  /**
84
84
  * `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
85
- * A missing branch context or any failure (no Neon account, no `.neon`, no network)
86
- * logs a warning and returns `{}` so the function still runs locally; only a
87
- * {@link DevEnvMismatchError} (policy declares a resource the branch lacks) is
88
- * re-thrown for the caller to surface.
85
+ *
86
+ * - Success `{ vars }` (possibly just the always-present Postgres URLs).
87
+ * - No linked branch / project → `{ vars: {}, skipped }` with a "link a branch" hint; the
88
+ * function still runs locally, just without Neon env.
89
+ * - Any other failure (offline, transient API error) → `{ vars: {}, skipped }` naming the
90
+ * cause; again non-fatal.
91
+ * - {@link DevEnvMismatchError} (policy declares a secret-bearing service the branch lacks)
92
+ * is the one hard stop and is re-thrown for the caller to surface.
89
93
  */
90
94
  export const resolveDevEnv = async (ctx) => {
91
95
  try {
92
- return await resolveNeonEnvVars(ctx);
96
+ return { vars: await resolveNeonEnvVars(ctx) };
93
97
  }
94
98
  catch (err) {
95
99
  if (err instanceof DevEnvMismatchError)
96
100
  throw err;
97
101
  if (err instanceof MissingBranchContextError) {
98
102
  log.debug('dev: %s; skipping env injection', err.message);
99
- return {};
103
+ return {
104
+ vars: {},
105
+ skipped: {
106
+ reason: 'no linked Neon branch — run `neonctl link`, then ' +
107
+ '`neonctl checkout <branch>`, to inject DATABASE_URL and friends',
108
+ },
109
+ };
100
110
  }
101
- log.warning('Could not inject Neon env vars; the function will run without them: %s', err instanceof Error ? err.message : String(err));
102
- return {};
111
+ const detail = err instanceof Error ? err.message : String(err);
112
+ log.debug('dev: env resolution failed: %s', detail);
113
+ return {
114
+ vars: {},
115
+ skipped: {
116
+ reason: `could not reach Neon (${detail}); running without Neon env`,
117
+ },
118
+ };
103
119
  }
104
120
  };
105
121
  /**
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.0",
8
+ "version": "2.24.1",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
package/pkg.js CHANGED
@@ -1,3 +1,25 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
- export default JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf-8'));
4
+ /**
5
+ * Load the CLI's package.json for version metadata. In the built CLI it sits right next to
6
+ * this module (the build copies it into `dist`); when running from source (tests, `tsx`) it
7
+ * does not, so we walk up to the nearest `package.json`. Both layouts resolve to the same
8
+ * file, keeping `pkg.version` correct everywhere without a test-only shim.
9
+ */
10
+ const loadPkg = () => {
11
+ let dir = dirname(fileURLToPath(import.meta.url));
12
+ for (;;) {
13
+ try {
14
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
15
+ }
16
+ catch {
17
+ const parent = dirname(dir);
18
+ if (parent === dir) {
19
+ throw new Error('Could not locate package.json for version detection.');
20
+ }
21
+ dir = parent;
22
+ }
23
+ }
24
+ };
25
+ export default loadPkg();
package/storage_api.js ADDED
@@ -0,0 +1,114 @@
1
+ // Typed client helpers for the branch object-storage (bucket/object) API.
2
+ //
3
+ // These endpoints are part of the Neon object-storage surface (the "Buckets"
4
+ // tag in the public API). They are not yet exposed as typed methods on the
5
+ // published `@neondatabase/api-client` package, so the request/response types
6
+ // and the thin call helpers live here. They are implemented on top of the
7
+ // api-client's public `request()` method, which means they reuse the exact
8
+ // same authentication, base URL, headers and retry behaviour as every other
9
+ // neonctl command. When the generated client gains these methods, the call
10
+ // sites in `src/commands/bucket.ts` can switch over with no behavioural
11
+ // change.
12
+ const bucketsPath = (projectId, branchId) => `/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/buckets`;
13
+ const bucketPath = (projectId, branchId, bucketName) => `${bucketsPath(projectId, branchId)}/${encodeURIComponent(bucketName)}`;
14
+ /**
15
+ * Create a bucket on a branch.
16
+ *
17
+ * @request POST /projects/{project_id}/branches/{branch_id}/buckets
18
+ */
19
+ export const createProjectBranchBucket = (apiClient, { projectId, branchId, name, accessLevel, }) => {
20
+ const body = { name };
21
+ // Omit access_level entirely so the server default (`private`) applies.
22
+ if (accessLevel !== undefined) {
23
+ body.access_level = accessLevel;
24
+ }
25
+ return apiClient.request({
26
+ path: bucketsPath(projectId, branchId),
27
+ method: 'POST',
28
+ body,
29
+ format: 'json',
30
+ secure: true,
31
+ });
32
+ };
33
+ /**
34
+ * List the buckets on a branch.
35
+ *
36
+ * @request GET /projects/{project_id}/branches/{branch_id}/buckets
37
+ */
38
+ export const listProjectBranchBuckets = (apiClient, { projectId, branchId }) => apiClient.request({
39
+ path: bucketsPath(projectId, branchId),
40
+ method: 'GET',
41
+ format: 'json',
42
+ secure: true,
43
+ });
44
+ /**
45
+ * Delete a bucket from a branch.
46
+ *
47
+ * @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}
48
+ */
49
+ export const deleteProjectBranchBucket = (apiClient, { projectId, branchId, bucketName, }) => apiClient.request({
50
+ path: bucketPath(projectId, branchId, bucketName),
51
+ method: 'DELETE',
52
+ secure: true,
53
+ });
54
+ /**
55
+ * List objects (and collapsed folders) in a bucket on a branch.
56
+ *
57
+ * @request GET /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects
58
+ */
59
+ export const listProjectBranchBucketObjects = (apiClient, { projectId, branchId, bucketName, ...query }) => apiClient.request({
60
+ path: `${bucketPath(projectId, branchId, bucketName)}/objects`,
61
+ method: 'GET',
62
+ query,
63
+ format: 'json',
64
+ secure: true,
65
+ });
66
+ /**
67
+ * Download an object's raw bytes from a bucket on a branch.
68
+ *
69
+ * The server returns the body as `application/octet-stream` with a
70
+ * `Content-Disposition: attachment` header; the helper requests the body as a
71
+ * stream (`responseType: 'stream'`), so `.data` is a Node `Readable` the caller
72
+ * can pipe straight to disk without buffering the whole object in memory. The
73
+ * response headers are returned alongside so the caller can derive a filename
74
+ * from `Content-Disposition`.
75
+ *
76
+ * The object key may contain `/`; it is percent-encoded into a single path
77
+ * segment so nested keys are routed to the `{object_key}` parameter.
78
+ *
79
+ * @request GET /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}/download
80
+ */
81
+ export const getProjectBranchBucketObject = (apiClient, { projectId, branchId, bucketName, objectKey, }) => apiClient.request({
82
+ path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}/download`,
83
+ method: 'GET',
84
+ format: 'stream',
85
+ secure: true,
86
+ });
87
+ /**
88
+ * Delete an object from a bucket on a branch.
89
+ *
90
+ * The object key may contain `/`; it is percent-encoded into a single path
91
+ * segment so nested keys are routed to the `{object_key}` parameter.
92
+ *
93
+ * @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}
94
+ */
95
+ export const deleteProjectBranchBucketObject = (apiClient, { projectId, branchId, bucketName, objectKey, }) => apiClient.request({
96
+ path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}`,
97
+ method: 'DELETE',
98
+ secure: true,
99
+ });
100
+ /**
101
+ * Delete every object under a key prefix (folder) in a bucket on a branch.
102
+ *
103
+ * `prefix` must be non-empty and end with `/`; every object on this branch
104
+ * whose key starts with the prefix is soft-deleted in a single call.
105
+ *
106
+ * @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects-by-prefix
107
+ */
108
+ export const deleteProjectBranchBucketObjectsByPrefix = (apiClient, { projectId, branchId, bucketName, prefix, }) => apiClient.request({
109
+ path: `${bucketPath(projectId, branchId, bucketName)}/objects-by-prefix`,
110
+ method: 'DELETE',
111
+ query: { prefix },
112
+ format: 'json',
113
+ secure: true,
114
+ });