neonctl 2.26.1 → 2.26.3

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
@@ -455,7 +455,7 @@ The target directory must be empty unless you pass `--force` (a lone `.git` is i
455
455
  | [me](https://neon.com/docs/reference/cli-me) | | Show current user |
456
456
  | [branches](https://neon.com/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-default`, `set-expiration`, `delete`, `get` | Manage branches |
457
457
  | [databases](https://neon.com/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
458
- | functions | `deploy`, `list`, `get`, `delete` | Manage Neon Functions |
458
+ | function | `deploy`, `list`, `get`, `delete` | Manage Neon Functions |
459
459
  | [roles](https://neon.com/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
460
460
  | [operations](https://neon.com/docs/reference/cli-operations) | `list` | Manage operations |
461
461
  | [connection-string](https://neon.com/docs/reference/cli-connection-string) | | Get connection string |
package/commands/auth.js CHANGED
@@ -110,6 +110,9 @@ export const ensureAuth = async (props) => {
110
110
  // login. It uses an API key / stored credentials when present (harmless),
111
111
  // otherwise it proceeds with no API client.
112
112
  const isBootstrap = props._[0] === 'bootstrap';
113
+ // `init` manages its own auth flow (asks the user if they have an account,
114
+ // then triggers OAuth at the right time). Skip the global auth middleware.
115
+ const isInit = props._[0] === 'init';
113
116
  // Use existing API key or handle auth command
114
117
  if (props.apiKey || props._[0] === 'auth') {
115
118
  if (props.apiKey) {
@@ -162,6 +165,10 @@ export const ensureAuth = async (props) => {
162
165
  log.debug('bootstrap: no usable credentials; continuing without auth');
163
166
  return;
164
167
  }
168
+ if (isInit) {
169
+ log.debug('init: skipping global auth; init manages its own auth flow');
170
+ return;
171
+ }
165
172
  // Start new auth flow if no valid token exists or refresh failed
166
173
  const apiKey = await authFlow(props);
167
174
  props.apiKey = apiKey;
@@ -6,7 +6,7 @@ import prompts from 'prompts';
6
6
  import which from 'which';
7
7
  import { isCi } from '../env.js';
8
8
  import { log } from '../log.js';
9
- import { FALLBACK_TEMPLATES, fetchFileBytes, fetchSymlinkTarget, fetchTemplates, findTemplate, resolveTemplate, templateIds, } from '../utils/bootstrap.js';
9
+ import { FALLBACK_TEMPLATES, downloadTemplate, fetchTemplates, findTemplate, templateIds, } from '../utils/bootstrap.js';
10
10
  // The directory positional is optional: omitting it in an interactive terminal
11
11
  // prompts for one. In a non-interactive context a missing directory is an error.
12
12
  export const command = 'bootstrap [directory]';
@@ -101,18 +101,18 @@ const resolveTemplateList = async (props) => props.template && findTemplate(FALL
101
101
  ? FALLBACK_TEMPLATES
102
102
  : fetchTemplates();
103
103
  /**
104
- * The picker label for a template: the title prefixed with the Neon services it
105
- * uses as a dim badge, e.g. "[Postgres · Functions] Hono API …". The badge is
106
- * styled with chalk.dim only (never a foreground color) so it survives the
107
- * cyan/underline `prompts` paints over the focused row dim resets with the
108
- * intensity SGR, leaving the row's color and underline intact. The one-line
109
- * description renders under the title on focus (handled by `prompts`).
104
+ * The picker label for a template: the title first, then the Neon services it
105
+ * uses as a dim, italic suffix, e.g. "Hono API … Postgres · Functions". The
106
+ * suffix is styled with chalk.dim (and italic) only never a foreground color
107
+ * so it survives the cyan/underline `prompts` paints over the focused row: dim
108
+ * and italic reset with their own SGRs, leaving the row's color and underline
109
+ * intact. Descriptions are intentionally omitted to keep the picker uncluttered.
110
110
  */
111
111
  const formatTemplateTitle = (template) => {
112
112
  if (!template.services || template.services.length === 0) {
113
113
  return template.title;
114
114
  }
115
- return `${chalk.dim(`[${template.services.join(' · ')}]`)} ${template.title}`;
115
+ return `${template.title} ${chalk.dim.italic(template.services.join(' · '))}`;
116
116
  };
117
117
  const resolveSelectedTemplate = async (props, interactive, templates) => {
118
118
  if (props.template) {
@@ -141,7 +141,6 @@ const resolveSelectedTemplate = async (props, interactive, templates) => {
141
141
  message: 'Which template would you like to use?',
142
142
  choices: templates.map((template) => ({
143
143
  title: formatTemplateTitle(template),
144
- description: template.description,
145
144
  value: template.id,
146
145
  })),
147
146
  initial: 0,
@@ -207,25 +206,23 @@ const ensureTargetUsable = (dir, force) => {
207
206
  };
208
207
  const scaffold = async (template, targetDir) => {
209
208
  log.info('Fetching template "%s" from GitHub…', template.id);
210
- const { commitSha, entries } = await resolveTemplate(template);
209
+ const files = await downloadTemplate(template);
211
210
  mkdirSync(targetDir, { recursive: true });
212
- log.info('Scaffolding %d files into %s…', entries.length, targetDir);
213
- await mapWithConcurrency(entries, 8, async (entry) => {
214
- const dest = join(targetDir, entry.path);
211
+ log.info('Scaffolding %d files into %s…', files.length, targetDir);
212
+ for (const file of files) {
213
+ const dest = join(targetDir, file.path);
215
214
  mkdirSync(dirname(dest), { recursive: true });
216
- if (entry.kind === 'symlink') {
217
- const target = await fetchSymlinkTarget(template, commitSha, entry.repoPath);
218
- writeSymlink(dest, target);
215
+ if (file.kind === 'symlink') {
216
+ writeSymlink(dest, file.target);
219
217
  }
220
218
  else {
221
- const bytes = await fetchFileBytes(template, commitSha, entry.repoPath);
222
- writeFileSync(dest, bytes);
223
- if (entry.executable) {
219
+ writeFileSync(dest, file.bytes);
220
+ if (file.executable) {
224
221
  chmodSync(dest, 0o755);
225
222
  }
226
223
  }
227
- });
228
- return entries.length;
224
+ }
225
+ return files.length;
229
226
  };
230
227
  const writeSymlink = (dest, target) => {
231
228
  if (isSymlink(dest)) {
@@ -561,16 +558,6 @@ const displayDir = (targetDir) => {
561
558
  }
562
559
  return rel.startsWith('..') ? targetDir : rel;
563
560
  };
564
- const mapWithConcurrency = async (items, limit, fn) => {
565
- const queue = [...items];
566
- const worker = async () => {
567
- for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
568
- await fn(next);
569
- }
570
- };
571
- const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
572
- await Promise.all(workers);
573
- };
574
561
  const isSymlink = (path) => {
575
562
  try {
576
563
  return lstatSync(path).isSymbolicLink();
@@ -95,7 +95,7 @@ export const builder = (argv) => argv
95
95
  .command({
96
96
  command: 'list <target>',
97
97
  aliases: ['ls'],
98
- describe: 'List objects in a bucket',
98
+ describe: 'List objects in a bucket. By default folders are collapsed (like "aws s3 ls"); pass --recursive for a flat listing of every key',
99
99
  builder: (yargs) => yargs
100
100
  .usage('$0 bucket object list <bucket>[/<prefix>] [options]')
101
101
  .positional('target', {
@@ -105,8 +105,13 @@ export const builder = (argv) => argv
105
105
  })
106
106
  .options({
107
107
  ...scopeOptions,
108
+ recursive: {
109
+ describe: 'List every key flat, descending into nested folders (no delimiter). Mutually exclusive with --delimiter. Mirrors "aws s3 ls --recursive"',
110
+ type: 'boolean',
111
+ default: false,
112
+ },
108
113
  delimiter: {
109
- describe: 'Collapse keys sharing a common prefix (e.g. "/") into folders',
114
+ describe: 'Collapse keys sharing this prefix separator into folders. Defaults to "/" (folder view); ignored when --recursive is set',
110
115
  type: 'string',
111
116
  },
112
117
  cursor: {
@@ -223,7 +228,26 @@ const deleteBucket = async (props) => {
223
228
  }
224
229
  log.info(`Bucket "${props.name}" deleted from branch ${branchId}`);
225
230
  };
231
+ // Resolve the delimiter to send to the backend, mirroring `aws s3 ls`:
232
+ // - default (neither flag): "/" so the listing is folder-collapsed;
233
+ // - --recursive: no delimiter, so every nested key is returned flat;
234
+ // - explicit --delimiter <x>: that value (an empty string lists flat too).
235
+ // `--recursive` together with an explicit `--delimiter` is nonsensical and is
236
+ // rejected client-side before any HTTP request is made.
237
+ export const resolveListDelimiter = (props) => {
238
+ if (props.recursive && props.delimiter !== undefined) {
239
+ throw new Error('--recursive and --delimiter cannot be used together. Use --recursive for a flat listing, or --delimiter to collapse on a separator.');
240
+ }
241
+ if (props.recursive) {
242
+ return undefined;
243
+ }
244
+ if (props.delimiter !== undefined) {
245
+ return props.delimiter;
246
+ }
247
+ return '/';
248
+ };
226
249
  const listObjects = async (props) => {
250
+ const delimiter = resolveListDelimiter(props);
227
251
  const branchId = await branchIdFromProps(props);
228
252
  const { bucket, rest } = splitBucketTarget(props.target);
229
253
  const { data } = await listProjectBranchBucketObjects(props.apiClient, {
@@ -231,7 +255,7 @@ const listObjects = async (props) => {
231
255
  branchId,
232
256
  bucketName: bucket,
233
257
  prefix: rest === '' ? undefined : rest,
234
- delimiter: props.delimiter,
258
+ delimiter,
235
259
  cursor: props.cursor,
236
260
  limit: props.limit,
237
261
  });
@@ -1,6 +1,7 @@
1
1
  import { isAxiosError } from 'axios';
2
+ import chalk from 'chalk';
2
3
  import prompts from 'prompts';
3
- import { applyContext, readContextFile } from '../context.js';
4
+ import { applyContext, contextBranch, readContextFile } from '../context.js';
4
5
  import { isCi } from '../env.js';
5
6
  import { log } from '../log.js';
6
7
  import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
@@ -47,6 +48,13 @@ export const builder = (argv) => argv
47
48
  ],
48
49
  ]);
49
50
  export const handler = async (props) => {
51
+ // Show where the context is pinned *before* we switch it, so the user sees the move
52
+ // ("currently on X" → "checked out Y") and can catch a checkout they didn't mean to make.
53
+ // Read straight from `.neon` (a name, no API call); silent when nothing is pinned yet.
54
+ const previousBranch = contextBranch(readContextFile(props.contextFile));
55
+ if (previousBranch) {
56
+ log.info('%s Currently on branch %s', chalk.dim('→'), chalk.cyan.bold(previousBranch));
57
+ }
50
58
  // Branch listing is project-scoped, so `projectId` is the only thing
51
59
  // `checkout` actually needs. Resolve it through the standard chain
52
60
  // (--project-id flag > .neon file > single-project auto-detect); when
@@ -1,9 +1,12 @@
1
+ import chalk from 'chalk';
1
2
  import { resolveConfig } from '@neondatabase/config';
2
3
  import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
3
4
  import { toNeonConfigView } from '../config_format.js';
4
5
  import { log } from '../log.js';
6
+ import { isCi } from '../env.js';
5
7
  import { loadEnvFileIntoProcess } from '../env_file.js';
6
- import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
8
+ import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
9
+ import { announceTargetBranch } from '../utils/branch_notice.js';
7
10
  import { bundleEntry } from '../utils/esbuild.js';
8
11
  import { zipBundle } from '../utils/zip.js';
9
12
  import { writer } from '../writer.js';
@@ -16,8 +19,12 @@ import { autoPullEnvAfterPin } from './env.js';
16
19
  */
17
20
  const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
18
21
  const INSPECT_FIELDS = ['project', 'branch', 'config'];
19
- const APPLIED_FIELDS = ['action', 'kind', 'identifier', 'details'];
20
- const FUNCTION_FIELDS = ['slug', 'invocation_url'];
22
+ // Deliberately minimal: action/kind/identifier are short and fixed-ish, so the table can
23
+ // never overflow. Per-change `details` (a function's long invocationUrl in particular) are
24
+ // intentionally NOT a column — they used to be JSON-stringified into a cell and blew the
25
+ // table past 190 cols. Function URLs are printed below as a plain list (see reportPushResult),
26
+ // and the full details are still available via `--output json`.
27
+ const APPLIED_FIELDS = ['action', 'kind', 'identifier'];
21
28
  const CONFLICT_FIELDS = [
22
29
  'identifier',
23
30
  'field',
@@ -119,7 +126,13 @@ const loadConfig = async (props) => {
119
126
  return config;
120
127
  };
121
128
  export const status = async (props) => {
122
- const branchId = await branchIdFromProps(props);
129
+ const branch = await resolveBranchRef(props);
130
+ // `--config-json` is a script-friendly mode that emits only JSON to stdout, so keep it
131
+ // pristine; the regular human view gets the "which branch am I inspecting" guardrail.
132
+ if (!props.configJson) {
133
+ announceTargetBranch(props, branch, 'Inspecting branch');
134
+ }
135
+ const branchId = branch.branchId;
123
136
  const live = await inspect({
124
137
  projectId: props.projectId,
125
138
  branchId,
@@ -152,7 +165,9 @@ export const status = async (props) => {
152
165
  };
153
166
  export const planCmd = async (props) => {
154
167
  const config = await loadConfig(props);
155
- const branchId = await branchIdFromProps(props);
168
+ const branch = await resolveBranchRef(props);
169
+ announceTargetBranch(props, branch, 'Planning against branch');
170
+ const branchId = branch.branchId;
156
171
  // `plan` is a dry run that never bundles, so its options don't accept (or need)
157
172
  // an injected bundler — only `apply` does (it uses neonctlBundler).
158
173
  const result = await plan(config, {
@@ -166,7 +181,9 @@ export const planCmd = async (props) => {
166
181
  };
167
182
  export const applyCmd = async (props) => {
168
183
  const config = await loadConfig(props);
169
- const branchId = await branchIdFromProps(props);
184
+ const branch = await resolveBranchRef(props);
185
+ announceTargetBranch(props, branch, 'Applying to branch');
186
+ const branchId = branch.branchId;
170
187
  const result = await apply(config, {
171
188
  projectId: props.projectId,
172
189
  branchId,
@@ -241,7 +258,6 @@ const reportPushResult = (props, result, mode, services) => {
241
258
  action: change.action,
242
259
  kind: change.kind,
243
260
  identifier: change.identifier,
244
- details: change.details ? JSON.stringify(change.details) : '',
245
261
  }));
246
262
  const conflicts = result.conflicts.map((conflict) => ({
247
263
  identifier: conflict.identifier,
@@ -250,9 +266,9 @@ const reportPushResult = (props, result, mode, services) => {
250
266
  desired: stringify(conflict.desired),
251
267
  reason: conflict.reason,
252
268
  }));
253
- // Deployed functions carry their invocation URL in the change details — pull them into a
254
- // dedicated table so users can see where to call each function without digging through the
255
- // raw details blob. Keyed by slug so a function never shows twice.
269
+ // Deployed functions carry their invocation URL in the change details — collect them so
270
+ // we can list where to call each function without digging through the raw details blob.
271
+ // Keyed by slug so a function never shows twice.
256
272
  const functionUrlBySlug = new Map();
257
273
  for (const change of result.applied) {
258
274
  if (change.action === 'noop')
@@ -263,10 +279,6 @@ const reportPushResult = (props, result, mode, services) => {
263
279
  functionUrlBySlug.set(slug, invocationUrl);
264
280
  }
265
281
  }
266
- const functions = [...functionUrlBySlug].map(([slug, invocation_url]) => ({
267
- slug,
268
- invocation_url,
269
- }));
270
282
  const out = writer(props);
271
283
  const noChanges = changes.length === 0 && conflicts.length === 0;
272
284
  if (changes.length > 0) {
@@ -275,17 +287,21 @@ const reportPushResult = (props, result, mode, services) => {
275
287
  title: mode === 'plan' ? 'Planned changes' : 'Applied changes',
276
288
  });
277
289
  }
278
- if (functions.length > 0) {
279
- out.write(functions, {
280
- fields: FUNCTION_FIELDS,
281
- title: mode === 'plan' ? 'Function URLs (after apply)' : 'Function URLs',
282
- });
283
- }
284
290
  if (conflicts.length > 0) {
285
291
  out.write(conflicts, { fields: CONFLICT_FIELDS, title: 'Conflicts' });
286
292
  }
287
- // Flush any tables, then append the summary so it reads directly below them.
293
+ // Flush any tables, then append the lists/summary so they read directly below them.
288
294
  out.end();
295
+ // Function URLs are a plain list rather than a table: an invocation URL can be 70+ chars,
296
+ // which makes any bordered table overflow and wrap awkwardly in a normal terminal. A list
297
+ // lets each URL reflow on its own line, and stays copy-pasteable.
298
+ if (functionUrlBySlug.size > 0) {
299
+ const heading = mode === 'plan' ? 'Function URLs (after apply)' : 'Function URLs';
300
+ out.text(`\n${isCi() ? heading : chalk.bold(heading)}\n`);
301
+ for (const [slug, invocationUrl] of functionUrlBySlug) {
302
+ out.text(` • ${slug}: ${invocationUrl}\n`);
303
+ }
304
+ }
289
305
  if (noChanges) {
290
306
  log.info(`No changes — branch ${result.branchName} already matches the policy.`);
291
307
  }
package/commands/dev.js CHANGED
@@ -480,9 +480,8 @@ const spawnChild = (unit, runtimePath, bundlePath) => {
480
480
  const writeBundle = async (source, bundleDir) => {
481
481
  const files = await bundleEntry(source);
482
482
  mkdirSync(bundleDir, { recursive: true });
483
- // bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
484
- // it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
485
- // points the sourcemap link at `index.mjs.map` for us.
483
+ // bundleEntry emits a single `index.mjs` (no source map). The `.mjs` extension makes Node
484
+ // load it as ESM directly, so no `package.json` `"type": "module"` marker is needed.
486
485
  for (const [name, contents] of Object.entries(files)) {
487
486
  writeFileSync(join(bundleDir, name), contents);
488
487
  }
package/commands/env.js CHANGED
@@ -4,7 +4,8 @@ import { existsSync } from 'node:fs';
4
4
  import { log } from '../log.js';
5
5
  import { resolveNeonEnvVars } from '../dev/env.js';
6
6
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from '../env_file.js';
7
- import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
+ import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
8
+ import { announceTargetBranch } from '../utils/branch_notice.js';
8
9
  export const command = 'env';
9
10
  export const describe = "Manage a branch's Neon env variables locally";
10
11
  /**
@@ -33,15 +34,45 @@ export const builder = (argv) => argv
33
34
  })
34
35
  .example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
35
36
  .example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), async (args) => {
36
- await pull(args);
37
+ // Explicit `env pull` announces the branch it's reading from up front so the user
38
+ // can catch "pulled env from the wrong branch" before it overwrites their .env. The
39
+ // bundled auto-pull (link / checkout / apply) stays quiet — those already report the
40
+ // branch they pinned/applied to.
41
+ await pull(args, { announce: true });
37
42
  })
38
43
  .demandCommand(1);
39
44
  export const handler = (args) => args;
40
45
  /** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
41
46
  const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Object.values(group));
42
- export const pull = async (props) => {
47
+ /**
48
+ * The Neon env vars `env pull` *owns*, so it removes any that the branch no longer has when
49
+ * it reconciles the local `.env` (see {@link pull}). Scoped to the unambiguously Neon-named
50
+ * vars — the `NEON_*` aliases plus `DATABASE_URL[_UNPOOLED]` — so switching a working
51
+ * directory to a project/branch without Auth / the Data API drops the now-stale
52
+ * `NEON_AUTH_*` / `NEON_DATA_API_*` lines instead of leaving credentials for features that
53
+ * aren't enabled.
54
+ *
55
+ * Deliberately **excludes** the storage / AI Gateway vars Neon projects onto third-party SDK
56
+ * names (`AWS_*`, `OPENAI_*`): those collide with credentials a user may set by hand, so
57
+ * `env pull` only ever writes them, never prunes them. (Their Neon-branded siblings —
58
+ * `NEON_STORAGE_*` / `NEON_AI_GATEWAY_*` — are owned and pruned.)
59
+ */
60
+ const NEON_OWNED_ENV_KEYS = [
61
+ ...Object.values(NEON_ENV_VAR_KEYS.postgres),
62
+ ...Object.values(NEON_ENV_VAR_KEYS.auth),
63
+ ...Object.values(NEON_ENV_VAR_KEYS.dataApi),
64
+ NEON_ENV_VAR_KEYS.storage.regionNeon,
65
+ NEON_ENV_VAR_KEYS.storage.forcePathStyle,
66
+ NEON_ENV_VAR_KEYS.aiGateway.neonToken,
67
+ NEON_ENV_VAR_KEYS.aiGateway.neonBaseUrl,
68
+ ];
69
+ export const pull = async (props, opts = {}) => {
43
70
  const cwd = props.cwd ?? process.cwd();
44
- const branchId = await branchIdFromProps(props);
71
+ const branch = await resolveBranchRef(props);
72
+ if (opts.announce) {
73
+ announceTargetBranch(props, branch, 'Pulling env from branch');
74
+ }
75
+ const branchId = branch.branchId;
45
76
  // Resolve the target file first and layer its current contents under the resolver's env
46
77
  // source. This lets `fetchEnv` reuse one-time secrets that are already on disk — Neon Auth
47
78
  // keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
@@ -66,8 +97,16 @@ export const pull = async (props) => {
66
97
  'enabled Auth / Data API).');
67
98
  return { status: 'empty' };
68
99
  }
69
- const { written } = mergeEnvFile(targetPath, neonVars);
100
+ // Reconcile rather than blindly merge: write the branch's current Neon vars and prune any
101
+ // Neon-owned vars the branch no longer has (e.g. NEON_AUTH_* / NEON_DATA_API_* carried over
102
+ // from a previous project/branch). Non-Neon lines are always preserved.
103
+ const { written, removed } = mergeEnvFile(targetPath, neonVars, {
104
+ managedKeys: NEON_OWNED_ENV_KEYS,
105
+ });
70
106
  log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
107
+ if (removed.length > 0) {
108
+ log.info('Removed %d stale Neon variable%s not enabled on this branch: %s', removed.length, removed.length === 1 ? '' : 's', removed.join(', '));
109
+ }
71
110
  return { status: 'written', written, file: targetPath };
72
111
  };
73
112
  /**
@@ -14,6 +14,16 @@ const FUNCTION_FIELDS = [
14
14
  'invocation_url',
15
15
  'created_at',
16
16
  ];
17
+ const FUNCTIONS_LIST_LIMIT = 100;
18
+ // Table columns for `functions list`. `status` is a derived field (the
19
+ // table writer reads flat fields only): the current deployment's status.
20
+ const LIST_TABLE_FIELDS = [
21
+ 'slug',
22
+ 'name',
23
+ 'status',
24
+ 'invocation_url',
25
+ 'created_at',
26
+ ];
17
27
  const DEPLOYMENT_FIELDS = [
18
28
  'id',
19
29
  'status',
@@ -45,14 +55,14 @@ const ENTRY_CANDIDATES = ['index.ts', 'index.mjs', 'index.js'];
45
55
  // Overridable so tests can poll fast; defaults to 2s in real use.
46
56
  const POLL_INTERVAL_MS = Number(process.env.NEON_FUNCTIONS_POLL_INTERVAL_MS) || 2000;
47
57
  // Upper bound on --wait polling so the CLI never hangs (e.g. if our deployment
48
- // never becomes active_deployment). Overridable so tests can time out fast;
58
+ // never shows up as current_deployment). Overridable so tests can time out fast;
49
59
  // defaults to 10 minutes in real use.
50
60
  const POLL_TIMEOUT_MS = Number(process.env.NEON_FUNCTIONS_POLL_TIMEOUT_MS) || 600000;
51
- export const command = 'functions';
61
+ export const command = 'function';
52
62
  export const describe = 'Manage Neon Functions';
53
- export const aliases = ['function'];
63
+ export const aliases = ['functions'];
54
64
  export const builder = (argv) => argv
55
- .usage('$0 functions <sub-command> [options]')
65
+ .usage('$0 function <sub-command> [options]')
56
66
  .options({
57
67
  'project-id': {
58
68
  describe: 'Project ID',
@@ -137,7 +147,7 @@ const parseEnv = (entries) => {
137
147
  }
138
148
  return JSON.stringify(map);
139
149
  };
140
- const statusHint = (slug, projectId, branchId) => `Check status with: neonctl functions get ${slug} --project-id ${projectId} --branch ${branchId}`;
150
+ const statusHint = (slug, projectId, branchId) => `Check status with: neonctl function get ${slug} --project-id ${projectId} --branch ${branchId}`;
141
151
  // Emit the resolved deployment together with the function's invocation_url, so the
142
152
  // deploy output shows where the function is reachable (not just the deployment id).
143
153
  const emitDeployResult = (props, deployment, fn) => {
@@ -165,7 +175,7 @@ const deploy = async (props) => {
165
175
  props.runtime !== undefined;
166
176
  if (!hasOption) {
167
177
  throw new Error('Provide at least one option to deploy, e.g. --src or --env. ' +
168
- 'See: neonctl functions deploy --help.');
178
+ 'See: neonctl function deploy --help.');
169
179
  }
170
180
  // Cheap, offline validation first - fail before any network round-trip.
171
181
  if (!SLUG_PATTERN.test(props.slug)) {
@@ -188,12 +198,12 @@ const deploy = async (props) => {
188
198
  // Bundle before any network round-trip so a bundling failure fails fast.
189
199
  const zip = zipBundle(await bundleEntry(source));
190
200
  const branchId = await branchIdFromProps(props);
191
- // Snapshot the active version before deploy so we can detect the new one
192
- // afterward. A missing function (404) or no active version → undefined.
201
+ // Snapshot the current version before deploy so we can detect the new one
202
+ // afterward. A missing function (404) or no deployment yet → undefined.
193
203
  let before;
194
204
  try {
195
205
  const fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
196
- before = fn.active_deployment?.id;
206
+ before = fn.current_deployment?.id;
197
207
  }
198
208
  catch (err) {
199
209
  if (!(isAxiosError(err) && err.response?.status === 404))
@@ -213,12 +223,12 @@ const deploy = async (props) => {
213
223
  };
214
224
  process.once('SIGINT', onSignal);
215
225
  process.once('SIGTERM', onSignal);
216
- // Poll until a NEW active version appears (id greater than the snapshot, or
226
+ // Poll until a NEW version appears (id greater than the snapshot, or
217
227
  // any version if there was none). --no-wait stops there; --wait stops at a
218
228
  // terminal status. Bounded by POLL_TIMEOUT_MS so it never hangs.
219
229
  let resolved;
220
230
  // The function carries the invocation_url; keep the whole record (not just its
221
- // active_deployment) so we can surface that URL on success.
231
+ // current_deployment) so we can surface that URL on success.
222
232
  let resolvedFn;
223
233
  const deadline = Date.now() + POLL_TIMEOUT_MS;
224
234
  try {
@@ -237,7 +247,7 @@ const deploy = async (props) => {
237
247
  continue;
238
248
  throw err;
239
249
  }
240
- const dep = fn.active_deployment;
250
+ const dep = fn.current_deployment;
241
251
  const isNew = dep !== undefined && (before === undefined || dep.id > before);
242
252
  if (isNew && dep) {
243
253
  resolved = dep;
@@ -290,12 +300,31 @@ const get = async (props) => {
290
300
  fields: FUNCTION_FIELDS,
291
301
  title: 'function',
292
302
  });
293
- if (fn.active_deployment) {
294
- out.write(fn.active_deployment, {
303
+ const current = fn.current_deployment;
304
+ const active = fn.active_deployment;
305
+ if (current && active && current.id === active.id) {
306
+ out.write(current, {
295
307
  fields: DEPLOYMENT_FIELDS,
296
- title: 'active deployment',
308
+ title: 'deployment (current, active)',
297
309
  });
298
- writeDeploymentErrorSection(out, fn.active_deployment);
310
+ writeDeploymentErrorSection(out, current);
311
+ }
312
+ else {
313
+ if (current) {
314
+ out.write(current, {
315
+ fields: DEPLOYMENT_FIELDS,
316
+ title: 'current deployment',
317
+ });
318
+ // The failure reason is shown only for the current deployment;
319
+ // the active one completed successfully by definition.
320
+ writeDeploymentErrorSection(out, current);
321
+ }
322
+ if (active) {
323
+ out.write(active, {
324
+ fields: DEPLOYMENT_FIELDS,
325
+ title: 'active deployment',
326
+ });
327
+ }
299
328
  }
300
329
  if (props.listEnvVariables) {
301
330
  out.write((fn.active_deployment?.environment ?? []).map((name) => ({ name })), {
@@ -321,13 +350,27 @@ const deleteFn = async (props) => {
321
350
  };
322
351
  const list = async (props) => {
323
352
  const branchId = await branchIdFromProps(props);
324
- const functions = await listFunctions(props.apiClient, props.projectId, branchId);
353
+ const functions = [];
354
+ let cursor;
355
+ for (;;) {
356
+ const page = await listFunctions(props.apiClient, props.projectId, branchId, { cursor, limit: FUNCTIONS_LIST_LIMIT });
357
+ functions.push(...page.functions);
358
+ log.debug('Got %d functions, next cursor: %s', page.functions.length, page.next);
359
+ // A server echoing the same cursor would loop forever; treat it as
360
+ // the end of the list.
361
+ if (!page.next || page.next === cursor)
362
+ break;
363
+ cursor = page.next;
364
+ }
325
365
  if (props.output === 'json' || props.output === 'yaml') {
326
366
  writer(props).end(functions, { fields: FUNCTION_FIELDS });
327
367
  return;
328
368
  }
329
- writer(props).end(functions, {
330
- fields: FUNCTION_FIELDS,
369
+ writer(props).end(functions.map((fn) => ({
370
+ ...fn,
371
+ status: fn.current_deployment?.status ?? '',
372
+ })), {
373
+ fields: LIST_TABLE_FIELDS,
331
374
  emptyMessage: 'No functions found on this branch.',
332
375
  });
333
376
  };
package/commands/init.js CHANGED
@@ -1,4 +1,4 @@
1
- import { interactiveInit, orchestrate } from 'neon-init';
1
+ import { detectAgent, enrichResponse, interactiveInit, orchestrate, routeDataStep, } from 'neon-init';
2
2
  import { sendError } from '../analytics.js';
3
3
  import { log } from '../log.js';
4
4
  export const command = 'init';
@@ -11,6 +11,10 @@ export const builder = (yargs) => yargs
11
11
  alias: 'a',
12
12
  type: 'string',
13
13
  describe: 'Agent to configure (cursor, copilot, claude, etc.).',
14
+ })
15
+ .option('data', {
16
+ type: 'string',
17
+ describe: 'JSON object with a "step" field to route to a specific phase and phase-specific options.',
14
18
  })
15
19
  .option('skip-neon-auth', {
16
20
  type: 'boolean',
@@ -30,14 +34,36 @@ export const builder = (yargs) => yargs
30
34
  .strict(false);
31
35
  export const handler = async (argv) => {
32
36
  try {
33
- if (argv.agent !== undefined) {
37
+ // Auto-detect agent from environment if --agent not explicitly provided.
38
+ // For IDE-based detection (Cursor, VS Code, Windsurf), require non-TTY stdin
39
+ // to distinguish "agent spawned this" from "human typed this in terminal".
40
+ const agent = argv.agent || (!process.stdin.isTTY ? detectAgent() : null) || undefined;
41
+ const isAgentMode = agent !== undefined;
42
+ // --data with a "step" field routes to the appropriate phase
43
+ if (argv.data && isAgentMode) {
44
+ let data;
45
+ try {
46
+ data = JSON.parse(argv.data);
47
+ }
48
+ catch {
49
+ log.error('Invalid JSON in --data flag. Expected a JSON object.');
50
+ process.exit(1);
51
+ return;
52
+ }
53
+ if (typeof data.step === 'string') {
54
+ const result = await routeDataStep(data, agent);
55
+ log.info(JSON.stringify(enrichResponse(result), null, 2));
56
+ return;
57
+ }
58
+ }
59
+ if (isAgentMode) {
34
60
  const result = await orchestrate({
35
- agent: argv.agent || undefined,
61
+ agent,
36
62
  skipNeonAuth: argv.skipNeonAuth,
37
63
  skipMigrations: argv.skipMigrations,
38
64
  preview: argv.preview,
39
65
  });
40
- log.info(JSON.stringify(result, null, 2));
66
+ log.info(JSON.stringify(enrichResponse(result), null, 2));
41
67
  }
42
68
  else {
43
69
  await interactiveInit({ preview: argv.preview });