neon 2.46.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.
package/README.md CHANGED
@@ -417,9 +417,15 @@ The human-readable summary line goes to stderr and the diff body to stdout, so `
417
417
 
418
418
  ### env pull
419
419
 
420
- `env pull` writes the linked branch's Neon environment variables into a local dotenv file: an existing `.env` if you have one, otherwise `.env.local` (override with `--file <path>`). Only Neon-managed keys (`DATABASE_URL`, `DATABASE_URL_UNPOOLED`, and the Neon Auth / Data API URLs when those services are enabled) are written; any other lines in the file are preserved. The branch comes from the closest `.neon` file, so no `--branch` is needed (pass `--branch <id|name>` to target another branch).
420
+ `env pull` writes the linked branch's Neon environment variables into a local dotenv file: an existing `.env` if you have one, otherwise `.env.local` (override with `--file <path>`). Only Neon-managed keys are written (see the table below); any other lines in the file are preserved. The branch comes from the closest `.neon` file, so no `--branch` is needed (pass `--branch <id|name>` to target another branch).
421
421
 
422
- `link` and `checkout` invoke `env pull` automatically (see above), so you usually only run it by hand to refresh vars or to pull a different branch into a specific file:
422
+ **What gets pulled**, in precedence order:
423
+
424
+ 1. **`--service`**, when you pass it — exactly those services, whatever else is on the branch and whatever a `neon.ts` says.
425
+ 2. **`neon.ts`**, when the working directory has one — the policy is the source of truth, same as `neon dev` and `neon deploy`.
426
+ 3. **Everything the branch has** otherwise — Postgres, Neon Auth, the Data API, and object storage read back from the branch, plus the AI Gateway. The gateway has no branch-level state to read back (it is credential-gated, not provisioned), so a bare `env pull` asks for it rather than detecting it, which mints a branch credential. To leave it out, name the services you do want with `--service`.
427
+
428
+ If the gateway can't be resolved, it is dropped with a warning and the rest of the pull still lands. Gateway variables already in your file for *this* branch are left alone — a pull that couldn't reach the gateway is no evidence the branch has stopped having one — while ones left over from a different branch are pruned like any other stale value.
423
429
 
424
430
  ```bash
425
431
  # Refresh the linked branch's vars in place
@@ -427,10 +433,45 @@ neon env pull
427
433
 
428
434
  # Pull a specific branch into a specific file
429
435
  neon env pull --branch preview --file .env.preview
436
+
437
+ # Only the AI Gateway
438
+ neon env pull --service ai-gateway
439
+
440
+ # Repeat the flag or comma-separate; -s, --service and --services are all accepted
441
+ neon env pull -s postgres -s data-api
442
+ neon env pull -s postgres,auth
443
+ ```
444
+
445
+ Every services flag in the CLI takes those three spellings, the same value syntax, and the same service names — see [`config init --services`](#getting-a-neonts-config-init).
446
+
447
+ | `--service` | Variables |
448
+ | --- | --- |
449
+ | `postgres` | `DATABASE_URL`, `DATABASE_URL_UNPOOLED` |
450
+ | `auth` | `NEON_AUTH_BASE_URL`, `NEON_AUTH_JWKS_URL` |
451
+ | `data-api` | `NEON_DATA_API_URL` |
452
+ | `object-storage` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `AWS_REGION` |
453
+ | `ai-gateway` | `NEON_AI_GATEWAY_TOKEN`, `NEON_AI_GATEWAY_BASE_URL` |
454
+
455
+ `NEON_BRANCH` is written by every pull — it is branch identity, not a service.
456
+
457
+ **A scoped pull is scoped in both directions.** An unscoped `env pull` owns the Neon-named variables: pointing a directory at a branch without Neon Auth prunes the stale `NEON_AUTH_*` lines. `--service` narrows that to the services you named, so `env pull -s ai-gateway` never touches your `DATABASE_URL`. (`AWS_*` is never pruned by any pull: those names collide with credentials you may set yourself, so `env pull` only ever writes them.)
458
+
459
+ **A scoped pull also never revokes a credential.** Where an unscoped pull revokes the credential it replaces, a scoped one leaves the old one live — it can't tell which other services still use it. It says so when it happens; revoke it in the Neon Console if nothing does.
460
+
461
+ Naming a service the branch does not have is an error, not an empty pull:
462
+
463
+ ```
464
+ --service auth: branch br-snowy-frost-12345 has no Neon Auth integration, so there are no
465
+ auth env vars to pull. Provision it first (`neon deploy`, `neon config apply`, or the Neon
466
+ Console), or drop auth from --service.
430
467
  ```
431
468
 
469
+ `link`, `checkout`, and `config apply` invoke `env pull` automatically (see above). Those bundled pulls follow rules 2 and 3 above **without** the implied AI Gateway: minting a credential for a service you never named isn't something a side effect of another command should do. Run `neon env pull` to get it.
470
+
432
471
  If you'd rather not keep env vars on disk, inject them at runtime instead with `neon-env run -- <your dev command>` (from `@neon/env`) or `neon dev`, and pass `--no-env-pull` to `link` / `checkout`.
433
472
 
473
+ **`neon dev` resolves the same set, by the same rules** — including the AI Gateway on a branch with no `neon.ts`. A function running locally gets what the deployed runtime would inject into it, which is the whole point of `dev`; a handler that reads `NEON_AI_GATEWAY_BASE_URL` should not work in production and fail on your machine. `dev` writes nothing, but it does *read* your `.env` / `.env.local` to reuse the branch credential behind the AI Gateway and object storage. Without a file to read from it issues one on every start and leaves the last one live — it has nowhere to keep it, and so cannot name it to revoke it. It says so when it happens; run `env pull` (or just `link` / `checkout`) once and restarts reuse the credential instead.
474
+
434
475
  **Where `.neon` lives**: `link` writes `.neon` into the **current working directory** by default. If an existing `.neon` is found in any parent directory, that file is reused — so commands run from a sub-directory of a linked project still pick up the project's context. To pin the location explicitly, pass `--context-file <path>`.
435
476
 
436
477
  **`.gitignore` scaffolding**: when `.neon` is **created** for the first time, the CLI also makes sure a `.gitignore` sits alongside it listing `.neon`. If `.gitignore` doesn't exist it's created with a single `.neon` line; if it does exist, `.neon` is appended only when missing (no duplicates, your other entries are left alone). On subsequent updates to an existing `.neon`, `.gitignore` is left untouched — so if you deliberately un-ignore `.neon` (e.g. to commit shared context), the entry is not re-added on every command.
@@ -479,7 +520,10 @@ Selecting nothing is a valid answer: you get the starter policy, which is also w
479
520
  neon config init
480
521
 
481
522
  # Declare services with no prompt
482
- neon config init --services auth,functions,storage,ai-gateway
523
+ neon config init --services auth,functions,object-storage,ai-gateway
524
+
525
+ # Repeat the flag instead, and shorten it — every services flag takes all three spellings
526
+ neon config init -s auth -s functions
483
527
 
484
528
  # Explicitly ask for the bare starter policy
485
529
  neon config init --services none
@@ -488,6 +532,8 @@ neon config init --services none
488
532
  neon config init --no-install
489
533
  ```
490
534
 
535
+ Object storage is spelled `object-storage` here, matching [`env pull --service`](#env-pull) and the rest of the CLI. The old `storage` still works and warns; it will be removed.
536
+
491
537
  Choosing **Functions** also writes the handler the policy points at, since `source` is only resolved when `apply` bundles it — a declared function with no file on disk fails at deploy:
492
538
 
493
539
  ```ts
package/dist/analytics.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Analytics } from "@segment/analytics-node";
2
- import { inspectCredentials } from "./_shared/credentials.js";
2
+ import { inspectCredentials, OAUTH } from "./_shared/credentials.js";
3
3
  import { getApiClient, isNeonApiError } from "./api.js";
4
4
  import { getAuthContext } from "./auth_context.js";
5
5
  import { credentialsPath } from "./config.js";
@@ -14,6 +14,63 @@ const WRITE_KEY = "3SQXn5ejjXWLEJ8xU2PRYhAotLtTaeeV";
14
14
  * not be populated yet, so we also scan `process.argv` directly to be safe.
15
15
  */
16
16
  const hasCurrentBranchArgv = () => process.argv.includes("--current-branch");
17
+ const ANONYMOUS = "anonymous";
18
+ /**
19
+ * Who to attribute an event to, given whatever identified this invocation.
20
+ *
21
+ * Nothing is guaranteed to have identified it: a command can run with no credentials at all,
22
+ * leaving the id empty. Segment accepts an empty `userId` and forwards it as-is rather than
23
+ * rejecting it, so the substitution has to happen here. `""` is falsy but not nullish, which
24
+ * is why the fallback has to be `||`.
25
+ *
26
+ * Exported for tests.
27
+ */
28
+ export const analyticsUserId = (userId) => userId || ANONYMOUS;
29
+ /**
30
+ * The account an invocation that presented no API key may claim, which is nothing at all
31
+ * unless stored credentials named a user.
32
+ *
33
+ * Both fields are omitted together. An empty account reported under a named method describes
34
+ * an authentication that did not happen, which is worse than reporting neither.
35
+ *
36
+ * Exported for tests.
37
+ */
38
+ export const storedCredentialAttribution = (storedUserId) => storedUserId ? { accountId: storedUserId, authMethod: OAUTH } : {};
39
+ /**
40
+ * Which credential telemetry may describe this invocation with.
41
+ *
42
+ * `ensureAuth` records a context only when it selected a credential for this invocation, so a
43
+ * missing context means the global auth middleware selected nothing before this ran. A key
44
+ * sitting in `args.apiKey` is then not the credential the middleware chose — `neon profile list`
45
+ * never used it — and must not be queried on its behalf, which would attribute the run to an
46
+ * account it never authenticated as and add a telemetry-only API call. The local default is the
47
+ * guess.
48
+ *
49
+ * The boundary is deliberately the credential the middleware selected, not every key a handler
50
+ * may go on to use. Several `profile` subcommands authenticate inside their own handlers —
51
+ * `create --api-key` verifies the key it is about to store, `rotate-key` mints and revokes — and
52
+ * those runs are attributed to the local default rather than to the account the handler talked
53
+ * to. Attributing them to that key puts an `identify` for the signed-in user beside an
54
+ * `accountId` for a different account.
55
+ *
56
+ * A selected key records no file, because it authenticates as its own account rather than out
57
+ * of one. Reading `DEFAULT` for it would identify the run as whoever is signed in locally, and
58
+ * that borrowed id would suppress the API lookup that names the key's real owner.
59
+ *
60
+ * Exported for tests.
61
+ */
62
+ export const telemetryCredential = (authContext, apiKey, defaultCredentialsPath) => {
63
+ if (authContext === null) {
64
+ return { credentialsPath: defaultCredentialsPath };
65
+ }
66
+ if (authContext.source === "api-key") {
67
+ return { apiKey };
68
+ }
69
+ return {
70
+ apiKey,
71
+ credentialsPath: authContext.credentialsPath ?? defaultCredentialsPath,
72
+ };
73
+ };
17
74
  let client;
18
75
  let clientInitialized = false;
19
76
  let userId = "";
@@ -42,7 +99,7 @@ export const initAnalyticsClientMiddleware = (args) => {
42
99
  });
43
100
  log.debug("Initialized CLI analytics client");
44
101
  client.identify({
45
- userId: "anonymous",
102
+ userId: ANONYMOUS,
46
103
  });
47
104
  };
48
105
  /**
@@ -56,28 +113,27 @@ export const analyticsMiddleware = async (args) => {
56
113
  if (isCurrentBranchProbe(args)) {
57
114
  return;
58
115
  }
59
- // Read the credentials this invocation actually authenticated with, which `ensureAuth`
60
- // recorded. Reading `DEFAULT`'s unconditionally attributed every `--profile`-selected
61
- // command to whichever account happened to be the default one.
62
- const authenticatedAs = getAuthContext()?.credentialsPath ?? credentialsPath(args.configDir);
63
- // Telemetry must never turn a damaged or unreadable credentials file into a failed command.
64
- try {
65
- const read = inspectCredentials(authenticatedAs);
66
- if (read.kind === "ok" &&
67
- typeof read.credentials.user_id === "string") {
68
- userId = read.credentials.user_id;
116
+ const { apiKey: keyToQuery, credentialsPath: fileToRead } = telemetryCredential(getAuthContext(), args.apiKey, credentialsPath(args.configDir));
117
+ if (fileToRead !== undefined) {
118
+ // Telemetry must never turn a damaged or unreadable credentials file into a failed command.
119
+ try {
120
+ const read = inspectCredentials(fileToRead);
121
+ if (read.kind === "ok" &&
122
+ typeof read.credentials.user_id === "string") {
123
+ userId = read.credentials.user_id;
124
+ }
125
+ else if (read.kind !== "ok") {
126
+ log.debug("No usable credentials at %s", fileToRead);
127
+ }
69
128
  }
70
- else if (read.kind !== "ok") {
71
- log.debug("No usable credentials at %s", authenticatedAs);
129
+ catch (err) {
130
+ log.debug("Could not read %s: %s", fileToRead, err);
72
131
  }
73
132
  }
74
- catch (err) {
75
- log.debug("Could not read %s: %s", authenticatedAs, err);
76
- }
77
133
  try {
78
- if (args.apiKey) {
134
+ if (keyToQuery) {
79
135
  const apiClient = getApiClient({
80
- apiKey: args.apiKey,
136
+ apiKey: keyToQuery,
81
137
  apiHost: args.apiHost,
82
138
  });
83
139
  // Populating api key details for analytics
@@ -93,18 +149,19 @@ export const analyticsMiddleware = async (args) => {
93
149
  }
94
150
  }
95
151
  else {
96
- args.accountId = userId;
97
- args.authMethod = "oauth";
152
+ const { accountId, authMethod } = storedCredentialAttribution(userId);
153
+ args.accountId = accountId;
154
+ args.authMethod = authMethod;
98
155
  }
99
156
  }
100
157
  catch (err) {
101
158
  log.debug("Failed to get user id from api", err);
102
159
  }
103
160
  client.identify({
104
- userId: userId?.toString() ?? "anonymous",
161
+ userId: analyticsUserId(userId),
105
162
  });
106
163
  client.track({
107
- userId: userId || "anonymous",
164
+ userId: analyticsUserId(userId),
108
165
  event: "CLI Started",
109
166
  properties: getAnalyticsEventProperties(args),
110
167
  context: {
@@ -145,7 +202,7 @@ export const sendError = (err, errCode) => {
145
202
  }
146
203
  client.track({
147
204
  event: "CLI Error",
148
- userId: userId || "anonymous",
205
+ userId: analyticsUserId(userId),
149
206
  properties: getErrorAnalyticsEventProperties(err, errCode, errorEventContext),
150
207
  });
151
208
  log.debug("Sent CLI error event: %s", errCode);
@@ -156,7 +213,7 @@ export const trackEvent = (event, properties) => {
156
213
  }
157
214
  client.track({
158
215
  event,
159
- userId: userId || "anonymous",
216
+ userId: analyticsUserId(userId),
160
217
  properties,
161
218
  });
162
219
  log.debug("Sent CLI event: %s", event);
@@ -5,11 +5,12 @@ import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranch
5
5
  import chalk from "chalk";
6
6
  import { getApiClient } from "../api.js";
7
7
  import { toNeonConfigView } from "../config_format.js";
8
- import { FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, NEON_SERVICES, NO_SERVICES, parseServices, REQUIRED_PACKAGES, renderNeonConfig, renderNeonConfigFromView, } from "../config_template.js";
8
+ import { CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, CONFIG_INIT_UNAVAILABLE, FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, renderNeonConfig, renderNeonConfigFromView, } from "../config_template.js";
9
9
  import { contextBranch, readContextFile } from "../context.js";
10
10
  import { isCi } from "../env.js";
11
11
  import { loadEnvFileIntoProcess } from "../env_file.js";
12
12
  import { log } from "../log.js";
13
+ import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption, } from "../neon_services.js";
13
14
  import { assertAiGatewayProvisionable, warnAiGateway, } from "../utils/ai_gateway_notice.js";
14
15
  import { announceTargetBranch } from "../utils/branch_notice.js";
15
16
  import { getCliName } from "../utils/cli_name.js";
@@ -120,7 +121,13 @@ const missingDependencies = (cwd) => {
120
121
  */
121
122
  const resolveServices = async (props) => {
122
123
  if (props.services !== undefined) {
123
- return parseServices(props.services);
124
+ return parseServices(props.services, {
125
+ allowed: CONFIG_INIT_SERVICES,
126
+ whyUnavailable: CONFIG_INIT_UNAVAILABLE,
127
+ flag: "--services",
128
+ noneMeans: CONFIG_INIT_NONE_MEANS,
129
+ onDeprecated: (used, canonical) => log.warning(deprecatedServiceMessage(used, canonical)),
130
+ });
124
131
  }
125
132
  if (props.pickServices) {
126
133
  return props.pickServices();
@@ -217,7 +224,7 @@ export const initCmd = async (props) => {
217
224
  log.info("%s are already installed.", REQUIRED_PACKAGES.join(" and "));
218
225
  }
219
226
  else {
220
- const pm = resolvePackageManager();
227
+ const pm = resolvePackageManager(cwd);
221
228
  const args = addDependenciesArgs(pm, missing);
222
229
  if (props.install === false) {
223
230
  log.info("Install the Neon config packages to use neon.ts: %s %s", pm, args.join(" "));
@@ -285,12 +292,14 @@ export const builder = (argv) => argv
285
292
  type: "boolean",
286
293
  default: true,
287
294
  },
288
- services: {
289
- describe: `Services the scaffolded neon.ts declares, comma-separated: ${NEON_SERVICES.join(", ")}. ` +
290
- `Pass "${NO_SERVICES}" for the bare starter policy. Omitted: pick interactively on a ` +
291
- "terminal, starter policy in CI or without a TTY.",
292
- type: "string",
293
- },
295
+ services: servicesOption({
296
+ key: "services",
297
+ allowed: CONFIG_INIT_SERVICES,
298
+ noneMeans: CONFIG_INIT_NONE_MEANS,
299
+ describe: "Services the scaffolded neon.ts declares",
300
+ also: "Omitted: pick interactively on a terminal, starter policy in " +
301
+ "CI or without a TTY.",
302
+ }),
294
303
  "from-branch": {
295
304
  describe: "Seed neon.ts from a branch's live Neon state instead of asking. Uses the " +
296
305
  "branch pinned in .neon, or --branch <name|id>, or the project's default " +
@@ -300,7 +309,12 @@ export const builder = (argv) => argv
300
309
  // `default: false` makes `conflicts` reject every `--services` run.
301
310
  conflicts: "services",
302
311
  },
303
- }), (args) => initCmd(args));
312
+ }), (args) => initCmd({
313
+ ...args,
314
+ // `args` is untyped here, and "flag omitted" has to stay distinct from
315
+ // "flag given" — it decides whether the picker runs at all.
316
+ services: servicesFlagValue(args.services),
317
+ }));
304
318
  export const handler = (args) => {
305
319
  return args;
306
320
  };
@@ -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,7 +4,9 @@ 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";
10
12
  import { getCliName } from "../utils/cli_name.js";
@@ -34,14 +36,51 @@ export const builder = (argv) => argv
34
36
  "lines are preserved.",
35
37
  type: "string",
36
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
+ }),
37
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"))
38
58
  .example("$0 env pull", "Write the linked branch's Neon vars into .env.local (or .env if present)")
39
- .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);
40
62
  // Explicit `env pull` announces the branch it's reading from up front so the user
41
63
  // can catch "pulled env from the wrong branch" before it overwrites their .env. The
42
64
  // bundled auto-pull (link / checkout / apply) stays quiet — those already report the
43
65
  // branch they pinned/applied to.
44
- 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 });
45
84
  })
46
85
  .demandCommand(1);
47
86
  export const handler = (args) => args;
@@ -83,16 +122,18 @@ export const pull = async (props, opts = {}) => {
83
122
  // Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
84
123
  // pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
85
124
  // `env pull` is an explicit action, so it should error rather than write nothing.
86
- const { vars, credential } = await resolveNeonEnvVars({
125
+ const { vars, credential, skipped } = await resolveNeonEnvVars({
87
126
  cwd,
88
127
  projectId: props.projectId,
89
128
  branchId,
90
129
  env: { ...process.env, ...existingEnv },
130
+ ...(props.services ? { services: props.services } : {}),
131
+ ...(opts.implyAiGateway ? { implyAiGateway: true } : {}),
91
132
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
92
133
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
93
134
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
94
135
  });
95
- const neonVars = pickNeonVars(vars);
136
+ const neonVars = pickServiceVars(pickNeonVars(vars), props.services);
96
137
  if (Object.keys(neonVars).length === 0) {
97
138
  log.info("No Neon env variables to pull for this branch (no DATABASE_URL or " +
98
139
  "enabled Auth / Data API).");
@@ -102,7 +143,7 @@ export const pull = async (props, opts = {}) => {
102
143
  // Neon-owned vars the branch no longer has (e.g. NEON_AUTH_* / NEON_DATA_API_* carried over
103
144
  // from a previous project/branch). Non-Neon lines are always preserved.
104
145
  const { written, removed } = mergeEnvFile(targetPath, neonVars, {
105
- managedKeys: NEON_OWNED_ENV_KEYS,
146
+ managedKeys: managedKeysFor(props.services, unreachedButCurrent(skipped, existingEnv, branchId)),
106
147
  });
107
148
  log.info("Pulled %d Neon variable%s into %s: %s", written.length, written.length === 1 ? "" : "s", targetPath, written.join(", "));
108
149
  if (removed.length > 0) {
@@ -116,6 +157,17 @@ export const pull = async (props, opts = {}) => {
116
157
  if (credential.revoked.length > 0) {
117
158
  log.info("Revoked the credential it replaced (%s).", credential.revoked.join(", "));
118
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
+ }
119
171
  }
120
172
  // A dotenv file *we* create holds live branch credentials (DATABASE_URL, Auth keys, service
121
173
  // tokens), so ignore it the same way the `.neon` context file is — otherwise a fresh repo is
@@ -143,8 +195,75 @@ export const pull = async (props, opts = {}) => {
143
195
  written,
144
196
  file: targetPath,
145
197
  ...(credential && credential.keys.length > 0 ? { credential } : {}),
198
+ ...(skipped && skipped.length > 0 ? { skipped } : {}),
146
199
  };
147
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
+ };
148
267
  /**
149
268
  * Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
150
269
  * `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
@@ -183,6 +302,8 @@ export const renderAgentPullNote = (result) => {
183
302
  const credential = result.credential?.issued
184
303
  ? ` Issued a new branch credential, so ${result.credential.keys.join(", ")} changed.`
185
304
  : "";
305
+ // No `skipped` note: only the implied AI Gateway can be skipped, and the auto-pull
306
+ // this renders never implies it.
186
307
  return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? "" : "s"} into ${result.file}.${credential}`;
187
308
  }
188
309
  case "empty":
@@ -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
  ]));
package/dist/dev/env.js CHANGED
@@ -1,5 +1,6 @@
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";
5
6
  import { getCliName } from "../utils/cli_name.js";
@@ -34,6 +35,18 @@ export class MissingBranchContextError extends Error {
34
35
  this.name = "MissingBranchContextError";
35
36
  }
36
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
+ }
37
50
  /**
38
51
  * Resolve the branch's Neon env vars (pooled / direct `DATABASE_URL`, plus Auth /
39
52
  * Data API when enabled) into a `{ KEY: value }` map. Shared by `neon dev` (which
@@ -41,6 +54,8 @@ export class MissingBranchContextError extends Error {
41
54
  *
42
55
  * Tiered:
43
56
  *
57
+ * 0. {@link DevEnvContext.services} is set -> that selection *is* the policy, and any
58
+ * `neon.ts` is ignored. See {@link resolveSelectedServices}.
44
59
  * 1. a `neon.ts` policy is found -> the policy is the source of truth. We first
45
60
  * check it against the branch's live state (`plan`); if it declares a resource
46
61
  * the branch is missing, we stop with a {@link DevEnvMismatchError} pointing at
@@ -49,12 +64,17 @@ export class MissingBranchContextError extends Error {
49
64
  * branch's live state (Auth / Data API enablement plus any object-storage
50
65
  * buckets) into a config, then `fetchEnv` resolves what is actually enabled —
51
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.
52
69
  * 3. otherwise -> throw {@link MissingBranchContextError}.
53
70
  *
54
71
  * Unlike {@link resolveDevEnv}, this never swallows errors — callers decide how to
55
72
  * handle them.
56
73
  */
57
74
  export const resolveNeonEnvVars = async (ctx) => {
75
+ if (ctx.services) {
76
+ return await resolveSelectedServices(ctx, ctx.services);
77
+ }
58
78
  const config = await loadNeonConfig(ctx.cwd);
59
79
  if (config) {
60
80
  if (!ctx.projectId || !ctx.branchId) {
@@ -85,11 +105,193 @@ export const resolveNeonEnvVars = async (ctx) => {
85
105
  // straight into fetchEnv — no wrapping needed. pullConfig excludes functions and
86
106
  // the AI Gateway (neither can be faithfully read back), so fetchEnv never probes
87
107
  // the functions API here and only mints a storage credential when a bucket exists.
88
- 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
+ });
89
115
  }
90
116
  throw new MissingBranchContextError(`No project/branch context found. Link a branch (\`${getCliName()} link\` / ` +
91
117
  `\`${getCliName()} checkout\`) or pass --project-id and --branch.`);
92
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
+ ]));
288
+ }
289
+ if (services.includes("ai-gateway"))
290
+ preview.aiGateway = true;
291
+ if (Object.keys(preview).length > 0)
292
+ config.preview = preview;
293
+ return config;
294
+ };
93
295
  /**
94
296
  * `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
95
297
  *
@@ -180,11 +382,12 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
180
382
  const isMissingResource = (change) => change.kind === "service" &&
181
383
  change.action === "create" &&
182
384
  !change.identifier.startsWith("function:");
183
- const fetchAndProject = async (config, ctx) => fetchEnvReusingSecrets(config, {
385
+ const fetchAndProject = async (config, ctx, opts = {}) => fetchEnvReusingSecrets(config, {
184
386
  projectId: ctx.projectId,
185
387
  branch: ctx.branchId,
186
388
  ...apiOptions(ctx),
187
389
  ...(ctx.env ? { env: ctx.env } : {}),
390
+ ...(opts.revokeSuperseded === false ? { revokeSuperseded: false } : {}),
188
391
  });
189
392
  /**
190
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]);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Every Neon service a `--service` flag can name, spelled the way a user types it — one
3
+ * vocabulary for the whole CLI.
4
+ *
5
+ * Kebab-case rather than the `neon.ts` field names (`aiGateway`, `buckets`) so a flag reads
6
+ * like a flag, and the full product name rather than a shortening (`object-storage`, not
7
+ * `storage`) so nothing is ambiguous when read on its own.
8
+ *
9
+ * Commands take a **subset** of this via {@link ParseServicesOptions.allowed} — `config init`
10
+ * can only declare what a `neon.ts` has a field for, `env pull` can only pull what produces
11
+ * env vars — but the spelling of a service never varies between them. The order here is the
12
+ * canonical one: parsing sorts into it, so a command's output never depends on the order the
13
+ * flags were typed in.
14
+ *
15
+ * Not to be confused with `NeonFeature` in `init/bootstrap.ts`, which is what a *template*
16
+ * requires. That list comes from remote manifests (`neondatabase/examples/bootstrap.yaml`),
17
+ * spells Postgres `database`, and is not ours to rename.
18
+ */
19
+ export const NEON_SERVICES = [
20
+ "postgres",
21
+ "auth",
22
+ "data-api",
23
+ "functions",
24
+ "object-storage",
25
+ "ai-gateway",
26
+ ];
27
+ /**
28
+ * Spellings that used to be canonical, and the service they now mean. Accepted so a scripted
29
+ * `--services storage` keeps working, warned about so it does not quietly become a second
30
+ * vocabulary, and absent from help text, errors, and docs so nobody learns it fresh.
31
+ */
32
+ const DEPRECATED_SERVICE_ALIASES = {
33
+ // `config init --services storage` shipped before the vocabulary was unified.
34
+ storage: "object-storage",
35
+ };
36
+ /** An explicit empty selection, for commands where "declare nothing" is a real answer. */
37
+ export const NO_SERVICES = "none";
38
+ /**
39
+ * What to tell someone still using a retired spelling. A message rather than a log call, so
40
+ * the parser stays free of the CLI's writer and each command can surface it in its own voice.
41
+ */
42
+ export const deprecatedServiceMessage = (used, canonical) => `"${used}" is the old name for "${canonical}" and still works, but it will be removed. ` +
43
+ `Use "${canonical}".`;
44
+ /**
45
+ * Parse the raw values of a services flag into a canonical selection.
46
+ *
47
+ * Accepts the flag repeated (`-s auth -s postgres`) and comma-separated
48
+ * (`-s auth,postgres`), since both read naturally and users will try either. The result is
49
+ * deduplicated and sorted into {@link NEON_SERVICES} order, so what a command does never
50
+ * depends on typing order.
51
+ *
52
+ * An unrecognized name is rejected rather than dropped: a typo would otherwise act on
53
+ * everything *except* the service that was asked for, and report success. A name that is a
54
+ * real service but not one this command supports says so specifically — "functions has no env
55
+ * variables" is a different problem from a typo, and has a different fix.
56
+ */
57
+ export const parseServices = (raw, options) => {
58
+ const { allowed, flag, noneMeans, whyUnavailable = {}, onDeprecated, } = options;
59
+ const supported = `Supported values: ${allowed.join(", ")}${noneMeans !== undefined ? `, ${NO_SERVICES}` : ""}.`;
60
+ const names = raw
61
+ .flatMap((value) => value.split(","))
62
+ .map((name) => name.trim())
63
+ .filter((name) => name !== "");
64
+ if (names.length === 0) {
65
+ throw new Error(`${flag} needs at least one service. ${supported}`);
66
+ }
67
+ if (noneMeans !== undefined && names.includes(NO_SERVICES)) {
68
+ // Deduplicate before deciding it was combined with something: a repeated value is
69
+ // a no-op everywhere else in this parser, so `-s none -s none` must be too.
70
+ if (new Set(names).size > 1) {
71
+ throw new Error(`${flag} ${NO_SERVICES} cannot be combined with other services.`);
72
+ }
73
+ return [];
74
+ }
75
+ // Canonicalize first and unconditionally, so a retired spelling is reported against the
76
+ // service it means rather than as a word nobody recognizes.
77
+ const deprecated = new Map();
78
+ const resolved = names.map((name) => {
79
+ const canonical = DEPRECATED_SERVICE_ALIASES[name];
80
+ if (canonical === undefined)
81
+ return name;
82
+ deprecated.set(name, canonical);
83
+ return canonical;
84
+ });
85
+ const unsupported = resolved.filter((name) => !allowed.some((service) => service === name));
86
+ if (unsupported.length > 0) {
87
+ throw new Error(`${unsupportedMessage(unsupported, flag, whyUnavailable)} ${supported}`);
88
+ }
89
+ // Warned only once the selection is valid: a run that fails validation should not also
90
+ // carry a "still works" claim about a value that never took effect.
91
+ for (const [used, canonical] of deprecated)
92
+ onDeprecated?.(used, canonical);
93
+ return NEON_SERVICES.filter((service) => allowed.includes(service) && resolved.includes(service));
94
+ };
95
+ /**
96
+ * The sentences explaining why a selection was refused. A real Neon service this command
97
+ * cannot act on is a different mistake from a typo — different cause, different fix — so the
98
+ * two are never answered with the same word, and each service carries its reason where the
99
+ * command supplied one.
100
+ */
101
+ const unsupportedMessage = (unsupported, flag, whyUnavailable) => {
102
+ const known = unsupported.filter((name) => NEON_SERVICES.some((service) => service === name));
103
+ const unknown = unsupported.filter((name) => !known.some((service) => service === name));
104
+ return [
105
+ unknown.length > 0
106
+ ? `Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}.`
107
+ : undefined,
108
+ ...known.map((service) => {
109
+ const why = whyUnavailable[service];
110
+ return `${service} is not something ${flag} can select${why ? `: ${why}` : ""}.`;
111
+ }),
112
+ ]
113
+ .filter((part) => part !== undefined)
114
+ .join(" ");
115
+ };
116
+ /** Every spelling of the services flag, so a habit picked up on one command works on another. */
117
+ const SERVICE_FLAG_NAMES = ["s", "service", "services"];
118
+ /**
119
+ * The yargs option for a services flag, so every command that has one accepts the same
120
+ * spellings (`-s`, `--service`, `--services`) and the same value syntax. `key` is the name the
121
+ * command reads off `argv`; the rest become aliases.
122
+ */
123
+ export const servicesOption = (params) => ({
124
+ alias: SERVICE_FLAG_NAMES.filter((name) => name !== params.key),
125
+ describe: [
126
+ `${params.describe}: ${params.allowed.join(", ")}.`,
127
+ params.noneMeans !== undefined
128
+ ? `Pass "${NO_SERVICES}" for ${params.noneMeans}.`
129
+ : undefined,
130
+ "Repeat the flag or comma-separate.",
131
+ params.also,
132
+ ]
133
+ .filter((part) => part !== undefined)
134
+ .join(" "),
135
+ type: "array",
136
+ string: true,
137
+ });
138
+ /**
139
+ * Narrow a yargs value for a services flag to the raw strings, or `undefined` when the flag
140
+ * was not given. `argv` is untyped at the handler, and `string: true` only guarantees the
141
+ * element type when the flag was actually parsed as an array.
142
+ */
143
+ export const servicesFlagValue = (value) => Array.isArray(value) ? value.map(String) : undefined;
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
2
4
  import which from "which";
3
5
  import { log } from "../log.js";
4
6
  // npm first so it's the default/preselected choice; the rest follow in rough
@@ -9,6 +11,42 @@ export const PACKAGE_MANAGERS = [
9
11
  "yarn",
10
12
  "bun",
11
13
  ];
14
+ /**
15
+ * Lockfiles, and the package manager each one belongs to. npm is last on
16
+ * purpose: a repo with both a pnpm lockfile and a leftover `package-lock.json`
17
+ * (which a failed run like the one this fixes can leave behind) is a pnpm repo.
18
+ */
19
+ const LOCKFILES = [
20
+ ["pnpm-lock.yaml", "pnpm"],
21
+ ["yarn.lock", "yarn"],
22
+ // bun 1.2+ writes the text `bun.lock`; older versions the binary `bun.lockb`.
23
+ ["bun.lock", "bun"],
24
+ ["bun.lockb", "bun"],
25
+ ["package-lock.json", "npm"],
26
+ ];
27
+ /**
28
+ * The package manager the project at `cwd` uses, from its lockfile. Searches
29
+ * `cwd` and then each parent up to the repo root: in a monorepo the lockfile
30
+ * sits at the root while we scaffold into a package. Stopping at the root keeps
31
+ * a stray lockfile above the repository from deciding how we install into it.
32
+ */
33
+ export const detectProjectPackageManager = (cwd) => {
34
+ let dir = cwd;
35
+ for (;;) {
36
+ for (const [file, pm] of LOCKFILES) {
37
+ if (existsSync(join(dir, file)))
38
+ return pm;
39
+ }
40
+ // After the lockfiles, not before: the repo root's own lockfile counts.
41
+ // `.git` is a file rather than a directory in a worktree or submodule.
42
+ if (existsSync(join(dir, ".git")))
43
+ return undefined;
44
+ const parent = dirname(dir);
45
+ if (parent === dir)
46
+ return undefined;
47
+ dir = parent;
48
+ }
49
+ };
12
50
  /**
13
51
  * The package manager the CLI was invoked through, read from the
14
52
  * `npm_config_user_agent` npm sets for `npm exec`/`npx`, `pnpm dlx`, `yarn
@@ -32,11 +70,20 @@ export const detectPackageManager = () => {
32
70
  /** The package managers actually on PATH, in {@link PACKAGE_MANAGERS} order. */
33
71
  export const installedPackageManagers = () => PACKAGE_MANAGERS.filter((pm) => which.sync(pm, { nothrow: true }) !== null);
34
72
  /**
35
- * Pick a package manager without prompting: the one the CLI was invoked through,
36
- * else the first one installed, else npm. Used by non-interactive flows (e.g.
37
- * `config init`) where there's no scaffold prompt to hang a picker off.
73
+ * Pick a package manager without prompting: the one the project at `cwd` uses,
74
+ * else the one the CLI was invoked through, else the first one installed, else
75
+ * npm. Used by non-interactive flows (e.g. `config init`) where there's no
76
+ * scaffold prompt to hang a picker off.
77
+ *
78
+ * The project wins over the invocation on purpose. `npx neon …` inside a pnpm
79
+ * repo should still install with pnpm — which tool launched us says nothing
80
+ * about which one owns that project's `node_modules`, and running npm against
81
+ * pnpm's symlinked tree is what this ordering exists to prevent.
38
82
  */
39
- export const resolvePackageManager = () => detectPackageManager() ?? installedPackageManagers()[0] ?? "npm";
83
+ export const resolvePackageManager = (cwd) => detectProjectPackageManager(cwd) ??
84
+ detectPackageManager() ??
85
+ installedPackageManagers()[0] ??
86
+ "npm";
40
87
  /**
41
88
  * The argv that adds `packages` as runtime dependencies with `pm`. npm spells it
42
89
  * `install`; pnpm/yarn/bun use `add`.
@@ -1,8 +1,8 @@
1
1
  import prompts from "prompts";
2
- import { NEON_SERVICES } from "../config_template.js";
2
+ import { CONFIG_INIT_SERVICES } from "../config_template.js";
3
3
  /**
4
- * The picker's rows, in {@link NEON_SERVICES} order. Titles use the product names from the
5
- * CLI's README ("Managed Better Auth", "Object Storage") rather than the `neon.ts` field
4
+ * The picker's rows, in {@link CONFIG_INIT_SERVICES} order. Titles use the product names from
5
+ * the CLI's README ("Managed Better Auth", "Object Storage") rather than the `neon.ts` field
6
6
  * names, since this is the list a user reads before they've seen a policy.
7
7
  */
8
8
  const CHOICES = [
@@ -17,7 +17,7 @@ const CHOICES = [
17
17
  description: "Long-running, without timeouts, and closer to your database.",
18
18
  },
19
19
  {
20
- value: "storage",
20
+ value: "object-storage",
21
21
  title: "Object Storage",
22
22
  description: "S3-compatible blob storage that branches with your projects.",
23
23
  },
@@ -58,7 +58,7 @@ export const pickServicesInteractively = async () => {
58
58
  if (!Array.isArray(services)) {
59
59
  throw new Error("Aborted: no services selected.");
60
60
  }
61
- // Order by NEON_SERVICES rather than selection order so the rendered neon.ts is
61
+ // Order canonically rather than by selection order so the rendered neon.ts is
62
62
  // independent of the order the rows were toggled in.
63
- return NEON_SERVICES.filter((service) => services.includes(service));
63
+ return CONFIG_INIT_SERVICES.filter((service) => services.includes(service));
64
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "2.46.0",
3
+ "version": "2.47.0",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -62,8 +62,8 @@
62
62
  "yoctocolors": "^2.1.2",
63
63
  "@neon/sdk": "1.5.0",
64
64
  "@neon/config": "0.14.1",
65
- "@neon/config-runtime": "0.12.5",
66
- "@neon/env": "0.14.1"
65
+ "@neon/env": "0.15.0",
66
+ "@neon/config-runtime": "0.12.5"
67
67
  },
68
68
  "optionalDependencies": {
69
69
  "esbuild": "0.28.1"