neonctl 2.24.1 → 2.25.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/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
@@ -114,7 +116,7 @@ const runFromConfig = async (props) => {
114
116
  * Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
115
117
  * base across them so search-mode functions don't all probe the same starting port.
116
118
  *
117
- * Each search-mode (no `dev.port`, non-portless) function gets a distinct base starting at
119
+ * Each search-mode (no `dev.port`) function gets a distinct base starting at
118
120
  * `searchBase`; the runtime still walks upward from its base, so an occupied base
119
121
  * self-resolves and this never fails — the offset just makes startup deterministic.
120
122
  */
@@ -122,7 +124,7 @@ const planFunctionsToUnits = (functions, neonEnv, searchBase) => {
122
124
  let searchOffset = 0;
123
125
  return functions.map((fn) => {
124
126
  const base = searchBase + searchOffset;
125
- if (!fn.portless && fn.port === undefined)
127
+ if (fn.port === undefined)
126
128
  searchOffset += 1;
127
129
  return plannedToUnit(fn, neonEnv, base);
128
130
  });
@@ -160,19 +162,14 @@ const portFromProps = (port) => {
160
162
  };
161
163
  /**
162
164
  * Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
163
- * - portless: portless assigns the port and injects PORT, which the runtime honors — so
164
- * we set no port env (`inherit`) and `dev.port` is ignored. Wrapped with
165
- * `portless <slug>` for a stable `slug.localhost` URL.
166
165
  * - explicit `dev.port`: bind exactly, fail if taken.
167
166
  * - no `dev.port`: search for a free port (base coordinated by the caller).
168
167
  * Per-function neon.ts env layers over the shared branch env.
169
168
  */
170
169
  const plannedToUnit = (fn, branchEnv, searchBase) => {
171
- const port = fn.portless
172
- ? { mode: 'inherit' }
173
- : fn.port !== undefined
174
- ? { mode: 'explicit', port: fn.port }
175
- : { mode: 'search', from: searchBase };
170
+ const port = fn.port !== undefined
171
+ ? { mode: 'explicit', port: fn.port }
172
+ : { mode: 'search', from: searchBase };
176
173
  const childEnv = buildChildEnv({ ...branchEnv, ...fn.env }, port);
177
174
  return {
178
175
  slug: fn.slug,
@@ -188,10 +185,8 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
188
185
  configKey: JSON.stringify({
189
186
  source: fn.source,
190
187
  port: fn.port ?? null,
191
- portless: fn.portless,
192
188
  env: fn.env,
193
189
  }),
194
- ...(fn.portless ? { portless: { slug: fn.slug } } : {}),
195
190
  };
196
191
  };
197
192
  /**
@@ -210,7 +205,6 @@ const buildChildEnv = (neonEnv, port) => {
210
205
  else if (port.mode === 'search') {
211
206
  env.NEON_DEV_PORT_BASE = String(port.from);
212
207
  }
213
- // 'inherit': set neither, so an injected PORT (portless) drives the runtime.
214
208
  return env;
215
209
  };
216
210
  const READY_PATTERN = /neon-dev:ready (\d+)/;
@@ -219,20 +213,16 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
219
213
  * its inputs for hot reload, and tear everything down cleanly on shutdown. Units are
220
214
  * independent — one crashing or failing to start does not stop the others (it is shown
221
215
  * as errored and recovered on the next edit). A single SIGINT/SIGTERM shuts all of them
222
- * down, tree-killing each child so no descendant (e.g. a portless-wrapped runtime) is
223
- * orphaned.
216
+ * down, tree-killing each child so no descendant it spawned is orphaned.
224
217
  *
225
218
  * In config mode, `reload` lets the supervisor watch `neon.ts` and reconcile the live set
226
219
  * of units when it changes: a newly-declared function is hot-added (its own child, watcher,
227
220
  * and port) and a removed one is torn down — all without disturbing the functions that
228
- * stayed the same. A function whose config (env/port/portless/source) changed is restarted
221
+ * stayed the same. A function whose config (env/port/source) changed is restarted
229
222
  * in place; siblings are untouched.
230
223
  */
231
224
  const runSupervisor = async (units, options = {}) => {
232
225
  const { reload, envNote } = options;
233
- if (hasPortlessUnit(units)) {
234
- assertPortlessAvailable();
235
- }
236
226
  const runtimePath = resolveRuntimePath();
237
227
  let shuttingDown = false;
238
228
  const running = units.map(makeRunningUnit);
@@ -378,7 +368,7 @@ const makeRunningUnit = (unit) => ({
378
368
  * Pure slug-keyed diff of the live units against the freshly-resolved desired set:
379
369
  * - a slug present now but not before → **add** (new child + watcher + port),
380
370
  * - a slug gone from neon.ts → **remove** (torn down),
381
- * - a slug whose config (source/port/portless/env) changed → **restart** in place,
371
+ * - a slug whose config (source/port/env) changed → **restart** in place,
382
372
  * - an unchanged slug → left out of the plan entirely (never touched).
383
373
  * Functions that stayed the same never die, so an edit that only adds a function is
384
374
  * non-disruptive. `desired === null` (neon.ts deleted) is treated as "no functions".
@@ -434,8 +424,6 @@ const reconcileOnce = async (running, replan, ops) => {
434
424
  }
435
425
  if (ops.isShuttingDown())
436
426
  return;
437
- if (hasPortlessUnit(desired ?? []))
438
- assertPortlessAvailable();
439
427
  const plan = diffUnits(running, desired);
440
428
  for (const r of plan.remove) {
441
429
  logUnit(r.unit, chalk.dim('removed from neon.ts, stopping…'));
@@ -477,50 +465,18 @@ const nextSearchBase = (running) => {
477
465
  }
478
466
  return max + 1;
479
467
  };
480
- const hasPortlessUnit = (units) => units.some((u) => u.portless !== undefined);
481
468
  /**
482
- * Spawn the child for a unit. A portless unit is wrapped as `portless <slug> node
483
- * <runtime> <bundle>`: portless assigns a port, injects it as PORT (which the runtime
484
- * honors), and exposes the server at `slug.localhost`. A plain unit runs the bundled
485
- * output directly under `node`.
469
+ * Spawn the child for a unit: the bundled output run directly under `node`.
486
470
  *
487
- * Spawned detached (own process group) so killTree can reap the whole group — important
488
- * for the portless case, where the tree is portless -> node runtime.
471
+ * Spawned detached (own process group) so killTree can reap the whole group.
489
472
  */
490
473
  const spawnChild = (unit, runtimePath, bundlePath) => {
491
- if (unit.portless) {
492
- return spawn('portless', [unit.portless.slug, process.execPath, runtimePath, bundlePath], {
493
- stdio: ['ignore', 'pipe', 'pipe'],
494
- env: unit.childEnv,
495
- detached: true,
496
- });
497
- }
498
474
  return spawn(process.execPath, [runtimePath, bundlePath], {
499
475
  stdio: ['ignore', 'pipe', 'pipe'],
500
476
  env: unit.childEnv,
501
477
  detached: true,
502
478
  });
503
479
  };
504
- /** Fail early with an actionable message if a portless unit is requested but the binary is missing. */
505
- const assertPortlessAvailable = () => {
506
- const result = spawnSyncCheck('portless');
507
- if (!result) {
508
- throw new Error('A function sets `dev.portless: true`, but the `portless` command was not ' +
509
- 'found on your PATH. Install it globally (e.g. `npm i -g portless`) or ' +
510
- 'remove `dev.portless` from the function in neon.ts.');
511
- }
512
- };
513
- const spawnSyncCheck = (bin) => {
514
- try {
515
- // Synchronous, no-side-effect probe: `which`/`where` resolves the binary.
516
- const probe = process.platform === 'win32' ? 'where' : 'which';
517
- const { status } = spawnSync(probe, [bin]);
518
- return status === 0;
519
- }
520
- catch {
521
- return false;
522
- }
523
- };
524
480
  const writeBundle = async (source, bundleDir) => {
525
481
  const files = await bundleEntry(source);
526
482
  mkdirSync(bundleDir, { recursive: true });
@@ -689,7 +645,7 @@ const startDirectoryWatcher = async (chokidar, source, restart) => {
689
645
  /**
690
646
  * Terminate a child and every descendant it spawned. The child is started `detached`, so
691
647
  * on POSIX it leads its own process group and a negative-PID signal reaps the group
692
- * (covering portless -> neonctl -> node). On Windows there are no POSIX groups, so we
648
+ * (covering the runtime and anything it spawned). On Windows there are no POSIX groups, so we
693
649
  * shell out to `taskkill /T` to kill the tree. Escalates SIGTERM -> SIGKILL after 2s.
694
650
  */
695
651
  const killTree = (child) => {
package/commands/env.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
2
  import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
3
+ import { existsSync } from 'node:fs';
3
4
  import { log } from '../log.js';
4
5
  import { resolveNeonEnvVars } from '../dev/env.js';
5
- import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
6
+ import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from '../env_file.js';
6
7
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
8
  export const command = 'env';
8
9
  export const describe = "Manage a branch's Neon env variables locally";
@@ -41,6 +42,12 @@ const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Objec
41
42
  export const pull = async (props) => {
42
43
  const cwd = props.cwd ?? process.cwd();
43
44
  const branchId = await branchIdFromProps(props);
45
+ // Resolve the target file first and layer its current contents under the resolver's env
46
+ // source. This lets `fetchEnv` reuse one-time secrets that are already on disk — Neon Auth
47
+ // keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
48
+ // API returns exactly once — instead of minting a fresh credential on every pull.
49
+ const targetPath = resolveEnvFilePath(cwd, props.file);
50
+ const existingEnv = existsSync(targetPath) ? readEnvFile(targetPath) : {};
44
51
  // Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
45
52
  // pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
46
53
  // `env pull` is an explicit action, so it should error rather than write nothing.
@@ -48,7 +55,9 @@ export const pull = async (props) => {
48
55
  cwd,
49
56
  projectId: props.projectId,
50
57
  branchId,
58
+ env: { ...process.env, ...existingEnv },
51
59
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
60
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
52
61
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
53
62
  });
54
63
  const neonVars = pickNeonVars(vars);
@@ -57,7 +66,6 @@ export const pull = async (props) => {
57
66
  'enabled Auth / Data API).');
58
67
  return { status: 'empty' };
59
68
  }
60
- const targetPath = resolveEnvFilePath(cwd, props.file);
61
69
  const { written } = mergeEnvFile(targetPath, neonVars);
62
70
  log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
63
71
  return { status: 'written', written, file: targetPath };
@@ -21,6 +21,23 @@ const DEPLOYMENT_FIELDS = [
21
21
  'memory_mib',
22
22
  'created_at',
23
23
  ];
24
+ // Deploy emits the resolved deployment plus the function's invocation_url, so a
25
+ // successful `functions deploy` tells the user exactly where to call the function.
26
+ const DEPLOY_RESULT_FIELDS = [
27
+ 'id',
28
+ 'status',
29
+ 'invocation_url',
30
+ 'runtime',
31
+ 'memory_mib',
32
+ 'created_at',
33
+ ];
34
+ // In table mode a failed build's reason gets its own "deployment error"
35
+ // section after the deployment table; json/yaml carry the raw `error` field.
36
+ const writeDeploymentErrorSection = (out, dep) => {
37
+ if (dep.status === 'failed' && dep.error) {
38
+ out.write({ reason: dep.error }, { fields: ['reason'], title: 'deployment error' });
39
+ }
40
+ };
24
41
  const SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
25
42
  const SLUG_HELP = 'Use 1-20 lowercase letters and digits (no hyphens or other characters).';
26
43
  // Overridable so tests can poll fast; defaults to 2s in real use.
@@ -77,10 +94,19 @@ export const builder = (argv) => argv
77
94
  },
78
95
  }), (args) => deploy(args))
79
96
  .command('list', 'List functions on the branch', (yargs) => yargs, (args) => list(args))
80
- .command('get <slug>', "Show a function's details", (yargs) => yargs.positional('slug', {
97
+ .command('get <slug>', "Show a function's details", (yargs) => yargs
98
+ .positional('slug', {
81
99
  describe: 'Function slug',
82
100
  type: 'string',
83
101
  demandOption: true,
102
+ })
103
+ .options({
104
+ 'list-env-variables': {
105
+ describe: 'List the environment variable names of the active deployment',
106
+ type: 'boolean',
107
+ alias: 'E',
108
+ default: false,
109
+ },
84
110
  }), (args) => get(args))
85
111
  .command('delete <slug>', 'Delete a function on the branch', (yargs) => yargs.positional('slug', {
86
112
  describe: 'Function slug',
@@ -104,6 +130,15 @@ const parseEnv = (entries) => {
104
130
  return JSON.stringify(map);
105
131
  };
106
132
  const statusHint = (slug, projectId, branchId) => `Check status with: neonctl functions get ${slug} --project-id ${projectId} --branch ${branchId}`;
133
+ // Emit the resolved deployment together with the function's invocation_url, so the
134
+ // deploy output shows where the function is reachable (not just the deployment id).
135
+ const emitDeployResult = (props, deployment, fn) => {
136
+ const out = writer(props).write({ ...deployment, invocation_url: fn?.invocation_url }, { fields: DEPLOY_RESULT_FIELDS });
137
+ if (props.output !== 'json' && props.output !== 'yaml') {
138
+ writeDeploymentErrorSection(out, deployment);
139
+ }
140
+ out.end();
141
+ };
107
142
  // A poll error worth retrying: a network error (no HTTP response), a 5xx, or a
108
143
  // 404 from eventual consistency. Anything else (e.g. 401/403) is surfaced.
109
144
  const isTransient = (err) => isAxiosError(err) &&
@@ -165,6 +200,9 @@ const deploy = async (props) => {
165
200
  // any version if there was none). --no-wait stops there; --wait stops at a
166
201
  // terminal status. Bounded by POLL_TIMEOUT_MS so it never hangs.
167
202
  let resolved;
203
+ // The function carries the invocation_url; keep the whole record (not just its
204
+ // active_deployment) so we can surface that URL on success.
205
+ let resolvedFn;
168
206
  const deadline = Date.now() + POLL_TIMEOUT_MS;
169
207
  try {
170
208
  while (!interrupted && Date.now() < deadline) {
@@ -173,18 +211,20 @@ const deploy = async (props) => {
173
211
  break;
174
212
  // The deploy already succeeded server-side; tolerate transient poll
175
213
  // failures and retry on the next interval. Surface anything else.
176
- let dep;
214
+ let fn;
177
215
  try {
178
- dep = (await getFunction(props.apiClient, props.projectId, branchId, props.slug)).active_deployment;
216
+ fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
179
217
  }
180
218
  catch (err) {
181
219
  if (isTransient(err))
182
220
  continue;
183
221
  throw err;
184
222
  }
223
+ const dep = fn.active_deployment;
185
224
  const isNew = dep !== undefined && (before === undefined || dep.id > before);
186
225
  if (isNew && dep) {
187
226
  resolved = dep;
227
+ resolvedFn = fn;
188
228
  if (!props.wait)
189
229
  break;
190
230
  if (dep.status === 'completed' || dep.status === 'failed')
@@ -199,14 +239,14 @@ const deploy = async (props) => {
199
239
  if (interrupted) {
200
240
  log.info(statusHint(props.slug, props.projectId, branchId));
201
241
  if (resolved)
202
- writer(props).end(resolved, { fields: DEPLOYMENT_FIELDS });
242
+ emitDeployResult(props, resolved, resolvedFn);
203
243
  return;
204
244
  }
205
245
  if (resolved === undefined) {
206
246
  log.info(statusHint(props.slug, props.projectId, branchId));
207
247
  throw new Error(`Timed out waiting for the deployment of ${props.slug} to start. It may still be in progress.`);
208
248
  }
209
- writer(props).end(resolved, { fields: DEPLOYMENT_FIELDS });
249
+ emitDeployResult(props, resolved, resolvedFn);
210
250
  if (!props.wait) {
211
251
  log.info(statusHint(props.slug, props.projectId, branchId));
212
252
  return;
@@ -238,6 +278,14 @@ const get = async (props) => {
238
278
  fields: DEPLOYMENT_FIELDS,
239
279
  title: 'active deployment',
240
280
  });
281
+ writeDeploymentErrorSection(out, fn.active_deployment);
282
+ }
283
+ if (props.listEnvVariables) {
284
+ out.write((fn.active_deployment?.environment ?? []).map((name) => ({ name })), {
285
+ fields: ['name'],
286
+ title: 'environment',
287
+ emptyMessage: 'No environment variables on the active deployment.',
288
+ });
241
289
  }
242
290
  out.end();
243
291
  };
package/commands/index.js CHANGED
@@ -22,6 +22,7 @@ import * as config from './config.js';
22
22
  import * as deploy from './deploy.js';
23
23
  import * as env from './env.js';
24
24
  import * as bucket from './bucket.js';
25
+ import * as bootstrap from './bootstrap.js';
25
26
  export default [
26
27
  auth,
27
28
  users,
@@ -47,4 +48,5 @@ export default [
47
48
  deploy,
48
49
  env,
49
50
  bucket,
51
+ bootstrap,
50
52
  ];