neon 4.5.2 → 4.7.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/README.md CHANGED
@@ -256,14 +256,14 @@ The Neon CLI supports autocompletion, which you can configure in a few easy step
256
256
  `link` resolves what it can and **verifies every identifier you pass** before writing, so a `.neon` is never left half-written or pointing at something that doesn't exist:
257
257
 
258
258
  - **org** is inferred from the project (so `--project-id` alone is enough); it's omitted only when the project has no organization (personal account).
259
- - **project** is taken from `--project-id` (or chosen interactively / via `--agent`).
259
+ - **project** is taken from `--project-id` (or chosen interactively).
260
260
  - **branch** is left to an explicit [`neon checkout <branch>`](#checkout) — `link` never silently pins a project's default branch (that would make later commands quietly target, say, production). It only records a branch when you pass `--branch`, when one is already pinned for the same project (preserved), when you pick one in the interactive picker, or for a freshly **created** project (whose single branch is unambiguous).
261
261
 
262
262
  When a branch ends up pinned, `link` also runs [`env pull`](#env-pull) so the branch's Neon env vars (`DATABASE_URL`, …) land in a local `.env`. With no branch pinned there is nothing to pull, so `link` instead nudges you to run `neon checkout`. Pass `--no-env-pull` to skip the pull (for example when injecting env at runtime with `neon-env run` or `neon dev`).
263
263
 
264
264
  > **Migrating from `set-context`?** `set-context` is **deprecated** in favor of `link` (see [below](#set-context-is-deprecated)). It still works exactly as before for now (a raw write), it just prints a deprecation warning. The `.neon` `branchId` field is also superseded by `branch` (which stores the branch **name** when known); old `branchId` files are still read and are upgraded to `branch` the next time `link`/`checkout` writes the context.
265
265
 
266
- There are three modes:
266
+ There are two modes:
267
267
 
268
268
  **Interactive (default)** — guided prompts for humans:
269
269
 
@@ -322,64 +322,21 @@ neon link --no-checks --org-id org-abc123 --project-id polished-snowflake-123456
322
322
 
323
323
  Every supplied identifier is checked before anything is written, with actionable errors — e.g. `Project '…' not found`, `You don't have access to project '…'`, `Organization '…' not found, or your API key doesn't have access to it`, `Project '…' belongs to organization 'A', not 'B'`, or `Branch '…' not found in project '…'. Available branches: …`.
324
324
 
325
- **Agent mode (`--agent`)** — a JSON state machine designed for AI coding assistants. Each invocation returns a single JSON object with a `status` discriminator describing the next step, the available options, and the exact follow-up command to run.
325
+ **Agents and scripts (no TTY):** List, then link. `neon link --help` prints the same recipe.
326
326
 
327
327
  ```bash
328
- $ neon link --agent
329
- {
330
- "status": "needs_org",
331
- "instruction": "Ask the user which of these 2 organizations they want to link the current directory to. After they pick one, re-run the next_command_template with the chosen --org-id value.",
332
- "options": [
333
- { "id": "org-abc123", "name": "Personal Org" },
334
- { "id": "org-team", "name": "Team Org" }
335
- ],
336
- "next_command_template": "neon link --agent --org-id <org_id>"
337
- }
338
-
339
- $ neon link --agent --org-id org-abc123
340
- {
341
- "status": "needs_project",
342
- "instruction": "Ask the user whether to link to one of these 1 existing projects (use next_command_template with --project-id) or create a new project (use create_option.next_command_template).",
343
- "options": [
344
- { "id": "polished-snowflake-12345678", "name": "my-app" }
345
- ],
346
- "create_option": {
347
- "instruction": "To create a new project, ask the user for a project name. The region can be omitted to receive a follow-up needs_project_details response that lists available regions.",
348
- "next_command_template": "neon link --agent --org-id org-abc123 --project-name <name> --region-id <region_id>"
349
- },
350
- "next_command_template": "neon link --agent --org-id org-abc123 --project-id <project_id>"
351
- }
352
-
353
- $ neon link --agent --org-id org-abc123 --project-id polished-snowflake-12345678
354
- {
355
- "status": "linked",
356
- "context_file": "/path/to/cwd/.neon",
357
- "context": {
358
- "orgId": "org-abc123",
359
- "projectId": "polished-snowflake-12345678"
360
- },
361
- "project": { "id": "polished-snowflake-12345678" },
362
- "message": "Linked /path/to/cwd/.neon to project polished-snowflake-12345678 (org org-abc123). No branch pinned — run `neon checkout <branch>` (omit the branch to list options) to pin one and pull its env vars."
363
- }
328
+ neon orgs list --output json
329
+ neon projects list --org-id <org-id> --output json
330
+ neon link --project-id <project-id>
331
+ neon link --org-id <org-id> --project-name <name> --region-id aws-us-east-2
364
332
  ```
365
333
 
366
- The `linked` response omits `branch` unless one was pinned (via `--branch`, an existing pin, or project creation); pass `--branch <name|id>` to include it. The agent flow also handles project creation: if the agent sends `--project-name` without `--region-id`, the next response is `needs_project_details` with the list of supported regions.
367
-
368
- **Organization-scoped API keys** (those created at the organization level rather than the user level) cannot list user organizations or call the regions endpoint. `link` handles this transparently:
369
-
370
- - If the API key is org-scoped and at least one project already exists in the org, the CLI auto-detects the `org_id` from the first project. In interactive mode it prints an informational message; in `--agent` mode it skips straight to `needs_project`.
371
- - If the API key is org-scoped and no projects exist yet, `--agent` returns a `needs_org` response with `options: []` and an instruction telling the user to find their org ID in the Neon Console. Interactive mode prints an error pointing to `--org-id`.
372
- - When the regions endpoint is not allowed, `link` falls back to a built-in static region list.
334
+ Organization-scoped API keys cannot list user organizations (`orgs list`) or call the regions endpoint:
373
335
 
374
- **Agent error contract**: any unexpected failure in `--agent` mode is reported as JSON to stdout with exit code 1, so agents can always parse the response:
375
-
376
- ```json
377
- {
378
- "status": "error",
379
- "code": "CLIENT_ERROR",
380
- "message": "user has no access to projects"
381
- }
382
- ```
336
+ - Pass `--org-id` (Neon Console → Settings) or `--project-id` (org is inferred from the project).
337
+ - If the key is org-scoped and at least one project already exists, interactive `link` auto-detects the org from the first project and prints an informational message.
338
+ - If no projects exist yet, interactive `link` errors pointing at `--org-id`.
339
+ - When the regions endpoint is not allowed, interactive create falls back to a built-in static region list. Non-interactive create already requires `--region-id`.
383
340
 
384
341
  **Offline writes (`--no-checks`)** — write the `.neon` with no API calls at all: no org inference, no existence/access verification, no env pull. Because nothing can be resolved offline, it requires both `--org-id` and `--project-id` (`--branch` optional, stored verbatim). Handy for scripted/CI setups or re-creating a `.neon` from values you already trust:
385
342
 
@@ -710,6 +667,12 @@ $ neon bootstrap my-app
710
667
 
711
668
  # Scaffold a specific template into the current directory (no prompts)
712
669
  $ neon bootstrap . --template hono
670
+
671
+ # List templates
672
+ $ neon bootstrap --list-templates
673
+
674
+ # Machine-readable catalog
675
+ $ neon bootstrap --list-templates --output json
713
676
  ```
714
677
 
715
678
  The target directory must be empty unless you pass `--force` (a lone `.git` is ignored, so a freshly `git init`ed folder is fine). Symlinks and executable bits in the template are preserved.
@@ -3,8 +3,9 @@ import { isCi } from "../env.js";
3
3
  import { log } from "../log.js";
4
4
  import { getCliName } from "../utils/cli_name.js";
5
5
  import { t as credentialInputs } from "../_chunks/auth_selection-ktL6uoEe.js";
6
- import { BootstrapInputError, FALLBACK_TEMPLATES, ensureTargetUsable, fetchTemplates, findTemplate, scaffoldTemplate, templateIds } from "../init/bootstrap.js";
7
- import { DO_NOT_SUBSTITUTE_HINT, formatInstallCommand, inferPackageManager, installArgs, installedPackageManagers, resolvePackageManager, runCommand } from "../utils/package_manager.js";
6
+ import { writer } from "../writer.js";
7
+ import { FALLBACK_TEMPLATES, ensureTargetUsable, fetchTemplates, findTemplate, scaffoldTemplate, templateIds } from "../init/bootstrap.js";
8
+ import { formatInstallCommand, inferPackageManager, installArgs, installedPackageManagers, resolvePackageManager, runCommand } from "../utils/package_manager.js";
8
9
  import { existsSync } from "node:fs";
9
10
  import { join, relative, resolve } from "node:path";
10
11
  import chalk from "chalk";
@@ -16,6 +17,7 @@ var bootstrap_exports = /* @__PURE__ */ __exportAll({
16
17
  describe: () => describe,
17
18
  handler: () => handler
18
19
  });
20
+ const removedAgent = () => `\`${getCliName()} bootstrap --agent\` was removed. List templates with \`${getCliName()} bootstrap --list-templates --output json\`. Scaffold with \`${getCliName()} bootstrap <directory> --template <id>\` or \`${getCliName()} bootstrap <directory> --default\`.`;
19
21
  const command = "bootstrap [directory]";
20
22
  const describe = "Scaffold a new project from a Neon starter template";
21
23
  const builder = (argv) => argv.usage("$0 bootstrap [directory] [options]").positional("directory", {
@@ -28,7 +30,7 @@ const builder = (argv) => argv.usage("$0 bootstrap [directory] [options]").posit
28
30
  },
29
31
  "list-templates": {
30
32
  alias: ["list", "ls"],
31
- describe: "List available templates and exit.",
33
+ describe: "List available templates and exit. --output json and --output yaml print a machine-readable catalog.",
32
34
  type: "boolean",
33
35
  default: false
34
36
  },
@@ -38,9 +40,8 @@ const builder = (argv) => argv.usage("$0 bootstrap [directory] [options]").posit
38
40
  default: false
39
41
  },
40
42
  agent: {
41
- describe: "Emit a JSON state-machine response designed for AI agents instead of prompting. The output is a single JSON object with a discriminated `status` field describing the next step.",
42
- type: "boolean",
43
- default: false
43
+ hidden: true,
44
+ type: "boolean"
44
45
  },
45
46
  default: {
46
47
  alias: "y",
@@ -63,20 +64,33 @@ const builder = (argv) => argv.usage("$0 bootstrap [directory] [options]").posit
63
64
  type: "boolean",
64
65
  default: true
65
66
  }
66
- }).example("$0 bootstrap my-app", "Create ./my-app from an interactively chosen template").example("$0 bootstrap . --template hono", "Scaffold the Hono template into the current directory").example("$0 bootstrap my-app --default", "Quick start: scaffold the default template and run setup without prompting").example("$0 bootstrap my-app --template hono --agent", "Scaffold without prompting and emit the JSON state machine for AI agents").strict();
67
+ }).example("$0 bootstrap my-app", "Create ./my-app from an interactively chosen template").example("$0 bootstrap . --template hono", "Scaffold the Hono template into the current directory").example("$0 bootstrap my-app --default", "Quick start: scaffold the default template and run setup without prompting").example("$0 bootstrap --list-templates --output json", "Print the template catalog as JSON").check((argv) => {
68
+ if (argv.agent === true) throw new Error(removedAgent());
69
+ return true;
70
+ }).strict();
67
71
  const handler = async (props) => {
68
72
  if (props.listTemplates) {
69
73
  const templates = await fetchTemplates();
74
+ if (props.output === "json" || props.output === "yaml") {
75
+ writer(props).end(templates.map((t) => ({
76
+ id: t.id,
77
+ title: t.title,
78
+ description: t.description,
79
+ services: t.services ?? []
80
+ })), { fields: [
81
+ "id",
82
+ "title",
83
+ "description",
84
+ "services"
85
+ ] });
86
+ return;
87
+ }
70
88
  for (const t of templates) {
71
89
  const services = t.services && t.services.length > 0 ? ` [${t.services.join(" · ")}]` : "";
72
90
  process.stdout.write(`${t.id} — ${t.description}${services}\n`);
73
91
  }
74
92
  return;
75
93
  }
76
- if (props.agent) {
77
- await runAgentSafely(props);
78
- return;
79
- }
80
94
  const templates = await resolveTemplateList(props);
81
95
  const interactive = !props.default && Boolean(process.stdout.isTTY) && !isCi();
82
96
  const template = await resolveSelectedTemplate(props, interactive, templates);
@@ -163,15 +177,6 @@ const scaffold = async (template, targetDir) => {
163
177
  log.info("Scaffolded %d files into %s.", filesWritten, targetDir);
164
178
  return filesWritten;
165
179
  };
166
- /**
167
- * After a human scaffold, offer the things you almost always do next: install
168
- * dependencies, initialize a git repo, and link the directory to a Neon
169
- * project. In an interactive terminal each is a y/n prompt (skippable up front
170
- * with --no-install / --no-git / --no-link); `--default` runs install + git
171
- * without asking; otherwise we just print the manual steps so nothing runs
172
- * behind the user's back. Agent mode never reaches here — it returns these as
173
- * structured `next_steps` instead (see {@link runAgent}).
174
- */
175
180
  const runPostScaffoldSteps = async (props, targetDir, interactive) => {
176
181
  const inferred = inferPackageManager(targetDir);
177
182
  const defaultPm = resolvePackageManager(targetDir);
@@ -307,103 +312,6 @@ const printNextSteps = (targetDir, pm, opts) => {
307
312
  log.info(" See the README to run it.");
308
313
  log.info("");
309
314
  };
310
- const runAgentSafely = async (props) => {
311
- try {
312
- await runAgent(props);
313
- } catch (err) {
314
- emitAgent(toAgentError(err));
315
- process.exit(1);
316
- }
317
- };
318
- /**
319
- * The `--agent` flow: resolve what the flags determine and emit one JSON object
320
- * describing either the next input needed (`needs_template` / `needs_directory`)
321
- * or the terminal result (`scaffolded`). Unlike interactive mode it never
322
- * prompts and never runs install/git/link itself — those come back as structured
323
- * `next_steps` so the agent can confirm with the user and run them (the link
324
- * step chains into `neon link --agent`).
325
- */
326
- const runAgent = async (props) => {
327
- if (!props.template) {
328
- const templates = await fetchTemplates();
329
- emitAgent({
330
- status: "needs_template",
331
- instruction: `Ask the user which template to scaffold, then re-run the next_command_template with the chosen --template value${props.directory ? "" : " and a target directory"}.`,
332
- options: templates.map((template) => ({
333
- id: template.id,
334
- title: template.title,
335
- description: template.description,
336
- ...template.services ? { services: template.services } : {}
337
- })),
338
- next_command_template: `${getCliName()} bootstrap --agent ${props.directory ? shellArg(props.directory) : "<directory>"} --template <template_id>`
339
- });
340
- return;
341
- }
342
- const templates = await resolveTemplateList(props);
343
- const template = findTemplate(templates, props.template);
344
- if (!template) throw new BootstrapInputError(`Unknown template "${props.template}". Available templates: ${templateIds(templates)}.`, "UNKNOWN_TEMPLATE");
345
- if (props.directory === void 0) {
346
- emitAgent({
347
- status: "needs_directory",
348
- instruction: "Ask the user which directory to scaffold into (use \".\" for the current directory), then re-run the next_command_template with it.",
349
- next_command_template: `${getCliName()} bootstrap --agent <directory> --template ${shellArg(template.id)}`
350
- });
351
- return;
352
- }
353
- const targetDir = resolve(process.cwd(), props.directory === "." ? "" : props.directory);
354
- ensureTargetUsable(targetDir, props.force);
355
- const filesWritten = await scaffold(template, targetDir);
356
- const dir = displayDir(targetDir);
357
- const runIn = isCurrentDir(targetDir) ? "" : `cd ${shellArg(dir)} && `;
358
- const installPm = resolvePackageManager(targetDir);
359
- emitAgent({
360
- status: "scaffolded",
361
- directory: targetDir,
362
- template: {
363
- id: template.id,
364
- title: template.title
365
- },
366
- files_written: filesWritten,
367
- next_steps: [
368
- {
369
- action: "install_dependencies",
370
- instruction: `Ask the user whether to install dependencies, then run this in the project directory. ${DO_NOT_SUBSTITUTE_HINT}`,
371
- command: `${runIn}${formatInstallCommand(installPm)}`
372
- },
373
- {
374
- action: "initialize_git",
375
- instruction: "Ask the user whether to initialize a git repository in the project directory.",
376
- command: `${runIn}git init`
377
- },
378
- {
379
- action: "link_neon_project",
380
- instruction: "Ask the user whether to link the project to a Neon project now. This runs the link state machine — follow its JSON output for the next step.",
381
- command: `${runIn}${getCliName()} link --agent`
382
- }
383
- ],
384
- message: `Scaffolded "${template.title}" (${filesWritten} files) into ${dir}. Offer the next_steps to the user: install dependencies, initialize git, then link a Neon project.`
385
- });
386
- };
387
- const emitAgent = (response) => {
388
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
389
- };
390
- const toAgentError = (err) => {
391
- if (err instanceof BootstrapInputError) return {
392
- status: "error",
393
- code: err.agentCode,
394
- message: err.message
395
- };
396
- if (err instanceof Error) return {
397
- status: "error",
398
- code: "INTERNAL_ERROR",
399
- message: err.message
400
- };
401
- return {
402
- status: "error",
403
- code: "INTERNAL_ERROR",
404
- message: String(err)
405
- };
406
- };
407
315
  const isCurrentDir = (targetDir) => relative(process.cwd(), targetDir) === "";
408
316
  /**
409
317
  * The path to show the user: the bare relative path for the common
@@ -415,10 +323,6 @@ const displayDir = (targetDir) => {
415
323
  if (rel === "") return ".";
416
324
  return rel.startsWith("..") ? targetDir : rel;
417
325
  };
418
- const shellArg = (value) => {
419
- if (/^[A-Za-z0-9._:/-]+$/.test(value)) return value;
420
- return `'${value.replace(/'/g, `'\\''`)}'`;
421
- };
422
326
  const onPromptState = (state) => {
423
327
  if (state.aborted) {
424
328
  process.stdout.write("\x1B[?25h");
@@ -19,8 +19,7 @@ var env_exports = /* @__PURE__ */ __exportAll({
19
19
  command: () => "env",
20
20
  describe: () => describe,
21
21
  handler: () => handler,
22
- pull: () => pull,
23
- renderAgentPullNote: () => renderAgentPullNote
22
+ pull: () => pull
24
23
  });
25
24
  const command = "env";
26
25
  const describe = "Manage a branch's Neon env variables locally";
@@ -229,8 +228,7 @@ const pickSelectedVars = (vars, selectedKeys) => {
229
228
  * On by default; `--no-env-pull` opts out (e.g. when env is injected at runtime via
230
229
  * `neon-env run` / `neon dev`, or to keep secrets out of the working tree). The pin is the
231
230
  * command's primary effect and has already succeeded by the time this runs, so a pull failure
232
- * degrades to a warning rather than failing the command. Returns what happened so
233
- * `link --agent` can fold an accurate note into its JSON message.
231
+ * degrades to a warning rather than failing the command.
234
232
  */
235
233
  const autoPullEnvAfterPin = async (props) => {
236
234
  if (!props.envPull) {
@@ -250,21 +248,6 @@ Run \`${getCliName()} env pull\` once resolved (e.g. \`${getCliName()} deploy\`
250
248
  }
251
249
  };
252
250
  /**
253
- * Render the one-line env-pull note appended to `link --agent`'s JSON `message`, so an agent
254
- * reading the structured output knows whether its branch env is already on disk.
255
- */
256
- const renderAgentPullNote = (result) => {
257
- switch (result.status) {
258
- case "written": {
259
- const credential = result.credential?.issued ? ` Issued a new branch credential, so ${result.credential.keys.join(", ")} changed.` : "";
260
- return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? "" : "s"} into ${result.file}.${credential}`;
261
- }
262
- case "empty": return " No Neon env vars to pull for this branch yet.";
263
- case "skipped": return ` Skipped env pull (--no-env-pull); run \`${getCliName()} env pull\` later, or inject env at runtime with \`neon-env run -- <your dev command>\`.`;
264
- case "failed": return ` Could not pull env vars (${result.message}); run \`${getCliName()} env pull\` once resolved.`;
265
- }
266
- };
267
- /**
268
251
  * Keep only the recognized Neon variables from the resolved set, so a stray inherited
269
252
  * value never lands in the user's `.env` file. (Today `resolveNeonEnvVars` only emits Neon
270
253
  * vars, but filtering keeps the contract explicit and future-proof.)
@@ -278,4 +261,4 @@ const pickNeonVars = (vars) => {
278
261
  return out;
279
262
  };
280
263
  //#endregion
281
- export { ENV_PULL_SKIPPED_HINT, autoPullEnvAfterPin, builder, command, describe, handler, pull, renderAgentPullNote, env_exports as t };
264
+ export { ENV_PULL_SKIPPED_HINT, autoPullEnvAfterPin, builder, command, describe, handler, pull, env_exports as t };
@@ -5,8 +5,9 @@ import { isNeonApiError, messageFromBody } from "../api.js";
5
5
  import { applyContext, contextBranch, readContextFile, setContext, updateContextFile } from "../context.js";
6
6
  import { getCliName } from "../utils/cli_name.js";
7
7
  import { createBranch, pickBranchInteractively } from "../utils/branch_picker.js";
8
- import { autoPullEnvAfterPin, renderAgentPullNote } from "./env.js";
8
+ import { autoPullEnvAfterPin } from "./env.js";
9
9
  import { hasNeonConfigFile, initCmd } from "./config.js";
10
+ import { helpEpilogue } from "../utils/help_text.js";
10
11
  import { REGIONS } from "./projects.js";
11
12
  import prompts from "prompts";
12
13
  //#region src/commands/link.ts
@@ -18,6 +19,19 @@ var link_exports = /* @__PURE__ */ __exportAll({
18
19
  });
19
20
  const PROJECTS_LIST_LIMIT = 100;
20
21
  const CREATE_NEW_SENTINEL = "__create_new__";
22
+ const canPromptInteractively = () => !isCi() && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
23
+ const nonInteractiveLinkCommands = () => {
24
+ const cli = getCliName();
25
+ return [
26
+ `${cli} orgs list --output json`,
27
+ `${cli} projects list --org-id <org-id> --output json`,
28
+ `${cli} link --project-id <project-id>`,
29
+ `${cli} link --org-id <org-id> --project-name <name> --region-id aws-us-east-2`
30
+ ];
31
+ };
32
+ const nonInteractiveLinkHelp = () => nonInteractiveLinkCommands().map((command) => ` ${command}`).join("\n");
33
+ const orgScopedKeyHint = "Organization-scoped API keys cannot list orgs; pass --org-id.";
34
+ const removedAgent = () => `\`${getCliName()} link --agent\` was removed.\nUse:\n${nonInteractiveLinkHelp()}\n${orgScopedKeyHint}`;
21
35
  const command = "link";
22
36
  const describe = "Link the current directory to a Neon project";
23
37
  const builder = (argv) => argv.usage("$0 link [options]").options({
@@ -47,9 +61,8 @@ const builder = (argv) => argv.usage("$0 link [options]").options({
47
61
  type: "string"
48
62
  },
49
63
  agent: {
50
- describe: "Emit a JSON state-machine response designed for AI agents instead of prompting. The output is a single JSON object with a discriminated `status` field describing the next step.",
51
- type: "boolean",
52
- default: false
64
+ hidden: true,
65
+ type: "boolean"
53
66
  },
54
67
  yes: {
55
68
  alias: "y",
@@ -78,7 +91,10 @@ const builder = (argv) => argv.usage("$0 link [options]").options({
78
91
  ["$0 link --branch-id br-…", "Pin a branch in the already-linked project"],
79
92
  ["$0 link --no-checks --org-id org-… --project-id polished-snowflake-12345678", "Write the context offline (no API calls, no verification)"],
80
93
  ["$0 link --clear", "Forget the current org/project/branch context"]
81
- ]);
94
+ ]).epilogue(helpEpilogue("Non-interactive (CI, scripts, agents):", ...nonInteractiveLinkCommands().map((command) => ` ${command}`), orgScopedKeyHint)).check((argv) => {
95
+ if (argv.agent === true) throw new Error(removedAgent());
96
+ return true;
97
+ });
82
98
  const handler = async (props) => {
83
99
  if (props.clear) {
84
100
  clearContext(props.contextFile);
@@ -88,10 +104,6 @@ const handler = async (props) => {
88
104
  runWithoutChecks(props);
89
105
  return;
90
106
  }
91
- if (props.agent) {
92
- await runAgentSafely(props);
93
- return;
94
- }
95
107
  const inputs = parseInputs(props);
96
108
  validateInputs(inputs);
97
109
  const existing = readContextFile(props.contextFile);
@@ -99,14 +111,13 @@ const handler = async (props) => {
99
111
  await runNonInteractive(props, inputs, existing);
100
112
  return;
101
113
  }
102
- if (isCi()) {
114
+ if (!canPromptInteractively()) {
103
115
  log.error([
104
- "Missing inputs and CI environment detected (no TTY for prompts).",
116
+ "Missing inputs and no interactive terminal for prompts.",
105
117
  "",
106
- "Use one of:",
107
- ` ${getCliName()} link --agent (JSON state machine for agents)`,
108
- ` ${getCliName()} link --project-id <project> (link to an existing project; org is inferred)`,
109
- ` ${getCliName()} link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)`
118
+ "Use:",
119
+ nonInteractiveLinkHelp(),
120
+ orgScopedKeyHint
110
121
  ].join("\n"));
111
122
  process.exit(1);
112
123
  return;
@@ -195,20 +206,6 @@ const runWithoutChecks = (props) => {
195
206
  projectId: inputs.projectId,
196
207
  branch: inputs.branch
197
208
  });
198
- if (props.agent) {
199
- emitAgent({
200
- status: "linked",
201
- context_file: props.contextFile,
202
- context: {
203
- orgId: inputs.orgId,
204
- projectId: inputs.projectId,
205
- branch: inputs.branch
206
- },
207
- project: { id: inputs.projectId },
208
- message: `Wrote ${props.contextFile} without checks (org ${inputs.orgId}, project ${inputs.projectId}${inputs.branch ? `, branch ${inputs.branch}` : ""}). No verification or env pull was performed.`
209
- });
210
- return;
211
- }
212
209
  printSummary(props, {
213
210
  contextFile: props.contextFile,
214
211
  orgId: inputs.orgId,
@@ -218,25 +215,14 @@ const runWithoutChecks = (props) => {
218
215
  noChecks: true
219
216
  });
220
217
  };
221
- /**
222
- * A bad user-supplied identifier (project/org/branch that doesn't exist or
223
- * isn't accessible). Carries an `agentCode` so `--agent` mode can report a
224
- * precise `status: error` code instead of a generic INTERNAL_ERROR, while the
225
- * human path just prints the clear `message`.
226
- */
227
218
  var LinkInputError = class extends Error {
228
- constructor(message, agentCode) {
219
+ constructor(message) {
229
220
  super(message);
230
221
  this.name = "LinkInputError";
231
- this.agentCode = agentCode;
232
222
  }
233
223
  };
234
224
  const httpStatus = (err) => isNeonApiError(err) ? err.status : void 0;
235
- /**
236
- * Fetch a project, turning the common failure modes into clear, actionable
237
- * errors. 401 is rethrown so the global handler can refresh credentials;
238
- * everything else surfaces as a `LinkInputError` the user (or agent) can act on.
239
- */
225
+ /** 401 must reach the global handler so it can refresh credentials. */
240
226
  const fetchProjectOrThrow = async (props, projectId) => {
241
227
  try {
242
228
  const { data } = await props.apiClient.getProject(projectId);
@@ -244,8 +230,8 @@ const fetchProjectOrThrow = async (props, projectId) => {
244
230
  } catch (err) {
245
231
  const status = httpStatus(err);
246
232
  if (status === 401) throw err;
247
- if (status === 403) throw new LinkInputError(`You don't have access to project '${projectId}'. Check that your API key's account or organization can see it.`, "NO_ACCESS");
248
- if (status === 404) throw new LinkInputError(`Project '${projectId}' not found. Double-check the project ID — or that your API key has access to it.`, "NOT_FOUND");
233
+ if (status === 403) throw new LinkInputError(`You don't have access to project '${projectId}'. Check that your API key's account or organization can see it.`);
234
+ if (status === 404) throw new LinkInputError(`Project '${projectId}' not found. Double-check the project ID — or that your API key has access to it.`);
249
235
  throw err;
250
236
  }
251
237
  };
@@ -263,7 +249,7 @@ const verifyOrgAccess = async (props, orgId) => {
263
249
  } catch (err) {
264
250
  const status = httpStatus(err);
265
251
  if (status === 401) throw err;
266
- if (status === 403 || status === 404) throw new LinkInputError(`Organization '${orgId}' not found, or your API key doesn't have access to it. Find your org ID in the Neon Console under Settings.`, status === 403 ? "NO_ACCESS" : "NOT_FOUND");
252
+ if (status === 403 || status === 404) throw new LinkInputError(`Organization '${orgId}' not found, or your API key doesn't have access to it. Find your org ID in the Neon Console under Settings.`);
267
253
  throw err;
268
254
  }
269
255
  };
@@ -278,7 +264,7 @@ const resolveBranchRef = async (props, projectId, branchRef) => {
278
264
  const { data } = await props.apiClient.listProjectBranches({ projectId });
279
265
  const match = data.branches.find((b) => b.id === branchRef) ?? data.branches.find((b) => b.name === branchRef);
280
266
  if (match) return match;
281
- throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${data.branches.length > 0 ? data.branches.map((b) => `${b.id}${b.name ? ` (${b.name})` : ""}`).join(", ") : "(none)"}. Pin one with \`${getCliName()} checkout <branch>\`.`, "NOT_FOUND");
267
+ throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${data.branches.length > 0 ? data.branches.map((b) => `${b.id}${b.name ? ` (${b.name})` : ""}`).join(", ") : "(none)"}. Pin one with \`${getCliName()} checkout <branch>\`.`);
282
268
  };
283
269
  /**
284
270
  * The value to persist for a branch: prefer its human-readable **name** (nicer
@@ -301,7 +287,7 @@ const branchPersistValue = (branch) => branch.name ?? branch.id;
301
287
  const resolveOrgForProject = async (props, inputs, existing, projectId) => {
302
288
  const projectOrg = (await fetchProjectOrThrow(props, projectId)).org_id ?? void 0;
303
289
  if (inputs.orgId) {
304
- if (projectOrg && projectOrg !== inputs.orgId) throw new LinkInputError(`Project '${projectId}' belongs to organization '${projectOrg}', not '${inputs.orgId}'. Omit --org-id to use the project's own org, or pass the matching ID.`, "ORG_MISMATCH");
290
+ if (projectOrg && projectOrg !== inputs.orgId) throw new LinkInputError(`Project '${projectId}' belongs to organization '${projectOrg}', not '${inputs.orgId}'. Omit --org-id to use the project's own org, or pass the matching ID.`);
305
291
  if (!projectOrg) await verifyOrgAccess(props, inputs.orgId);
306
292
  return inputs.orgId;
307
293
  }
@@ -568,135 +554,6 @@ const promptRegion = async (props) => {
568
554
  });
569
555
  return regionId;
570
556
  };
571
- const runAgentSafely = async (props) => {
572
- try {
573
- const inputs = parseInputs(props);
574
- validateInputs(inputs);
575
- await runAgent(props, inputs);
576
- } catch (err) {
577
- emitAgent(toAgentError(err));
578
- process.exit(1);
579
- }
580
- };
581
- const runAgent = async (props, inputs) => {
582
- const { projectId, projectName, regionId, branch } = inputs;
583
- const existing = readContextFile(props.contextFile);
584
- if (projectId) {
585
- const orgId = await resolveOrgForProject(props, inputs, existing, projectId);
586
- const pinnedBranch = await resolvePinnedBranch(props, inputs, existing, projectId);
587
- applyContext(props.contextFile, {
588
- orgId,
589
- projectId,
590
- branch: pinnedBranch
591
- });
592
- const orgSuffix = orgId ? ` (org ${orgId})` : "";
593
- if (pinnedBranch) {
594
- const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
595
- ...props,
596
- projectId,
597
- branch: pinnedBranch,
598
- envPull: props.envPull
599
- }));
600
- emitAgent({
601
- status: "linked",
602
- context_file: props.contextFile,
603
- context: {
604
- orgId,
605
- projectId,
606
- branch: pinnedBranch
607
- },
608
- project: { id: projectId },
609
- message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix} on branch ${pinnedBranch}.${pullNote}`
610
- });
611
- return;
612
- }
613
- emitAgent({
614
- status: "linked",
615
- context_file: props.contextFile,
616
- context: {
617
- orgId,
618
- projectId
619
- },
620
- project: { id: projectId },
621
- message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix}. No branch pinned — run \`${getCliName()} checkout <branch>\` (omit the branch to list options) to pin one and pull its env vars.`
622
- });
623
- return;
624
- }
625
- const orgResolution = await resolveOrg(props, inputs.orgId);
626
- if (orgResolution.kind === "needs_selection") {
627
- emitAgent(buildNeedsOrgResponse(orgResolution));
628
- return;
629
- }
630
- const orgId = orgResolution.orgId;
631
- if (projectName && !regionId) {
632
- const regions = await fetchRegions(props);
633
- emitAgent({
634
- status: "needs_project_details",
635
- instruction: `Ask the user which region to create project "${projectName}" in. After they pick one, re-run the next_command_template with the chosen --region-id value.`,
636
- regions: regions.map((region) => ({
637
- id: region.region_id,
638
- name: region.name,
639
- default: region.default
640
- })),
641
- next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-name ${shellArg(projectName)} --region-id <region_id>`
642
- });
643
- return;
644
- }
645
- if (projectName && regionId) {
646
- await verifyOrgAccess(props, orgId);
647
- const created = await createProject(props, {
648
- orgId,
649
- name: projectName,
650
- regionId
651
- });
652
- applyContext(props.contextFile, {
653
- orgId,
654
- projectId: created.project.id,
655
- branch: created.branchName
656
- });
657
- const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
658
- ...props,
659
- projectId: created.project.id,
660
- branch: created.branchName,
661
- envPull: props.envPull
662
- }));
663
- emitAgent({
664
- status: "linked",
665
- context_file: props.contextFile,
666
- context: {
667
- orgId,
668
- projectId: created.project.id,
669
- branch: created.branchName
670
- },
671
- project: {
672
- id: created.project.id,
673
- name: created.project.name,
674
- region_id: created.project.region_id
675
- },
676
- message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile} on branch ${created.branchName}.${pullNote}`
677
- });
678
- return;
679
- }
680
- const projects = await listAllProjects(props, orgId);
681
- const branchNote = branch ? ` A branch was requested (--branch ${branch}) but a branch can only be pinned once a project is chosen — re-run with --project-id first, then \`${getCliName()} checkout ${branch}\`.` : "";
682
- emitAgent({
683
- status: "needs_project",
684
- instruction: (projects.length === 0 ? `Organization ${orgId} has no projects yet. Ask the user for a name for the new project, then re-run the create_option.next_command_template.` : `Ask the user whether to link to one of these ${projects.length} existing projects (use next_command_template with --project-id) or create a new project (use create_option.next_command_template).`) + branchNote,
685
- options: projects.map((project) => ({
686
- id: project.id,
687
- name: project.name,
688
- region_id: project.region_id
689
- })),
690
- create_option: {
691
- instruction: "To create a new project, ask the user for a project name. The region can be omitted to receive a follow-up needs_project_details response that lists available regions.",
692
- next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`
693
- },
694
- next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`
695
- });
696
- };
697
- const emitAgent = (response) => {
698
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
699
- };
700
557
  const ORG_KEY_LIMITED_FRAGMENT = "not allowed for organization API keys";
701
558
  const isOrgKeyLimitedError = (err) => {
702
559
  if (!isNeonApiError(err)) return false;
@@ -754,56 +611,6 @@ const detectOrgIdFromProjects = async (props) => {
754
611
  return;
755
612
  }
756
613
  };
757
- const buildNeedsOrgResponse = (resolution) => {
758
- if (resolution.orgKeyLimited) return {
759
- status: "needs_org",
760
- instruction: "This Neon API key is organization-scoped, so the CLI cannot list the user's organizations and no existing project was found to auto-detect the org ID. Ask the user for their Neon organization ID (visible in the Neon Console under the org's Settings page, formatted like `org-bitter-breeze-12345678`) and re-run the next_command_template with that --org-id.",
761
- options: [],
762
- next_command_template: `${getCliName()} link --agent --org-id <org_id>`
763
- };
764
- const orgs = resolution.orgs;
765
- return {
766
- status: "needs_org",
767
- instruction: orgs.length === 0 ? "The user does not belong to any organizations. Ask them to create one in the Neon Console (https://console.neon.tech/) before linking." : `Ask the user which of these ${orgs.length} organization${orgs.length === 1 ? "" : "s"} they want to link the current directory to. After they pick one, re-run the next_command_template with the chosen --org-id value.`,
768
- options: orgs.map((org) => ({
769
- id: org.id,
770
- name: org.name
771
- })),
772
- next_command_template: `${getCliName()} link --agent --org-id <org_id>`
773
- };
774
- };
775
- const toAgentError = (err) => {
776
- if (err instanceof LinkInputError) return {
777
- status: "error",
778
- code: err.agentCode,
779
- message: err.message
780
- };
781
- if (isNeonApiError(err)) {
782
- const status = err.status;
783
- const apiMessage = messageFromBody(err.data);
784
- const message = apiMessage !== void 0 && apiMessage.length > 0 ? apiMessage : err.message;
785
- let code = "API_ERROR";
786
- if (status === 401 || status === 403) code = "AUTH_ERROR";
787
- else if (status !== void 0 && status >= 400 && status < 500) code = "CLIENT_ERROR";
788
- else if (status !== void 0 && status >= 500) code = "SERVER_ERROR";
789
- else if (err.code === "ECONNABORTED") code = "TIMEOUT";
790
- return {
791
- status: "error",
792
- code,
793
- message
794
- };
795
- }
796
- if (err instanceof Error) return {
797
- status: "error",
798
- code: "INTERNAL_ERROR",
799
- message: err.message
800
- };
801
- return {
802
- status: "error",
803
- code: "INTERNAL_ERROR",
804
- message: String(err)
805
- };
806
- };
807
614
  const listAllProjects = async (props, orgId) => {
808
615
  const result = [];
809
616
  let cursor;
@@ -965,10 +772,6 @@ const onPromptState = (state) => {
965
772
  process.exit(1);
966
773
  }
967
774
  };
968
- const shellArg = (value) => {
969
- if (/^[A-Za-z0-9._:/-]+$/.test(value)) return value;
970
- return `'${value.replace(/'/g, `'\\''`)}'`;
971
- };
972
775
  const mustString = (value, name) => {
973
776
  if (value === void 0) throw new Error(`Internal error: expected ${name} to be set.`);
974
777
  return value;
@@ -4,13 +4,13 @@ import { readContextFile } from "../context.js";
4
4
  import { getCliName } from "../utils/cli_name.js";
5
5
  import { writer } from "../writer.js";
6
6
  import { noPassthrough, single } from "../utils/flags.js";
7
+ import { helpCsv, helpEpilogue } from "../utils/help_text.js";
7
8
  import { NEON_MCP_CATEGORIES, NEON_MCP_URL, existingNeonApiKey, installNeonMcpServer, neonMcpUrl, parseMcpCategories, trackedProjectMcpConfig } from "../mcp/install.js";
8
9
  import { mintMcpApiKey, mintedKeyRevokeCommand, withdrawMintedKey } from "../mcp/mint.js";
9
10
  import { canPickAgentsInteractively } from "../utils/agent_picker.js";
10
11
  import { mcpInstallableAgents, resolveInstallTargets } from "../mcp/targets.js";
11
12
  import { confirmMcpInstall } from "../mcp/wizard.js";
12
13
  import { resolveMcpPlan } from "../mcp/plan.js";
13
- import { helpCsv, helpEpilogue } from "../utils/help_text.js";
14
14
  //#region src/commands/mcp.ts
15
15
  var mcp_exports = /* @__PURE__ */ __exportAll({
16
16
  builder: () => builder,
@@ -2,11 +2,11 @@ import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { log } from "../log.js";
3
3
  import { writer } from "../writer.js";
4
4
  import { noPassthrough } from "../utils/flags.js";
5
+ import { helpCsv, helpEpilogue } from "../utils/help_text.js";
5
6
  import { NEON_MCP_URL } from "../mcp/install.js";
6
7
  import { getAgentDisplayName } from "../mcp/agents.js";
7
8
  import "../init/agents.js";
8
9
  import { canPickAgentsInteractively } from "../utils/agent_picker.js";
9
- import { helpCsv, helpEpilogue } from "../utils/help_text.js";
10
10
  import { pluginsInstallableAgents } from "../plugins/targets.js";
11
11
  import { resolvePluginsPlan } from "../plugins/plan.js";
12
12
  import { NEON_PLUGIN_NAME, PLUGIN_SKILLS, PLUGIN_SOURCE, neonPluginsRetryCommand, pluginsAddArgs, runPluginsCli } from "../plugins/run.js";
@@ -2,10 +2,10 @@ import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { log } from "../log.js";
3
3
  import { writer } from "../writer.js";
4
4
  import { noPassthrough } from "../utils/flags.js";
5
+ import { helpCsv, helpEpilogue } from "../utils/help_text.js";
5
6
  import { getAgentDisplayName } from "../mcp/agents.js";
6
7
  import "../init/agents.js";
7
8
  import { canPickAgentsInteractively } from "../utils/agent_picker.js";
8
- import { helpCsv, helpEpilogue } from "../utils/help_text.js";
9
9
  import { skillsHelpValues, skillsYesHelp } from "../skills/catalog.js";
10
10
  import { mappedSkillsAgentNames, skillsInstallableAgents } from "../skills/targets.js";
11
11
  import { confirmSkillsInstall, confirmSkillsUpdate } from "../skills/wizard.js";
@@ -305,12 +305,7 @@ const downloadTemplate = async (template) => {
305
305
  if (files.length === 0) throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
306
306
  return files;
307
307
  };
308
- /**
309
- * A bad caller-supplied input that an agent (or human) can correct: an unknown
310
- * template id or a non-empty target directory. Carries an `agentCode` so an
311
- * agent surface can report a precise error code instead of a generic
312
- * INTERNAL_ERROR, while a human path just surfaces the clear `message`.
313
- */
308
+ /** `agentCode` lets programmatic callers branch without parsing human-readable messages. */
314
309
  var BootstrapInputError = class extends Error {
315
310
  constructor(message, agentCode) {
316
311
  super(message);
@@ -1,17 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  //#region src/utils/cli_name.ts
3
- /**
4
- * The name this CLI was invoked as: `neon` (current) or `neonctl` (legacy alias).
5
- *
6
- * Use this for any user-facing string that suggests a command to run — help text,
7
- * error hints, and especially `--agent` `next_command_template` values, which an agent
8
- * executes verbatim. Hardcoding `neonctl` breaks those on installs of the `neon` package,
9
- * which no longer ships a `neonctl` binary (removed in `neon@2.38.0`).
10
- *
11
- * Derived from `process.argv[1]`, which is fixed for the process lifetime, so the result
12
- * is stable whether this is called at module load or per invocation. Mirrors the name used
13
- * for yargs `.scriptName()` in `index.ts`.
14
- */
3
+ /** Command hints must use the invoked binary so users can run them verbatim. */
15
4
  const getCliName = () => basename(process.argv[1] ?? "") === "neonctl" ? "neonctl" : "neon";
16
5
  //#endregion
17
6
  export { getCliName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "4.5.2",
3
+ "version": "4.7.0",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -64,8 +64,8 @@
64
64
  "yargs": "17.7.2",
65
65
  "yoctocolors": "^2.1.2",
66
66
  "@neon/config": "1.0.4",
67
- "@neon/config-runtime": "1.0.4",
68
- "@neon/sdk": "2.3.0"
67
+ "@neon/sdk": "2.3.0",
68
+ "@neon/config-runtime": "1.0.4"
69
69
  },
70
70
  "optionalDependencies": {
71
71
  "@napi-rs/keyring": "1.3.0",