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
@@ -4,6 +4,7 @@ import { applyContext, contextBranch, readContextFile, setContext, updateContext
4
4
  import { isCi } from "../env.js";
5
5
  import { log } from "../log.js";
6
6
  import { createBranch, pickBranchInteractively, } from "../utils/branch_picker.js";
7
+ import { getCliName } from "../utils/cli_name.js";
7
8
  import { hasNeonConfigFile, initCmd } from "./config.js";
8
9
  import { autoPullEnvAfterPin, renderAgentPullNote } from "./env.js";
9
10
  import { REGIONS } from "./projects.js";
@@ -34,7 +35,7 @@ export const builder = (argv) => argv
34
35
  alias: "branch-id",
35
36
  describe: "Branch name or ID to pin in the context (resolved to its ID before writing). " +
36
37
  "Without it, link only resolves the org and project — pin a branch with " +
37
- "`neonctl checkout <branch>` (link never guesses a default).",
38
+ `\`${getCliName()} checkout <branch>\` (link never guesses a default).`,
38
39
  type: "string",
39
40
  },
40
41
  params: {
@@ -76,7 +77,7 @@ export const builder = (argv) => argv
76
77
  .example([
77
78
  [
78
79
  "$0 link --project-id polished-snowflake-12345678",
79
- "Link an existing project (org is inferred); pin a branch later with 'neonctl checkout'",
80
+ `Link an existing project (org is inferred); pin a branch later with '${getCliName()} checkout'`,
80
81
  ],
81
82
  [
82
83
  "$0 link --org-id org-… --project-name my-app --region-id aws-us-east-2",
@@ -120,9 +121,9 @@ export const handler = async (props) => {
120
121
  "Missing inputs and CI environment detected (no TTY for prompts).",
121
122
  "",
122
123
  "Use one of:",
123
- " neonctl link --agent (JSON state machine for agents)",
124
- " neonctl link --project-id <project> (link to an existing project; org is inferred)",
125
- " neonctl link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)",
124
+ ` ${getCliName()} link --agent (JSON state machine for agents)`,
125
+ ` ${getCliName()} link --project-id <project> (link to an existing project; org is inferred)`,
126
+ ` ${getCliName()} link --org-id <org> --project-name <name> --region-id <region> (create a new project and link)`,
126
127
  ].join("\n"));
127
128
  process.exit(1);
128
129
  return;
@@ -180,7 +181,7 @@ const validateInputs = (inputs) => {
180
181
  throw new Error("Conflicting inputs: --project-id selects an existing project; --project-name and --region-id describe a new one. Pass only one set.");
181
182
  }
182
183
  if (inputs.projectName && inputs.branch) {
183
- throw new Error("Conflicting inputs: --branch pins a branch of an existing project, but --project-name creates a new one. Create the project first, then `neonctl checkout <branch>`.");
184
+ throw new Error(`Conflicting inputs: --branch pins a branch of an existing project, but --project-name creates a new one. Create the project first, then \`${getCliName()} checkout <branch>\`.`);
184
185
  }
185
186
  };
186
187
  /**
@@ -338,7 +339,7 @@ const resolveBranchRef = async (props, projectId, branchRef) => {
338
339
  .map((b) => `${b.id}${b.name ? ` (${b.name})` : ""}`)
339
340
  .join(", ")
340
341
  : "(none)";
341
- throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${available}. Pin one with \`neonctl checkout <branch>\`.`, "NOT_FOUND");
342
+ throw new LinkInputError(`Branch '${branchRef}' not found in project '${projectId}'. Available branches: ${available}. Pin one with \`${getCliName()} checkout <branch>\`.`, "NOT_FOUND");
342
343
  };
343
344
  /**
344
345
  * The value to persist for a branch: prefer its human-readable **name** (nicer
@@ -717,7 +718,7 @@ const runAgent = async (props, inputs) => {
717
718
  context_file: props.contextFile,
718
719
  context: { orgId, projectId },
719
720
  project: { id: projectId },
720
- message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix}. No branch pinned — run \`neonctl checkout <branch>\` (omit the branch to list options) to pin one and pull its env vars.`,
721
+ 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.`,
721
722
  });
722
723
  return;
723
724
  }
@@ -737,7 +738,7 @@ const runAgent = async (props, inputs) => {
737
738
  name: region.name,
738
739
  default: region.default,
739
740
  })),
740
- next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name ${shellArg(projectName)} --region-id <region_id>`,
741
+ next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-name ${shellArg(projectName)} --region-id <region_id>`,
741
742
  });
742
743
  return;
743
744
  }
@@ -781,7 +782,7 @@ const runAgent = async (props, inputs) => {
781
782
  // the instruction rather than silently dropped.
782
783
  const projects = await listAllProjects(props, orgId);
783
784
  const branchNote = branch
784
- ? ` 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 \`neonctl checkout ${branch}\`.`
785
+ ? ` 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}\`.`
785
786
  : "";
786
787
  emitAgent({
787
788
  status: "needs_project",
@@ -796,9 +797,9 @@ const runAgent = async (props, inputs) => {
796
797
  })),
797
798
  create_option: {
798
799
  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.",
799
- next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`,
800
+ next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-name <name> --region-id <region_id>`,
800
801
  },
801
- next_command_template: `neonctl link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`,
802
+ next_command_template: `${getCliName()} link --agent --org-id ${shellArg(orgId)} --project-id <project_id>`,
802
803
  });
803
804
  };
804
805
  const emitAgent = (response) => {
@@ -865,7 +866,7 @@ const buildNeedsOrgResponse = (resolution) => {
865
866
  status: "needs_org",
866
867
  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.",
867
868
  options: [],
868
- next_command_template: "neonctl link --agent --org-id <org_id>",
869
+ next_command_template: `${getCliName()} link --agent --org-id <org_id>`,
869
870
  };
870
871
  }
871
872
  const orgs = resolution.orgs;
@@ -875,7 +876,7 @@ const buildNeedsOrgResponse = (resolution) => {
875
876
  ? "The user does not belong to any organizations. Ask them to create one in the Neon Console (https://console.neon.tech/) before linking."
876
877
  : `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.`,
877
878
  options: orgs.map((org) => ({ id: org.id, name: org.name })),
878
- next_command_template: "neonctl link --agent --org-id <org_id>",
879
+ next_command_template: `${getCliName()} link --agent --org-id <org_id>`,
879
880
  };
880
881
  };
881
882
  const toAgentError = (err) => {
@@ -954,7 +955,7 @@ const resolveInteractiveBranch = async (props, projectId) => {
954
955
  const picked = await pickBranchInteractively(branches, {
955
956
  message: "Which branch would you like to link?",
956
957
  nonInteractiveMessage: "No branch could be selected without an interactive terminal. " +
957
- "Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.",
958
+ `Re-run \`${getCliName()} link\` interactively, or \`${getCliName()} checkout <branch>\` to pin one.`,
958
959
  });
959
960
  if (picked.kind === "existing") {
960
961
  const existing = branches.find((b) => b.id === picked.branchId);
@@ -1031,7 +1032,7 @@ const printSummary = (_props, summary) => {
1031
1032
  }
1032
1033
  else if (summary.projectId && !summary.branch && !summary.orgOnly) {
1033
1034
  lines.push("");
1034
- lines.push("No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.");
1035
+ lines.push(`No branch pinned. Run \`${getCliName()} checkout <branch>\` to pin a branch and pull its env vars.`);
1035
1036
  }
1036
1037
  lines.push("");
1037
1038
  process.stdout.write(`${lines.join("\n")}\n`);
@@ -4,6 +4,7 @@ import { updateContextFile } from "../context.js";
4
4
  import { isCi } from "../env.js";
5
5
  import { log } from "../log.js";
6
6
  import { projectCreateRequest, projectUpdateRequest, } from "../parameters.gen.js";
7
+ import { getCliName } from "../utils/cli_name.js";
7
8
  import { getComputeUnits } from "../utils/compute_units.js";
8
9
  import { psql } from "../utils/psql.js";
9
10
  import { writer } from "../writer.js";
@@ -392,11 +393,11 @@ The organization ID has been saved in ${props.contextFile}
392
393
 
393
394
  If you'd like to change the default organization later, use
394
395
 
395
- neonctl link --org-id <org_id>
396
+ ${getCliName()} link --org-id <org_id>
396
397
 
397
398
  Or to clear the context file and forget the default organization
398
399
 
399
- neonctl link --clear
400
+ ${getCliName()} link --clear
400
401
 
401
402
  `);
402
403
  }
@@ -1,7 +1,8 @@
1
1
  import { applyContext } from "../context.js";
2
2
  import { log } from "../log.js";
3
+ import { getCliName } from "../utils/cli_name.js";
3
4
  export const command = "set-context";
4
- export const describe = "Deprecated: use `neonctl link`. Set the .neon context (raw write).";
5
+ export const describe = `Deprecated: use \`${getCliName()} link\`. Set the .neon context (raw write).`;
5
6
  export const builder = (argv) => argv.usage("$0 set-context [options]").options({
6
7
  "project-id": {
7
8
  describe: "Project ID",
@@ -17,9 +18,9 @@ export const builder = (argv) => argv.usage("$0 set-context [options]").options(
17
18
  },
18
19
  });
19
20
  export const handler = (props) => {
20
- log.warning("`neonctl set-context` is deprecated and will be removed in a future release. " +
21
- "Use `neonctl link` instead — it verifies inputs and infers the org for you " +
22
- "(or `neonctl link --no-checks` for the same write-without-checks behavior).");
21
+ log.warning(`\`${getCliName()} set-context\` is deprecated and will be removed in a future release. ` +
22
+ `Use \`${getCliName()} link\` instead — it verifies inputs and infers the org for you ` +
23
+ `(or \`${getCliName()} link --no-checks\` for the same write-without-checks behavior).`);
23
24
  const context = {
24
25
  projectId: props.projectId,
25
26
  orgId: props.orgId,
@@ -1,3 +1,4 @@
1
+ import { NEON_SERVICES } from "./neon_services.js";
1
2
  /**
2
3
  * The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
3
4
  *
@@ -11,55 +12,32 @@ export const CONFIG_PACKAGE = "@neon/config";
11
12
  export const ENV_PACKAGE = "@neon/env";
12
13
  export const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
13
14
  /**
14
- * A Neon service `config init` can declare in the `neon.ts` it scaffolds, spelled the way a
15
- * user types it in `--services`. Kebab-case rather than the `neon.ts` field names (`aiGateway`,
16
- * `buckets`) so the flag reads like a flag; {@link renderNeonConfig} owns the mapping.
15
+ * The services `config init` can declare in the `neon.ts` it scaffolds — the subset of
16
+ * {@link NEON_SERVICES} a policy has a field for. {@link renderNeonConfig} owns the mapping
17
+ * from these names to the `neon.ts` fields (`aiGateway`, `buckets`).
17
18
  *
18
- * Postgres is absent because every branch has it, and `dataApi` is absent because enabling it
19
- * with the default `authProvider: "neon"` requires `auth` — a pairing the picker would have to
20
- * enforce rather than offer.
19
+ * Postgres is absent because every branch has it, so there is nothing to declare. `data-api`
20
+ * is absent because enabling it with the default `authProvider: "neon"` requires `auth` — a
21
+ * pairing the picker would have to enforce rather than offer.
21
22
  */
22
- export const NEON_SERVICES = [
23
- "auth",
24
- "functions",
25
- "storage",
26
- "ai-gateway",
27
- ];
28
- /** `--services none`: declare nothing, i.e. scaffold the bare starter policy. */
29
- export const NO_SERVICES = "none";
23
+ export const CONFIG_INIT_SERVICES = NEON_SERVICES.filter((service) => service !== "postgres" && service !== "data-api");
24
+ /**
25
+ * What `config init --services none` produces. One constant because it is both the help text
26
+ * and what tells the parser `none` is a value here — passing the literal at each call site
27
+ * lets the two drift into documenting something the parser does not accept.
28
+ */
29
+ export const CONFIG_INIT_NONE_MEANS = "the bare starter policy";
30
+ /** Why the two a policy cannot declare are not selectable, for the refusal message. */
31
+ export const CONFIG_INIT_UNAVAILABLE = {
32
+ postgres: "every branch has Postgres, so a policy has nothing to declare for it",
33
+ "data-api": "enabling it with the default provider requires auth, so declare auth here and turn the Data API on with `neon data-api create`",
34
+ };
30
35
  /** Slug, display name, and source path of the function scaffolded for `functions`. */
31
36
  export const FUNCTION_SLUG = "hello";
32
37
  export const FUNCTION_NAME = "Hello World";
33
38
  export const FUNCTION_FILENAME = "hello.ts";
34
39
  /** Name of the bucket scaffolded for `storage`. */
35
40
  export const BUCKET_NAME = "assets";
36
- /**
37
- * Parse a `--services` value into a canonical service list: comma-separated
38
- * {@link NEON_SERVICES} names, or {@link NO_SERVICES} on its own for none.
39
- *
40
- * Unknown names are rejected here rather than silently dropped — a typo'd service would
41
- * otherwise scaffold a policy missing exactly the service the user asked for. The result is
42
- * deduplicated and ordered by {@link NEON_SERVICES} so the rendered file doesn't depend on the
43
- * order they were typed in.
44
- */
45
- export const parseServices = (raw) => {
46
- const names = raw
47
- .split(",")
48
- .map((name) => name.trim())
49
- .filter((name) => name !== "");
50
- if (names.includes(NO_SERVICES)) {
51
- if (names.length > 1) {
52
- throw new Error(`--services ${NO_SERVICES} cannot be combined with other services.`);
53
- }
54
- return [];
55
- }
56
- const unknown = names.filter((name) => !NEON_SERVICES.includes(name));
57
- if (unknown.length > 0) {
58
- throw new Error(`Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}. ` +
59
- `Supported values: ${NEON_SERVICES.join(", ")}, ${NO_SERVICES}.`);
60
- }
61
- return NEON_SERVICES.filter((service) => names.includes(service));
62
- };
63
41
  /**
64
42
  * One indentation level in the emitted `neon.ts`. Two spaces, which is what every renderer
65
43
  * here produces and what `config_template.format.test.ts` holds them to.
@@ -88,7 +66,7 @@ const renderPreview = (services) => {
88
66
  ...at(3, `${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`),
89
67
  ]));
90
68
  }
91
- if (services.includes("storage")) {
69
+ if (services.includes("object-storage")) {
92
70
  lines.push(...block(2, "buckets", [
93
71
  ...at(3, `// "private" is the default; use "public_read" for anonymous reads`, `${BUCKET_NAME}: { access: "private" },`),
94
72
  ]));
@@ -1,5 +1,6 @@
1
1
  import { contextBranch, currentContextFile, readContextFile, } from "./context.js";
2
2
  import { log } from "./log.js";
3
+ import { getCliName } from "./utils/cli_name.js";
3
4
  /**
4
5
  * Offline fast path for `(config) status --current-branch` (used by shell prompts).
5
6
  *
@@ -35,7 +36,7 @@ cwd = process.cwd()) => {
35
36
  process.stdout.write(`${branch}\n`);
36
37
  }
37
38
  else {
38
- log.info("No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.");
39
+ log.info(`No branch pinned. Run \`${getCliName()} checkout <branch>\` to pin a branch and pull its env vars.`);
39
40
  process.exitCode = 1;
40
41
  }
41
42
  return true;
package/dist/dev/env.js CHANGED
@@ -1,7 +1,9 @@
1
- import { loadConfigFromFile } from "@neon/config";
1
+ import { createNeonApiFromOptions, loadConfigFromFile, } from "@neon/config";
2
2
  import { plan, pullConfig } from "@neon/config-runtime";
3
+ import { NEON_ENV_VAR_KEYS } from "@neon/env";
3
4
  import { fetchEnvReusingSecrets, } from "@neon/env/runtime";
4
5
  import { log } from "../log.js";
6
+ import { getCliName } from "../utils/cli_name.js";
5
7
  /** The API-targeting options every runtime call forwards from the context. */
6
8
  const apiOptions = (ctx) => ({
7
9
  ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
@@ -33,6 +35,18 @@ export class MissingBranchContextError extends Error {
33
35
  this.name = "MissingBranchContextError";
34
36
  }
35
37
  }
38
+ /**
39
+ * Thrown when an explicit `--service` selection names a service the branch does not have.
40
+ * Unlike the policy path — where the same situation is a {@link DevEnvMismatchError} pointing
41
+ * at `deploy` — the user named the service on the command line, so the fix is to provision it
42
+ * or drop it from the selection.
43
+ */
44
+ export class ServiceNotOnBranchError extends Error {
45
+ constructor() {
46
+ super(...arguments);
47
+ this.name = "ServiceNotOnBranchError";
48
+ }
49
+ }
36
50
  /**
37
51
  * Resolve the branch's Neon env vars (pooled / direct `DATABASE_URL`, plus Auth /
38
52
  * Data API when enabled) into a `{ KEY: value }` map. Shared by `neon dev` (which
@@ -40,6 +54,8 @@ export class MissingBranchContextError extends Error {
40
54
  *
41
55
  * Tiered:
42
56
  *
57
+ * 0. {@link DevEnvContext.services} is set -> that selection *is* the policy, and any
58
+ * `neon.ts` is ignored. See {@link resolveSelectedServices}.
43
59
  * 1. a `neon.ts` policy is found -> the policy is the source of truth. We first
44
60
  * check it against the branch's live state (`plan`); if it declares a resource
45
61
  * the branch is missing, we stop with a {@link DevEnvMismatchError} pointing at
@@ -48,17 +64,22 @@ export class MissingBranchContextError extends Error {
48
64
  * branch's live state (Auth / Data API enablement plus any object-storage
49
65
  * buckets) into a config, then `fetchEnv` resolves what is actually enabled —
50
66
  * so a branch with a bucket gets its `AWS_*` storage vars pulled with no policy.
67
+ * With {@link DevEnvContext.implyAiGateway}, the AI Gateway is added on top, since
68
+ * `pullConfig` cannot read it back.
51
69
  * 3. otherwise -> throw {@link MissingBranchContextError}.
52
70
  *
53
71
  * Unlike {@link resolveDevEnv}, this never swallows errors — callers decide how to
54
72
  * handle them.
55
73
  */
56
74
  export const resolveNeonEnvVars = async (ctx) => {
75
+ if (ctx.services) {
76
+ return await resolveSelectedServices(ctx, ctx.services);
77
+ }
57
78
  const config = await loadNeonConfig(ctx.cwd);
58
79
  if (config) {
59
80
  if (!ctx.projectId || !ctx.branchId) {
60
81
  throw new MissingBranchContextError("Found a neon.ts but could not resolve the project/branch. " +
61
- "Run `neonctl link` and `neonctl checkout <branch>`, or pass " +
82
+ `Run \`${getCliName()} link\` and \`${getCliName()} checkout <branch>\`, or pass ` +
62
83
  "--project-id / --branch.");
63
84
  }
64
85
  // Resolve env from the policy with its `preview.functions` removed. Functions carry no
@@ -84,10 +105,192 @@ export const resolveNeonEnvVars = async (ctx) => {
84
105
  // straight into fetchEnv — no wrapping needed. pullConfig excludes functions and
85
106
  // the AI Gateway (neither can be faithfully read back), so fetchEnv never probes
86
107
  // the functions API here and only mints a storage credential when a bucket exists.
87
- return await fetchAndProject(pulled.config, ctx);
108
+ if (!ctx.implyAiGateway) {
109
+ return await fetchAndProject(pulled.config, ctx);
110
+ }
111
+ return await resolveWithImpliedGateway(pulled.config, ctx, {
112
+ projectId: ctx.projectId,
113
+ branchId: ctx.branchId,
114
+ });
115
+ }
116
+ throw new MissingBranchContextError(`No project/branch context found. Link a branch (\`${getCliName()} link\` / ` +
117
+ `\`${getCliName()} checkout\`) or pass --project-id and --branch.`);
118
+ };
119
+ /** The same config with the AI Gateway enabled, leaving any other `preview` entries intact. */
120
+ const withAiGateway = (config) => ({
121
+ ...config,
122
+ preview: { ...config.preview, aiGateway: true },
123
+ });
124
+ /**
125
+ * Tier-2 resolution with the AI Gateway added on top of the branch's read-back state.
126
+ *
127
+ * The gateway is not detectable — `pullConfig` reports no enabled flag for it — so it is
128
+ * implied rather than observed. Nobody named it, so it must never be the reason the whole
129
+ * resolve fails: a project outside the regions where branch credentials exist would otherwise
130
+ * lose its `DATABASE_URL` too, and `neon dev` would start with no env at all.
131
+ *
132
+ * So the gateway is only added once its credential endpoint has been shown to answer, by
133
+ * reading the branch's credentials first. A project that does not have them says so on a
134
+ * read, before anything is minted — which is the whole question, since the gateway's env is a
135
+ * credential and nothing else.
136
+ *
137
+ * Deciding this **before** resolving, rather than by catching and retrying, is what keeps it
138
+ * honest. A retry re-runs every call the first attempt made, so it would blame the gateway for
139
+ * a one-off failure in shared work, and — worse — a first attempt that minted a credential and
140
+ * then failed would be papered over by a second that succeeds without one, swallowing the
141
+ * error and stranding a secret nobody holds. Once the read succeeds, a later failure is a real
142
+ * failure and propagates: the same thing already happens on a branch with object storage,
143
+ * whose credential is minted whether or not the gateway is involved.
144
+ */
145
+ const resolveWithImpliedGateway = async (config, ctx,
146
+ /** Resolved by the caller, which is the branch this env belongs to. */
147
+ branch) => {
148
+ const unreachable = await credentialsUnreachable(ctx, branch);
149
+ if (unreachable === null) {
150
+ return await fetchAndProject(withAiGateway(config), ctx);
151
+ }
152
+ // Deliberately does not assert that the project lacks the gateway: a read can also fail
153
+ // for a reason that has nothing to do with the feature, and this is not the place to
154
+ // guess which. Name both, and the command that answers it.
155
+ log.warning("Could not reach the AI Gateway's credentials, so %s were not resolved. Everything " +
156
+ "else was. Either this project does not have the AI Gateway, or the call failed — " +
157
+ `\`${getCliName()} env pull -s ai-gateway\` will say which.\nDetails: %s`, [
158
+ NEON_ENV_VAR_KEYS.aiGateway.apiKey,
159
+ NEON_ENV_VAR_KEYS.aiGateway.baseUrl,
160
+ ].join(" and "), unreachable);
161
+ return {
162
+ ...(await fetchAndProject(config, ctx)),
163
+ skipped: ["ai-gateway"],
164
+ };
165
+ };
166
+ /**
167
+ * Why the branch's credentials could not be read, or `null` when they could. A plain read: it
168
+ * mints nothing, revokes nothing, and changes nothing, so asking is free of the side effects
169
+ * that make a failed resolve ambiguous.
170
+ */
171
+ const credentialsUnreachable = async (ctx, branch) => {
172
+ try {
173
+ await apiFor(ctx).listCredentials(branch.projectId, branch.branchId);
174
+ return null;
175
+ }
176
+ catch (err) {
177
+ return err instanceof Error ? err.message : String(err);
178
+ }
179
+ };
180
+ /** The adapter for direct branch reads: the injected one in tests, else built from options. */
181
+ const apiFor = (ctx) => ctx.api ??
182
+ createNeonApiFromOptions("neon env", {
183
+ ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
184
+ ...(ctx.apiHost ? { apiHost: ctx.apiHost } : {}),
185
+ });
186
+ /**
187
+ * Tier-0: resolve exactly the services `--service` named, with `neon.ts` out of the picture.
188
+ *
189
+ * The selection is checked against the branch's live state so a service that is named but not
190
+ * provisioned fails by name, instead of quietly contributing no vars. `postgres` and the AI
191
+ * Gateway are not checked: every branch has Postgres, and the gateway has no branch-level
192
+ * state to check (an unavailable one surfaces when its credential is minted).
193
+ */
194
+ const resolveSelectedServices = async (ctx, services) => {
195
+ const { projectId, branchId } = ctx;
196
+ if (!projectId || !branchId) {
197
+ throw new MissingBranchContextError("--service needs a project and branch to read from. " +
198
+ `Run \`${getCliName()} link\` and \`${getCliName()} checkout <branch>\`, or pass ` +
199
+ "--project-id / --branch.");
200
+ }
201
+ // Read only the services that were named, rather than going through `pullConfig`. That
202
+ // keeps a selection independent of everything else on the branch — `pullConfig` also
203
+ // enumerates functions and credentials, so a failure there would abort `-s auth` — and it
204
+ // keeps an "object storage isn't available for this project" error intact, which
205
+ // `pullConfig` degrades to an empty bucket list and would report as "no buckets".
206
+ const api = apiFor(ctx);
207
+ const has = (service) => services.includes(service);
208
+ const [auth, dataApiEnabled, buckets] = await Promise.all([
209
+ has("auth") ? api.getNeonAuth(projectId, branchId) : null,
210
+ has("data-api") ? readDataApiEnabled(api, projectId, branchId) : null,
211
+ has("object-storage")
212
+ ? api.listBranchBuckets(projectId, branchId)
213
+ : null,
214
+ ]);
215
+ const config = configForServices(services, branchId, {
216
+ authEnabled: auth !== null,
217
+ dataApiEnabled,
218
+ buckets: buckets ?? [],
219
+ });
220
+ // A selection resolves part of the branch, so it must not revoke: the credential its
221
+ // persisted secrets name may also back a service it is not resolving. See
222
+ // `fetchEnvReusingSecrets`'s `revokeSuperseded`.
223
+ return await fetchAndProject(config, ctx, { revokeSuperseded: false });
224
+ };
225
+ /**
226
+ * Whether the branch has a Data API integration — or `null` when that cannot be determined.
227
+ *
228
+ * It is enabled per branch *and database*, so this has to probe the database `fetchEnv` will
229
+ * resolve the URL from, or the two would disagree. That is Neon's default `neondb`, else the
230
+ * only database; several databases with no `neondb` is a case `fetchEnv` refuses to auto-pick
231
+ * at all. Reporting "no Data API integration" there would be a claim this read cannot support,
232
+ * so it answers `null` and lets `fetchEnv` raise its own ambiguity error, which names the
233
+ * databases and the fix.
234
+ */
235
+ const readDataApiEnabled = async (api, projectId, branchId) => {
236
+ const databases = await api.listBranchDatabases(projectId, branchId);
237
+ const database = databases.find((db) => db.name === NEON_DEFAULT_DATABASE) ??
238
+ (databases.length === 1 ? databases[0] : undefined);
239
+ if (!database)
240
+ return databases.length === 0 ? false : null;
241
+ const dataApi = await api.getNeonDataApi(projectId, branchId, database.name);
242
+ return dataApi !== null;
243
+ };
244
+ /** Neon's default database, and the one `fetchEnv` prefers when a branch has several. */
245
+ const NEON_DEFAULT_DATABASE = "neondb";
246
+ /**
247
+ * Build the `Config` an explicit `--service` selection stands for, raising
248
+ * {@link ServiceNotOnBranchError} for anything the branch does not have. Naming a service
249
+ * that isn't there has to fail rather than contribute no vars, or a scoped pull would report
250
+ * "no Neon env variables to pull" — which reads as a statement about the branch rather than
251
+ * about the selection.
252
+ */
253
+ const configForServices = (services, branchId, branch) => {
254
+ // The command that provisions each one, for a user who may well have no `neon.ts` — in
255
+ // which case `deploy` / `config apply` would be no help at all.
256
+ const provisionWith = {
257
+ auth: `${getCliName()} neon-auth enable`,
258
+ "data-api": `${getCliName()} data-api create`,
259
+ "object-storage": `${getCliName()} buckets create <name>`,
260
+ };
261
+ const notOnBranch = (service, what) => {
262
+ throw new ServiceNotOnBranchError(`--service ${service}: branch ${branchId} has no ${what}, so there are no ` +
263
+ `${service} env vars to pull. Provision it first (\`${provisionWith[service]}\`, ` +
264
+ `or in the Neon Console), or drop ${service} from --service.`);
265
+ };
266
+ const config = {};
267
+ if (services.includes("auth")) {
268
+ if (!branch.authEnabled)
269
+ notOnBranch("auth", "Neon Auth integration");
270
+ config.auth = true;
271
+ }
272
+ if (services.includes("data-api")) {
273
+ // Only a positive "not there" is an error; an undecidable read defers to `fetchEnv`.
274
+ if (branch.dataApiEnabled === false) {
275
+ notOnBranch("data-api", "Data API integration");
276
+ }
277
+ config.dataApi = true;
278
+ }
279
+ const preview = {};
280
+ if (services.includes("object-storage")) {
281
+ if (branch.buckets.length === 0) {
282
+ notOnBranch("object-storage", "object-storage buckets");
283
+ }
284
+ preview.buckets = Object.fromEntries(branch.buckets.map((bucket) => [
285
+ bucket.name,
286
+ { access: bucket.accessLevel },
287
+ ]));
88
288
  }
89
- throw new MissingBranchContextError("No project/branch context found. Link a branch (`neonctl link` / " +
90
- "`neonctl checkout`) or pass --project-id and --branch.");
289
+ if (services.includes("ai-gateway"))
290
+ preview.aiGateway = true;
291
+ if (Object.keys(preview).length > 0)
292
+ config.preview = preview;
293
+ return config;
91
294
  };
92
295
  /**
93
296
  * `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
@@ -113,8 +316,8 @@ export const resolveDevEnv = async (ctx) => {
113
316
  return {
114
317
  vars: {},
115
318
  skipped: {
116
- reason: "no linked Neon branch — run `neonctl link`, then " +
117
- "`neonctl checkout <branch>`, to inject DATABASE_URL and friends",
319
+ reason: `no linked Neon branch — run \`${getCliName()} link\`, then ` +
320
+ `\`${getCliName()} checkout <branch>\`, to inject DATABASE_URL and friends`,
118
321
  },
119
322
  };
120
323
  }
@@ -167,8 +370,8 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
167
370
  const names = missing.map((change) => change.identifier).join(", ");
168
371
  throw new DevEnvMismatchError(`Your neon.ts declares ${names} for branch ${ctx.branchId}, but the branch ` +
169
372
  "does not have it yet, so the matching env vars cannot be injected. " +
170
- "Provision it first with `neonctl deploy` (or `neonctl config apply`), " +
171
- "then re-run `neonctl dev`.");
373
+ `Provision it first with \`${getCliName()} deploy\` (or \`${getCliName()} config apply\`), ` +
374
+ `then re-run \`${getCliName()} dev\`.`);
172
375
  };
173
376
  /**
174
377
  * A planned change that provisions a branch-level resource the branch lacks: a
@@ -179,11 +382,12 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
179
382
  const isMissingResource = (change) => change.kind === "service" &&
180
383
  change.action === "create" &&
181
384
  !change.identifier.startsWith("function:");
182
- const fetchAndProject = async (config, ctx) => fetchEnvReusingSecrets(config, {
385
+ const fetchAndProject = async (config, ctx, opts = {}) => fetchEnvReusingSecrets(config, {
183
386
  projectId: ctx.projectId,
184
387
  branch: ctx.branchId,
185
388
  ...apiOptions(ctx),
186
389
  ...(ctx.env ? { env: ctx.env } : {}),
390
+ ...(opts.revokeSuperseded === false ? { revokeSuperseded: false } : {}),
187
391
  });
188
392
  /**
189
393
  * Load a `neon.ts` policy if one exists on the path from `cwd` up to the repo
@@ -0,0 +1,51 @@
1
+ import { NEON_ENV_VAR_KEYS } from "@neon/env";
2
+ import { NEON_SERVICES } from "./neon_services.js";
3
+ /**
4
+ * The services `env pull --service` can select: every Neon service that produces branch env
5
+ * vars. `functions` is the one left out — a function's env comes from the local `neon.ts`,
6
+ * never from the branch, so there is nothing to pull.
7
+ */
8
+ export const ENV_PULL_SERVICES = NEON_SERVICES.filter((service) => service !== "functions");
9
+ /** Why the services `env pull` leaves out are not selectable, for the refusal message. */
10
+ export const ENV_PULL_UNAVAILABLE = {
11
+ functions: "a function's env comes from your neon.ts, not from the branch, so there is nothing to pull",
12
+ };
13
+ /** The OS-level env vars each service contributes to a pulled `.env`. */
14
+ const SERVICE_ENV_KEYS = {
15
+ postgres: Object.values(NEON_ENV_VAR_KEYS.postgres),
16
+ auth: Object.values(NEON_ENV_VAR_KEYS.auth),
17
+ "data-api": Object.values(NEON_ENV_VAR_KEYS.dataApi),
18
+ "object-storage": Object.values(NEON_ENV_VAR_KEYS.storage),
19
+ "ai-gateway": Object.values(NEON_ENV_VAR_KEYS.aiGateway),
20
+ functions: [],
21
+ };
22
+ /**
23
+ * The subset of {@link SERVICE_ENV_KEYS} a pull *owns*, and so may prune from the target file
24
+ * when the branch no longer has it. Object storage is deliberately absent: it is emitted under
25
+ * the third-party `AWS_*` names, which collide with credentials a user may set by hand, so
26
+ * `env pull` only ever writes them.
27
+ */
28
+ const SERVICE_OWNED_ENV_KEYS = {
29
+ ...SERVICE_ENV_KEYS,
30
+ "object-storage": [],
31
+ };
32
+ /**
33
+ * Branch identity. Not a service — every branch has a name — so a scoped pull refreshes it
34
+ * alongside whatever services were selected.
35
+ */
36
+ export const BRANCH_ENV_KEY = NEON_ENV_VAR_KEYS.branch.name;
37
+ /** Every env var the selected services contribute, plus branch identity. */
38
+ export const envServiceKeys = (services) => {
39
+ const keys = new Set([BRANCH_ENV_KEY]);
40
+ for (const service of services) {
41
+ for (const key of SERVICE_ENV_KEYS[service])
42
+ keys.add(key);
43
+ }
44
+ return keys;
45
+ };
46
+ /**
47
+ * The env vars a pull scoped to `services` may prune. Narrower than the unscoped set on
48
+ * purpose: `env pull -s ai-gateway` says nothing about `DATABASE_URL`, so it must leave it
49
+ * alone rather than treat its absence from this pull as "the branch no longer has it".
50
+ */
51
+ export const ownedEnvServiceKeys = (services) => services.flatMap((service) => SERVICE_OWNED_ENV_KEYS[service]);
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { basename } from "node:path";
2
1
  import yargs from "yargs";
3
2
  import { hideBin } from "yargs/helpers";
4
3
  import { analyticsMiddleware, closeAnalytics, getAnalyticsEventProperties, initAnalyticsClientMiddleware, sendError, trackEvent, } from "./analytics.js";
@@ -13,6 +12,7 @@ import { isNetworkError, matchErrorCode, NETWORK_ERROR_MESSAGE, } from "./errors
13
12
  import { showHelp } from "./help.js";
14
13
  import { log } from "./log.js";
15
14
  import pkg from "./pkg.js";
15
+ import { getCliName } from "./utils/cli_name.js";
16
16
  import { fillInArgs, resolveApiKeyFromEnv } from "./utils/middlewares.js";
17
17
  const NO_SUBCOMMANDS_VERBS = [
18
18
  // `api <path>` has a handler but no subcommands (like `status`), so the
@@ -159,7 +159,7 @@ builder = builder
159
159
  .group("version", "Global options:")
160
160
  .alias("version", "v")
161
161
  .completion()
162
- .scriptName(basename(process.argv[1]) === "neon" ? "neon" : "neonctl")
162
+ .scriptName(getCliName())
163
163
  .epilog("For more information, visit https://neon.com/docs/reference/neon-cli")
164
164
  .wrap(null)
165
165
  .fail(false);