neon 2.45.0 → 2.47.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.
Files changed (59) hide show
  1. package/README.md +111 -4
  2. package/dist/_shared/paths.js +3 -4
  3. package/dist/analytics.js +82 -25
  4. package/dist/commands/bootstrap.js +12 -11
  5. package/dist/commands/checkout.js +7 -6
  6. package/dist/commands/config.js +26 -11
  7. package/dist/commands/data_api.js +4 -3
  8. package/dist/commands/dev.js +67 -14
  9. package/dist/commands/env.js +131 -9
  10. package/dist/commands/functions.js +3 -2
  11. package/dist/commands/init.js +82 -37
  12. package/dist/commands/ip_allow.js +3 -2
  13. package/dist/commands/link.js +17 -16
  14. package/dist/commands/projects.js +3 -2
  15. package/dist/commands/set_context.js +5 -4
  16. package/dist/config_template.js +20 -42
  17. package/dist/current_branch_fast_path.js +2 -1
  18. package/dist/dev/env.js +214 -10
  19. package/dist/env_services.js +51 -0
  20. package/dist/index.js +2 -2
  21. package/dist/init/agents.js +127 -0
  22. package/dist/init/auth.js +77 -0
  23. package/dist/init/bootstrap.js +448 -0
  24. package/dist/init/build_config.js +2 -0
  25. package/dist/init/detect_agent.js +108 -0
  26. package/dist/init/editors.js +62 -0
  27. package/dist/init/enrich_output.js +71 -0
  28. package/dist/init/extension.js +191 -0
  29. package/dist/init/inspect.js +287 -0
  30. package/dist/init/interactive.js +651 -0
  31. package/dist/init/neonctl.js +184 -0
  32. package/dist/init/orchestrate.js +190 -0
  33. package/dist/init/phases/auth.js +209 -0
  34. package/dist/init/phases/cleanup.js +27 -0
  35. package/dist/init/phases/db.js +283 -0
  36. package/dist/init/phases/getting_started.js +228 -0
  37. package/dist/init/phases/mcp.js +227 -0
  38. package/dist/init/phases/migrations.js +251 -0
  39. package/dist/init/phases/neon_auth.js +135 -0
  40. package/dist/init/phases/setup.js +729 -0
  41. package/dist/init/phases/skills.js +89 -0
  42. package/dist/init/phases/status.js +70 -0
  43. package/dist/init/resolve_context.js +107 -0
  44. package/dist/init/route_command.js +100 -0
  45. package/dist/init/skills.js +248 -0
  46. package/dist/init/types.js +1 -0
  47. package/dist/init/vsix.js +111 -0
  48. package/dist/neon_services.js +143 -0
  49. package/dist/psql/command/cmd_meta.js +2 -2
  50. package/dist/psql/core/mainloop.js +1 -1
  51. package/dist/psql/core/startup.js +1 -1
  52. package/dist/psql/core/syncVars.js +3 -3
  53. package/dist/psql/index.js +1 -1
  54. package/dist/utils/cli_name.js +14 -0
  55. package/dist/utils/esbuild.js +1 -1
  56. package/dist/utils/package_manager.js +51 -4
  57. package/dist/utils/service_picker.js +6 -6
  58. package/dist/utils/write_sync.js +39 -0
  59. package/package.json +18 -12
@@ -7,7 +7,9 @@ import chalk from "chalk";
7
7
  import { resolveDevEnv } from "../dev/env.js";
8
8
  import { resolveFunctionsFromConfig, } from "../dev/functions.js";
9
9
  import { resolveWatchInputs } from "../dev/inputs.js";
10
+ import { readEnvFile, resolveEnvFilePath } from "../env_file.js";
10
11
  import { log } from "../log.js";
12
+ import { getCliName } from "../utils/cli_name.js";
11
13
  import { branchIdResolve } from "../utils/enrichers.js";
12
14
  import { bundleEntry } from "../utils/esbuild.js";
13
15
  export const command = "dev";
@@ -30,7 +32,68 @@ export const builder = (argv) => argv
30
32
  type: "number",
31
33
  },
32
34
  })
35
+ .epilogue([
36
+ "",
37
+ "Functions run with the linked branch's Neon env injected, the same set the",
38
+ "deployed runtime gives them: DATABASE_URL, plus Neon Auth, the Data API,",
39
+ "object storage and the AI Gateway where the branch has them. A neon.ts in",
40
+ "this directory decides instead, exactly as it does for `env pull`.",
41
+ "",
42
+ "`dev` reads your .env / .env.local to reuse the branch credential behind the",
43
+ "AI Gateway and object storage, and never writes to them. With no such file it",
44
+ "issues a credential on every start, so run `env pull` once if you restart often.",
45
+ ].join("\n"))
33
46
  .strict();
47
+ /**
48
+ * The resolver context for a `neon dev` run.
49
+ *
50
+ * Two things here are what make local dev match the deployed runtime, which injects a
51
+ * branch's whole env into a function:
52
+ *
53
+ * - **The AI Gateway is asked for**, like `env pull` does, because nothing can detect it.
54
+ * Without this, a function that works deployed fails locally with no `NEON_AI_GATEWAY_*`,
55
+ * which is exactly the difference `dev` exists to eliminate.
56
+ * - **The local dotenv file is layered in**, so the branch credential behind the gateway and
57
+ * object storage is *reused* rather than re-minted. `dev` writes no file of its own, so
58
+ * without a source of persisted secrets every start would mint a credential and leave the
59
+ * last one live — one orphan per restart. `neon-env run` already reads the file for this
60
+ * reason; `dev` was the one that didn't.
61
+ */
62
+ export const devEnvContext = (props, branchId, cwd) => {
63
+ const envFile = resolveEnvFilePath(cwd);
64
+ return {
65
+ cwd,
66
+ implyAiGateway: true,
67
+ env: {
68
+ ...process.env,
69
+ ...(existsSync(envFile) ? readEnvFile(envFile) : {}),
70
+ },
71
+ ...(props.projectId ? { projectId: props.projectId } : {}),
72
+ ...(branchId ? { branchId } : {}),
73
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
74
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
75
+ };
76
+ };
77
+ /**
78
+ * Say when a run issued a branch credential.
79
+ *
80
+ * `dev` has nowhere to persist one — it writes no file — so on a branch with nothing to reuse
81
+ * it mints per start and cannot name the previous one to revoke it. Every other command that
82
+ * mints says so; this is the one that runs dozens of times a day, and the server banner listing
83
+ * `NEON_AI_GATEWAY_TOKEN` reads as "fetched", not "just created, and the last one is still
84
+ * live". The note names the one action that stops it, so it disappears once followed.
85
+ */
86
+ export const reportDevCredential = (credential) => {
87
+ if (!credential?.issued)
88
+ return;
89
+ if (credential.revoked.length > 0) {
90
+ log.info("Issued a new branch credential — %s changed. Revoked the one it replaced (%s).", credential.keys.join(", "), credential.revoked.join(", "));
91
+ return;
92
+ }
93
+ log.warning("Issued a branch credential for this run (%s) and left any previous one live — a dev " +
94
+ `server has nowhere to keep it. Run \`${getCliName()} env pull\` once to write it to ` +
95
+ "your .env, and restarts will reuse it instead of issuing another.", credential.keys.join(", "));
96
+ };
34
97
  export const handler = async (props) => {
35
98
  if (props.source !== undefined) {
36
99
  await runSingleSource(props);
@@ -51,13 +114,8 @@ const runSingleSource = async (props) => {
51
114
  throw new Error(`Source file not found: ${source}`);
52
115
  }
53
116
  const branchId = await resolveBranchId(props);
54
- const { vars: neonEnv, skipped } = await resolveDevEnv({
55
- cwd: process.cwd(),
56
- ...(props.projectId ? { projectId: props.projectId } : {}),
57
- ...(branchId ? { branchId } : {}),
58
- ...(props.apiKey ? { apiKey: props.apiKey } : {}),
59
- ...(props.apiHost ? { apiHost: props.apiHost } : {}),
60
- });
117
+ const { vars: neonEnv, skipped, credential, } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
118
+ reportDevCredential(credential);
61
119
  const unit = {
62
120
  slug: null,
63
121
  source,
@@ -89,13 +147,8 @@ const runFromConfig = async (props) => {
89
147
  throw new Error("neon.ts has no functions to serve. Add at least one under " +
90
148
  "`preview.functions`, or pass --source <path>.");
91
149
  }
92
- const { vars: neonEnv, skipped } = await resolveDevEnv({
93
- cwd: process.cwd(),
94
- ...(props.projectId ? { projectId: props.projectId } : {}),
95
- ...(branchId ? { branchId } : {}),
96
- ...(props.apiKey ? { apiKey: props.apiKey } : {}),
97
- ...(props.apiHost ? { apiHost: props.apiHost } : {}),
98
- });
150
+ const { vars: neonEnv, skipped, credential, } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
151
+ reportDevCredential(credential);
99
152
  const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
100
153
  // Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
101
154
  // functions without restarting the dev server. `searchBase` lets a freshly-added unit
@@ -4,9 +4,12 @@ import chalk from "chalk";
4
4
  import { ensureGitignored } from "../context.js";
5
5
  import { resolveNeonEnvVars } from "../dev/env.js";
6
6
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
7
+ import { ENV_PULL_SERVICES, ENV_PULL_UNAVAILABLE, envServiceKeys, ownedEnvServiceKeys, } from "../env_services.js";
7
8
  import { log } from "../log.js";
9
+ import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption, } from "../neon_services.js";
8
10
  import { warnAiGateway } from "../utils/ai_gateway_notice.js";
9
11
  import { announceTargetBranch } from "../utils/branch_notice.js";
12
+ import { getCliName } from "../utils/cli_name.js";
10
13
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
11
14
  export const command = "env";
12
15
  export const describe = "Manage a branch's Neon env variables locally";
@@ -15,7 +18,7 @@ export const describe = "Manage a branch's Neon env variables locally";
15
18
  * `--no-env-pull`. Names the two ways to get the branch's vars without an on-disk file written
16
19
  * eagerly: an explicit `neonctl env pull`, or runtime injection via `neon-env run`.
17
20
  */
18
- export const ENV_PULL_SKIPPED_HINT = "Skipped env pull (--no-env-pull). Run `neonctl env pull` to write this branch’s env vars " +
21
+ export const ENV_PULL_SKIPPED_HINT = `Skipped env pull (--no-env-pull). Run \`${getCliName()} env pull\` to write this branch’s env vars ` +
19
22
  "(DATABASE_URL, …) into a local .env, or inject them at runtime with `neon-env run -- <your dev command>`.";
20
23
  export const builder = (argv) => argv
21
24
  .usage("$0 env <sub-command> [options]")
@@ -33,14 +36,51 @@ export const builder = (argv) => argv
33
36
  "lines are preserved.",
34
37
  type: "string",
35
38
  },
39
+ service: servicesOption({
40
+ key: "service",
41
+ allowed: ENV_PULL_SERVICES,
42
+ describe: "Pull only these services' variables",
43
+ also: "Overrides neon.ts, and prunes only within the services you name.",
44
+ }),
36
45
  })
46
+ .epilogue([
47
+ "",
48
+ "What gets pulled, in precedence order:",
49
+ " 1. --service, when given — exactly those, ignoring neon.ts.",
50
+ " 2. neon.ts, when this directory has one.",
51
+ " 3. Otherwise everything the branch has, plus the AI Gateway —",
52
+ " which mints a branch credential for it.",
53
+ "",
54
+ "The pull bundled into link / checkout / config apply follows 2 and 3",
55
+ "without the AI Gateway, so it never mints a credential you did not ask",
56
+ "for. Run `env pull` to add it.",
57
+ ].join("\n"))
37
58
  .example("$0 env pull", "Write the linked branch's Neon vars into .env.local (or .env if present)")
38
- .example("$0 env pull --branch preview --file .env.preview", "Pull a specific branch into a specific file"), async (args) => {
59
+ .example("$0 env pull --branch preview --file .env.preview", "Pull a specific branch into a specific file")
60
+ .example("$0 env pull -s ai-gateway -s postgres", "Pull only the AI Gateway and Postgres variables"), async (args) => {
61
+ const raw = servicesFlagValue(args.service);
39
62
  // Explicit `env pull` announces the branch it's reading from up front so the user
40
63
  // can catch "pulled env from the wrong branch" before it overwrites their .env. The
41
64
  // bundled auto-pull (link / checkout / apply) stays quiet — those already report the
42
65
  // branch they pinned/applied to.
43
- await pull(args, { announce: true });
66
+ //
67
+ // It also implies the AI Gateway when there is no neon.ts, so a bare `env pull`
68
+ // really does write everything the branch can give you. The bundled auto-pull does
69
+ // not: minting a credential for a service the user never named is not something a
70
+ // side effect of `link` / `checkout` / `apply` should do.
71
+ await pull({
72
+ ...args,
73
+ ...(raw
74
+ ? {
75
+ services: parseServices(raw, {
76
+ allowed: ENV_PULL_SERVICES,
77
+ whyUnavailable: ENV_PULL_UNAVAILABLE,
78
+ flag: "--service",
79
+ onDeprecated: (used, canonical) => log.warning(deprecatedServiceMessage(used, canonical)),
80
+ }),
81
+ }
82
+ : {}),
83
+ }, { announce: true, implyAiGateway: raw === undefined });
44
84
  })
45
85
  .demandCommand(1);
46
86
  export const handler = (args) => args;
@@ -82,16 +122,18 @@ export const pull = async (props, opts = {}) => {
82
122
  // Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
83
123
  // pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
84
124
  // `env pull` is an explicit action, so it should error rather than write nothing.
85
- const { vars, credential } = await resolveNeonEnvVars({
125
+ const { vars, credential, skipped } = await resolveNeonEnvVars({
86
126
  cwd,
87
127
  projectId: props.projectId,
88
128
  branchId,
89
129
  env: { ...process.env, ...existingEnv },
130
+ ...(props.services ? { services: props.services } : {}),
131
+ ...(opts.implyAiGateway ? { implyAiGateway: true } : {}),
90
132
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
91
133
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
92
134
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
93
135
  });
94
- const neonVars = pickNeonVars(vars);
136
+ const neonVars = pickServiceVars(pickNeonVars(vars), props.services);
95
137
  if (Object.keys(neonVars).length === 0) {
96
138
  log.info("No Neon env variables to pull for this branch (no DATABASE_URL or " +
97
139
  "enabled Auth / Data API).");
@@ -101,7 +143,7 @@ export const pull = async (props, opts = {}) => {
101
143
  // Neon-owned vars the branch no longer has (e.g. NEON_AUTH_* / NEON_DATA_API_* carried over
102
144
  // from a previous project/branch). Non-Neon lines are always preserved.
103
145
  const { written, removed } = mergeEnvFile(targetPath, neonVars, {
104
- managedKeys: NEON_OWNED_ENV_KEYS,
146
+ managedKeys: managedKeysFor(props.services, unreachedButCurrent(skipped, existingEnv, branchId)),
105
147
  });
106
148
  log.info("Pulled %d Neon variable%s into %s: %s", written.length, written.length === 1 ? "" : "s", targetPath, written.join(", "));
107
149
  if (removed.length > 0) {
@@ -115,6 +157,17 @@ export const pull = async (props, opts = {}) => {
115
157
  if (credential.revoked.length > 0) {
116
158
  log.info("Revoked the credential it replaced (%s).", credential.revoked.join(", "));
117
159
  }
160
+ else if (credential.superseded.length > 0) {
161
+ // An unscoped pull revokes what it supersedes and says so above. A scoped one
162
+ // cannot — it may not be the only service on that credential — so it leaves the
163
+ // old one live. Say that too, rather than letting the identical-looking output
164
+ // imply the branch is not accumulating credentials. Driven by what the resolver
165
+ // actually declined to revoke, so a first pull (which supersedes nothing) does
166
+ // not send the user hunting for a credential that was never there.
167
+ log.info("Left the credential it replaced live (%s): a pull scoped with --service " +
168
+ "can't tell which other services still use it. Revoke it in the Neon " +
169
+ "Console if nothing does.", credential.superseded.join(", "));
170
+ }
118
171
  }
119
172
  // A dotenv file *we* create holds live branch credentials (DATABASE_URL, Auth keys, service
120
173
  // tokens), so ignore it the same way the `.neon` context file is — otherwise a fresh repo is
@@ -142,8 +195,75 @@ export const pull = async (props, opts = {}) => {
142
195
  written,
143
196
  file: targetPath,
144
197
  ...(credential && credential.keys.length > 0 ? { credential } : {}),
198
+ ...(skipped && skipped.length > 0 ? { skipped } : {}),
145
199
  };
146
200
  };
201
+ /**
202
+ * The keys this pull is allowed to prune, i.e. the ones it is authoritative for.
203
+ *
204
+ * A `--service` selection narrows that to the services it named: `env pull -s ai-gateway`
205
+ * says nothing about `DATABASE_URL`, so it must not read that variable's absence from this
206
+ * pull as "the branch no longer has it". `unreached` is subtracted for the same reason — see
207
+ * {@link unreachedButCurrent}.
208
+ */
209
+ const managedKeysFor = (services, unreached) => {
210
+ const owned = services
211
+ ? ownedEnvServiceKeys(services)
212
+ : [...NEON_OWNED_ENV_KEYS];
213
+ if (unreached.length === 0)
214
+ return owned;
215
+ const keep = new Set(ownedEnvServiceKeys(unreached));
216
+ return owned.filter((key) => !keep.has(key));
217
+ };
218
+ /**
219
+ * Of the services this pull could not reach, the ones whose variables already on disk belong
220
+ * to the branch being pulled — the only ones worth keeping.
221
+ *
222
+ * Failing to reach a service is not evidence that the branch stopped having it:
223
+ * `PLATFORM_FEATURE_UNAVAILABLE` covers a transient incident as well as a project that
224
+ * genuinely lacks the feature, and pruning would delete a token whose secret exists nowhere
225
+ * else and strand the live credential behind it. But that only argues for keeping *this
226
+ * branch's* values. Variables left over from another branch are stale by definition, and
227
+ * keeping those would leave an app pointed at the wrong branch's gateway — a worse failure
228
+ * than losing a token, because it is silent.
229
+ *
230
+ * The gateway is the only service that can be unreached (only it is implied rather than
231
+ * observed), and its base URL is branch-scoped, so the persisted URL is what tells the two
232
+ * cases apart. Anything that does not resolve to this branch's gateway host is pruned, which
233
+ * is the safe direction: a stale entry costs a re-pull, a wrongly-kept one silently misroutes
234
+ * traffic.
235
+ */
236
+ const unreachedButCurrent = (skipped, existingEnv, branchId) => {
237
+ if (!skipped?.includes("ai-gateway"))
238
+ return [];
239
+ const baseUrl = existingEnv[NEON_ENV_VAR_KEYS.aiGateway.baseUrl];
240
+ return baseUrl !== undefined && isBranchGatewayUrl(baseUrl, branchId)
241
+ ? ["ai-gateway"]
242
+ : [];
243
+ };
244
+ /**
245
+ * Whether a persisted `NEON_AI_GATEWAY_BASE_URL` addresses `branchId`'s gateway.
246
+ *
247
+ * Checks the parsed **hostname** against the shape `@neon/env` builds
248
+ * (`<branchId>-api.ai.<host suffix>`), not the raw string: a prefix comparison is satisfied
249
+ * by a URL whose userinfo carries the branch id (`https://<branchId>-api.ai.@other-host/`)
250
+ * while the request actually goes elsewhere. An unparseable value is not this branch's
251
+ * gateway either, which is an answer rather than a swallowed failure.
252
+ */
253
+ const isBranchGatewayUrl = (baseUrl, branchId) => URL.canParse(baseUrl) &&
254
+ new URL(baseUrl).hostname.startsWith(`${branchId}-api.ai.`);
255
+ /**
256
+ * Narrow the resolved vars to the selected services (plus `NEON_BRANCH`, which every pull
257
+ * refreshes). Needed because the two `DATABASE_URL*` vars are always resolved — `fetchEnv`
258
+ * reads both connection URIs regardless, since the AI Gateway host is derived from the direct
259
+ * one — so `--service ai-gateway` has to drop them here rather than avoid fetching them.
260
+ */
261
+ const pickServiceVars = (vars, services) => {
262
+ if (!services)
263
+ return vars;
264
+ const wanted = envServiceKeys(services);
265
+ return Object.fromEntries(Object.entries(vars).filter(([key]) => wanted.has(key)));
266
+ };
147
267
  /**
148
268
  * Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
149
269
  * `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
@@ -165,7 +285,7 @@ export const autoPullEnvAfterPin = async (props) => {
165
285
  catch (err) {
166
286
  const message = err instanceof Error ? err.message : String(err);
167
287
  log.warning("Branch pinned, but pulling its Neon env vars failed: %s\n" +
168
- "Run `neonctl env pull` once resolved (e.g. `neonctl deploy` if a declared service " +
288
+ `Run \`${getCliName()} env pull\` once resolved (e.g. \`${getCliName()} deploy\` if a declared service ` +
169
289
  "is missing), or inject them at runtime with `neon-env run -- <your dev command>`.", message);
170
290
  return { status: "failed", message };
171
291
  }
@@ -182,15 +302,17 @@ export const renderAgentPullNote = (result) => {
182
302
  const credential = result.credential?.issued
183
303
  ? ` Issued a new branch credential, so ${result.credential.keys.join(", ")} changed.`
184
304
  : "";
305
+ // No `skipped` note: only the implied AI Gateway can be skipped, and the auto-pull
306
+ // this renders never implies it.
185
307
  return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? "" : "s"} into ${result.file}.${credential}`;
186
308
  }
187
309
  case "empty":
188
310
  return " No Neon env vars to pull for this branch yet.";
189
311
  case "skipped":
190
- return (" Skipped env pull (--no-env-pull); run `neonctl env pull` later, " +
312
+ return (` Skipped env pull (--no-env-pull); run \`${getCliName()} env pull\` later, ` +
191
313
  "or inject env at runtime with `neon-env run -- <your dev command>`.");
192
314
  case "failed":
193
- return ` Could not pull env vars (${result.message}); run \`neonctl env pull\` once resolved.`;
315
+ return ` Could not pull env vars (${result.message}); run \`${getCliName()} env pull\` once resolved.`;
194
316
  }
195
317
  };
196
318
  /**
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { isNeonApiError, retryOnLock } from "../api.js";
4
4
  import { createDeployment, deleteFunction, getFunction, listFunctions, } from "../functions_api.js";
5
5
  import { log } from "../log.js";
6
+ import { getCliName } from "../utils/cli_name.js";
6
7
  import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
7
8
  import { bundleEntry } from "../utils/esbuild.js";
8
9
  import { zipBundle } from "../utils/zip.js";
@@ -146,7 +147,7 @@ const parseEnv = (entries) => {
146
147
  }
147
148
  return JSON.stringify(map);
148
149
  };
149
- const statusHint = (slug, projectId, branchId) => `Check status with: neonctl function get ${slug} --project-id ${projectId} --branch ${branchId}`;
150
+ const statusHint = (slug, projectId, branchId) => `Check status with: ${getCliName()} function get ${slug} --project-id ${projectId} --branch ${branchId}`;
150
151
  // Emit the resolved deployment together with the function's invocation_url, so the
151
152
  // deploy output shows where the function is reachable (not just the deployment id).
152
153
  const emitDeployResult = (props, deployment, fn) => {
@@ -172,7 +173,7 @@ const deploy = async (props) => {
172
173
  props.runtime !== undefined;
173
174
  if (!hasOption) {
174
175
  throw new Error("Provide at least one option to deploy, e.g. --src or --env. " +
175
- "See: neonctl function deploy --help.");
176
+ `See: ${getCliName()} function deploy --help.`);
176
177
  }
177
178
  // Cheap, offline validation first - fail before any network round-trip.
178
179
  if (!SLUG_PATTERN.test(props.slug)) {
@@ -1,7 +1,11 @@
1
- import { detectAgent, enrichResponse, interactiveInit, orchestrate, routeDataStep, } from "neon-init";
2
1
  import { credentialInputs } from "../_shared/auth_selection.js";
3
- import { sendError } from "../analytics.js";
4
- import { log } from "../log.js";
2
+ import { closeAnalytics, sendError } from "../analytics.js";
3
+ import { detectAgent } from "../init/detect_agent.js";
4
+ import { enrichResponse } from "../init/enrich_output.js";
5
+ import { interactiveInit } from "../init/interactive.js";
6
+ import { orchestrate } from "../init/orchestrate.js";
7
+ import { routeDataStep } from "../init/route_command.js";
8
+ import { STDOUT_FD, writeAllSync } from "../utils/write_sync.js";
5
9
  export const command = "init";
6
10
  export const describe = "Initialize a project with Neon using your AI coding assistant";
7
11
  export const builder = (yargs) => yargs
@@ -29,62 +33,103 @@ export const builder = (yargs) => yargs
29
33
  describe: "Enable preview features (e.g. project bootstrapping from templates).",
30
34
  })
31
35
  .strict(false);
36
+ /**
37
+ * The agent-facing half of `neon init` speaks JSON, and it speaks it on **stdout**:
38
+ * one object, no prefix, nothing else. `log.info` would prefix every line with
39
+ * `INFO: ` and send it to stderr, which is right for a diagnostic and wrong for the
40
+ * payload an agent is expected to parse.
41
+ */
42
+ const writeAgentResponse = (result) => {
43
+ writeAllSync(STDOUT_FD, `${JSON.stringify(enrichResponse(result), null, 2)}\n`);
44
+ };
45
+ /**
46
+ * A failure has to arrive in the shape the caller asked for. An agent parses stdout and
47
+ * has no branch for "empty stdout, exit 1" — it cannot tell a broken credentials file
48
+ * from a phase that legitimately produced nothing — so the error goes out as JSON too.
49
+ */
50
+ const writeAgentFailure = (error) => {
51
+ writeAllSync(STDOUT_FD, `${JSON.stringify({ success: false, error: error.message }, null, 2)}\n`);
52
+ };
53
+ /** ` at position 12`, or nothing when the parser did not report one. */
54
+ const parsePosition = (parseError) => {
55
+ const message = parseError instanceof Error ? parseError.message : "";
56
+ const at = message.match(/at position (\d+)/);
57
+ return at === null ? "" : ` at position ${at[1]}`;
58
+ };
32
59
  export const handler = async (argv) => {
33
- // `init` delegates its whole auth flow to `neon-init`, which reads the default credentials
34
- // directly and re-invokes the CLI as a subprocess. It has no way to be told which profile
35
- // to use, so honouring a selection here is not possible yet — and silently running as the
36
- // default account would be worse than refusing, because naming an account is the entire
37
- // job of the thing being ignored.
60
+ // Auto-detect agent from environment. When --agent is explicitly passed,
61
+ // always detect (the user asked for agent mode). Otherwise, require
62
+ // non-TTY stdin to distinguish agent from human in terminal.
38
63
  //
39
- // `NEON_PROFILE` counts just as much as the flag. Checking only the flag left the case that
40
- // is easier to hit by accident: a profile exported once into a shell then silently
41
- // disregarded by every `neon init` run in it.
42
- const selectedProfile = argv.profile?.trim() || credentialInputs().profileEnv.trim();
43
- if (selectedProfile) {
44
- const how = argv.profile?.trim()
45
- ? "--profile was passed, so"
46
- : "NEON_PROFILE is set, so";
47
- throw new Error(`${how} \`neon init\` would run as the default account instead of "${selectedProfile}", and it does not support profile selection yet. Run it without one, or set the project up with \`neon --profile ${selectedProfile} link\`.`);
48
- }
64
+ // Resolved before anything can fail, so that every failure this handler sees —
65
+ // including the profile refusal — is reported in the shape the caller can read.
66
+ // Failures raised by `ensureAuth` are not among them: it resolves credentials
67
+ // above its own `init` skip, so an unknown profile, a contradictory
68
+ // `--api-key`/`--profile` pair, and a damaged credentials file all report on
69
+ // stderr before this runs.
70
+ const agent = (argv.agent || !process.stdin.isTTY ? detectAgent() : null) ||
71
+ undefined;
72
+ const isAgentMode = argv.agent || agent !== undefined;
49
73
  try {
50
- // Auto-detect agent from environment. When --agent is explicitly passed,
51
- // always detect (the user asked for agent mode). Otherwise, require
52
- // non-TTY stdin to distinguish agent from human in terminal.
53
- const agent = (argv.agent || !process.stdin.isTTY ? detectAgent() : null) ||
54
- undefined;
55
- const isAgentMode = argv.agent || agent !== undefined;
74
+ // The init flow reads the default credentials directly and re-invokes the CLI as a
75
+ // subprocess. It has no way to be told which profile to use, so honouring a selection
76
+ // here is not possible yet — and silently running as the default account would be
77
+ // worse than refusing, because naming an account is the entire job of the thing being
78
+ // ignored.
79
+ //
80
+ // `NEON_PROFILE` counts just as much as the flag. Checking only the flag left the case
81
+ // that is easier to hit by accident: a profile exported once into a shell then
82
+ // silently disregarded by every `neon init` run in it.
83
+ const selectedProfile = argv.profile?.trim() || credentialInputs().profileEnv.trim();
84
+ if (selectedProfile) {
85
+ const how = argv.profile?.trim()
86
+ ? "--profile was passed, so"
87
+ : "NEON_PROFILE is set, so";
88
+ throw new Error(`${how} \`neon init\` would run as the default account instead of "${selectedProfile}", and it does not support profile selection yet. Run it without one, or set the project up with \`neon --profile ${selectedProfile} link\`.`);
89
+ }
56
90
  // --data with a "step" field routes to the appropriate phase
57
91
  if (argv.data && isAgentMode) {
58
92
  let data;
59
93
  try {
60
94
  data = JSON.parse(argv.data);
61
95
  }
62
- catch {
63
- log.error("Invalid JSON in --data flag. Expected a JSON object.");
64
- process.exit(1);
65
- return;
96
+ catch (parseError) {
97
+ // Neither the payload nor the parser's message may appear here. `--data`
98
+ // carries whatever the caller put in it — a connection string, an API key —
99
+ // and V8 quotes a window of the input around the syntax error, so both would
100
+ // travel into the error message, onto stdout, and into `sendError`'s
101
+ // analytics payload. `shared/cli-core/src/credentials.ts` discards the same
102
+ // message for the same reason. The position is a number and says enough.
103
+ throw new Error(`Invalid JSON in --data flag${parsePosition(parseError)}. Expected a JSON object.`);
66
104
  }
67
105
  if (typeof data.step === "string") {
68
- const result = await routeDataStep(data, agent);
69
- log.info(JSON.stringify(enrichResponse(result), null, 2));
106
+ writeAgentResponse(await routeDataStep(data, agent));
70
107
  return;
71
108
  }
72
109
  }
73
110
  if (isAgentMode) {
74
- const result = await orchestrate({
111
+ writeAgentResponse(await orchestrate({
75
112
  agent,
76
113
  skipMigrations: argv.skipMigrations,
77
114
  preview: argv.preview,
78
- });
79
- log.info(JSON.stringify(enrichResponse(result), null, 2));
115
+ }));
80
116
  }
81
117
  else {
82
118
  await interactiveInit({ preview: argv.preview });
83
119
  }
84
120
  }
85
- catch {
86
- const exitError = new Error(`failed to run neon-init`);
87
- sendError(exitError, "NEON_INIT_FAILED");
88
- process.exit(1);
121
+ catch (error) {
122
+ const cause = error instanceof Error ? error : new Error(String(error));
123
+ if (isAgentMode) {
124
+ // Agent mode answers and exits here, so nothing else will report this. Attribute
125
+ // it to init and flush before exiting — `process.exit` drops in-flight events.
126
+ sendError(cause, "NEON_INIT_FAILED");
127
+ writeAgentFailure(cause);
128
+ await closeAnalytics();
129
+ process.exit(1);
130
+ }
131
+ // A human gets the top-level handler's single `ERROR: <message>` line on stderr, and
132
+ // its `sendError`. Reporting here as well would file one failure as two events.
133
+ throw cause;
89
134
  }
90
135
  };
@@ -1,5 +1,6 @@
1
1
  import { log } from "../log.js";
2
2
  import { projectUpdateRequest } from "../parameters.gen.js";
3
+ import { getCliName } from "../utils/cli_name.js";
3
4
  import { fillSingleProject } from "../utils/enrichers.js";
4
5
  import { writer } from "../writer.js";
5
6
  const IP_ALLOW_FIELDS = [
@@ -68,7 +69,7 @@ const list = async (props) => {
68
69
  const add = async (props) => {
69
70
  if (props.ips.length <= 0) {
70
71
  throw new Error(`Enter individual IP addresses, define ranges with a dash, or use CIDR notation for more flexibility.
71
- Example: neonctl ip-allow add 192.168.1.1, 192.168.1.20-192.168.1.50, 192.168.1.0/24 --project-id <id>`);
72
+ Example: ${getCliName()} ip-allow add 192.168.1.1, 192.168.1.20-192.168.1.50, 192.168.1.0/24 --project-id <id>`);
72
73
  }
73
74
  const project = {};
74
75
  const { data } = await props.apiClient.getProject(props.projectId);
@@ -90,7 +91,7 @@ const add = async (props) => {
90
91
  };
91
92
  const remove = async (props) => {
92
93
  if (props.ips.length <= 0) {
93
- throw new Error(`Remove individual IP addresses and ranges. Example: neonctl ip-allow remove 192.168.1.1 --project-id <id>`);
94
+ throw new Error(`Remove individual IP addresses and ranges. Example: ${getCliName()} ip-allow remove 192.168.1.1 --project-id <id>`);
94
95
  }
95
96
  const project = {};
96
97
  const { data } = await props.apiClient.getProject(props.projectId);