neon 8.0.8 → 8.0.10

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.
@@ -2,7 +2,7 @@ import { log } from "../log.js";
2
2
  import { getCliName } from "../utils/cli_name.js";
3
3
  import { declaredNeonServices } from "../config_services.js";
4
4
  import { loadProjectConfig } from "../project.js";
5
- import { a as defaultAiGatewayCredential, c as fetchEnvKeysState, d as isLiveCredential, h as toEntries, i as credentialName, l as functionBaseUrlKey, m as resolveBranchPolicy, n as createApiFromOptions, o as defaultStorageCredential, p as policyEnvKeys, r as credentialEnvKeys, t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "./env-Dkl0TY44.js";
5
+ import { a as defaultAiGatewayCredential, c as fetchEnvKeysState, d as isLiveCredential, h as toEntries, i as credentialName, l as functionBaseUrlKey, m as resolveBranchPolicy, n as createApiFromOptions, o as defaultStorageCredential, p as policyEnvKeys, r as credentialEnvKeys, t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "./env-HYNFtxu5.js";
6
6
  import { ENV_PULL_SERVICES, envKeysForSelection, serviceForEnvKey, servicesForEnvKeys } from "../env_services.js";
7
7
  import { formatInstallCommand, resolvePackageManager } from "../utils/package_manager.js";
8
8
  import { ErrorCode, createNeonApiFromOptions, isPlatformError } from "@neon/config";
@@ -42,9 +42,9 @@ import { plan, pullConfig } from "@neon/config-runtime";
42
42
  * ```
43
43
  */
44
44
  async function fetchEnvReusingSecrets(config, options) {
45
- const { env: source = process.env, keys: requestedKeys, revokeSuperseded = true, ...fetchOptions } = options;
45
+ const { env: source = process.env, keys: requestedKeys, revokeSuperseded = true, resolvedBranch, ...fetchOptions } = options;
46
46
  const api = options.api ?? createApiFromOptions(options);
47
- const { branch, desired } = await resolveBranchPolicy(config, options, api);
47
+ const { branch, desired } = await resolveBranchPolicy(config, options, api, resolvedBranch);
48
48
  const allPolicyKeys = policyEnvKeys(desired);
49
49
  const requested = requestedKeys ? new Set(requestedKeys) : null;
50
50
  const selectedPolicyKeys = requested === null ? allPolicyKeys : [...allPolicyKeys.filter((key) => requested.has(key)), ...[...requested].filter(isFunctionBaseUrlKey).sort()];
@@ -66,6 +66,7 @@ async function fetchEnvReusingSecrets(config, options) {
66
66
  credential: {
67
67
  issued: false,
68
68
  keys: [],
69
+ fresh: [],
69
70
  revoked: [],
70
71
  superseded: []
71
72
  },
@@ -111,6 +112,7 @@ async function fetchEnvReusingSecrets(config, options) {
111
112
  credential: {
112
113
  issued: false,
113
114
  keys: secretKeys,
115
+ fresh: [],
114
116
  revoked: [],
115
117
  superseded: []
116
118
  },
@@ -124,6 +126,7 @@ async function fetchEnvReusingSecrets(config, options) {
124
126
  credential: {
125
127
  issued: true,
126
128
  keys: secretKeys,
129
+ fresh: secretKeys.filter((key) => !keptSecretKeys.includes(key)),
127
130
  revoked: revokeSuperseded ? [...ours] : [],
128
131
  superseded: revokeSuperseded ? [] : [...ours]
129
132
  },
@@ -245,6 +248,7 @@ const CLAIMABLE_RESOLVE_ORDER = [
245
248
  const emptyClaimableCredential = () => ({
246
249
  issued: false,
247
250
  keys: [],
251
+ fresh: [],
248
252
  revoked: [],
249
253
  superseded: []
250
254
  });
@@ -410,10 +414,21 @@ const resolveNeonEnvVars = async (ctx) => {
410
414
  branchId: ctx.branchId,
411
415
  ...apiOptions(ctx)
412
416
  });
413
- if (!ctx.implyAiGateway) return await fetchAndProject(pulled.config, ctx, { functionUrls: "all-live" });
417
+ const resolvedBranch = {
418
+ id: pulled.branch.id,
419
+ name: pulled.branch.name,
420
+ isDefault: pulled.branch.isDefault,
421
+ protected: pulled.branch.protected,
422
+ ...pulled.branch.expiresAt ? { expiresAt: pulled.branch.expiresAt } : {}
423
+ };
424
+ if (!ctx.implyAiGateway) return await fetchAndProject(pulled.config, ctx, {
425
+ functionUrls: "all-live",
426
+ resolvedBranch
427
+ });
414
428
  return await resolveWithImpliedGateway(pulled.config, ctx, {
415
429
  projectId: ctx.projectId,
416
- branchId: ctx.branchId
430
+ branchId: ctx.branchId,
431
+ resolvedBranch
417
432
  });
418
433
  }
419
434
  throw new MissingBranchContextError(`No project/branch context found. Link a branch (\`${getCliName()} link\` / \`${getCliName()} checkout\`) or pass --project-id and --branch.`);
@@ -450,9 +465,15 @@ const withAiGateway = (config) => ({
450
465
  */
451
466
  const resolveWithImpliedGateway = async (config, ctx, branch) => {
452
467
  const unreachable = await credentialsUnreachable(ctx, branch);
453
- if (unreachable === null) return await fetchAndProject(withAiGateway(config), ctx, { functionUrls: "all-live" });
468
+ if (unreachable === null) return await fetchAndProject(withAiGateway(config), ctx, {
469
+ functionUrls: "all-live",
470
+ resolvedBranch: branch.resolvedBranch
471
+ });
454
472
  log.warning(`Could not reach the AI Gateway's credentials, so %s were not resolved. Everything else was. Either this project does not have the AI Gateway, or the call failed — \`${getCliName()} env pull -s ai-gateway\` will say which.\nDetails: %s`, [NEON_ENV_VAR_KEYS.aiGateway.apiKey, NEON_ENV_VAR_KEYS.aiGateway.baseUrl].join(" and "), unreachable);
455
- const pulled = await fetchAndProject(config, ctx, { functionUrls: "all-live" });
473
+ const pulled = await fetchAndProject(config, ctx, {
474
+ functionUrls: "all-live",
475
+ resolvedBranch: branch.resolvedBranch
476
+ });
456
477
  return {
457
478
  ...pulled,
458
479
  skipped: [...pulled.skipped ?? [], "ai-gateway"]
@@ -670,7 +691,8 @@ const fetchAndProject = async (config, ctx, opts = {}) => {
670
691
  ...opts.keys ? { keys: opts.keys } : {},
671
692
  ...opts.revokeSuperseded === false ? { revokeSuperseded: false } : {},
672
693
  ...opts.functionUrls ? { functionUrls: opts.functionUrls } : {},
673
- ...opts.listedFunctions ? { listedFunctions: opts.listedFunctions } : {}
694
+ ...opts.listedFunctions ? { listedFunctions: opts.listedFunctions } : {},
695
+ ...opts.resolvedBranch ? { resolvedBranch: opts.resolvedBranch } : {}
674
696
  });
675
697
  return {
676
698
  vars: result.vars,
@@ -96,6 +96,22 @@ function parseFunctionBaseUrlKey(key) {
96
96
  function isFunctionBaseUrlKey(key) {
97
97
  return parseFunctionBaseUrlKey(key) !== null;
98
98
  }
99
+ /**
100
+ * Starts a read now and keeps an unread rejection from crashing the process; the caller
101
+ * awaits it (and sees the error) when its turn comes. The read is called synchronously and
102
+ * its own promise returned, so errors surface in the same order as a direct call; a
103
+ * synchronous throw becomes the returned rejection.
104
+ */
105
+ function started(read) {
106
+ let promise;
107
+ try {
108
+ promise = read();
109
+ } catch (error) {
110
+ promise = Promise.reject(error);
111
+ }
112
+ promise.catch(() => void 0);
113
+ return promise;
114
+ }
99
115
  /** Fail loudly when selected-key dependency planning and execution disagree. */
100
116
  function requiredValue(value, description) {
101
117
  if (value === null) throw new Error(`fetchEnv: missing ${description}.`);
@@ -141,6 +157,12 @@ async function fetchEnvKeysState(config, options, keys, policy) {
141
157
  selectedFunctionKeys,
142
158
  wants
143
159
  });
160
+ const wantsAnyFunctionUrl = selection === null || selectedFunctionKeys.length > 0;
161
+ const storageEnabled = (desired.preview?.buckets.length ?? 0) > 0;
162
+ const wantsStorage = storageEnabled && (wants(K.storage.accessKeyId) || wants(K.storage.secretAccessKey) || wants(K.storage.endpoint) || wants(K.storage.region));
163
+ const functionsRead = functionUrlMode === "all-live" && wantsAnyFunctionUrl && options.listedFunctions === void 0 ? listFunctionInvocationUrls(api, projectId, branch.id) : null;
164
+ functionsRead?.catch(() => void 0);
165
+ const storageRead = wantsStorage ? started(() => api.getProjectBranchStorage(projectId, branch.id)) : null;
144
166
  const needsUnpooled = wantsUnpooled || gatewayEnabled && wants(K.aiGateway.baseUrl) || constructSlugs.length > 0;
145
167
  const needsConnectionTarget = wantsPooled || needsUnpooled;
146
168
  const needsDatabase = needsConnectionTarget || wantsDataApi;
@@ -190,8 +212,6 @@ async function fetchEnvKeysState(config, options, keys, policy) {
190
212
  }
191
213
  result.dataApi = { url: dataApiSnapshot.url };
192
214
  }
193
- const storageEnabled = (desired.preview?.buckets.length ?? 0) > 0;
194
- const wantsStorage = storageEnabled && (wants(K.storage.accessKeyId) || wants(K.storage.secretAccessKey) || wants(K.storage.endpoint) || wants(K.storage.region));
195
215
  const wantsGateway = gatewayEnabled && (wants(K.aiGateway.apiKey) || wants(K.aiGateway.baseUrl));
196
216
  const wantsStorageCredential = storageEnabled && (wants(K.storage.accessKeyId) || wants(K.storage.secretAccessKey));
197
217
  const wantsGatewayCredential = gatewayEnabled && wants(K.aiGateway.apiKey);
@@ -199,7 +219,7 @@ async function fetchEnvKeysState(config, options, keys, policy) {
199
219
  if (wantsStorage || wantsGateway) {
200
220
  let storage = null;
201
221
  if (wantsStorage) {
202
- storage = await api.getProjectBranchStorage(projectId, branch.id);
222
+ storage = await requiredValue(storageRead, "storage read");
203
223
  if (!storage) throw new PlatformError(ErrorCode.NotFound, [`fetchEnv: branch policy declares object storage (buckets) but storage is not enabled on branch ${branch.name} (${branch.id}).`, "Enable it via `apply(config, { projectId, branchId })` (or in the Neon Console) — then re-run fetchEnv. Or remove buckets."].join(" "), { details: {
204
224
  projectId,
205
225
  branchId: branch.id
@@ -230,11 +250,10 @@ async function fetchEnvKeysState(config, options, keys, policy) {
230
250
  result.aiGateway = gateway;
231
251
  }
232
252
  }
233
- const wantsAnyFunctionUrl = selection === null || selectedFunctionKeys.length > 0;
234
253
  const functions = {};
235
254
  let functionUrlsUnavailable = false;
236
255
  if (functionUrlMode === "all-live" && wantsAnyFunctionUrl) {
237
- const listed = options.listedFunctions === void 0 ? await listFunctionInvocationUrls(api, projectId, branch.id) : listedFromSnapshots(options.listedFunctions);
256
+ const listed = options.listedFunctions === void 0 ? await requiredValue(functionsRead, "function listing") : listedFromSnapshots(options.listedFunctions);
238
257
  if (listed.status === "unavailable") {
239
258
  if (selection === null) functionUrlsUnavailable = true;
240
259
  } else for (const fn of listed.functions) {
@@ -297,13 +316,8 @@ async function listFunctionInvocationUrls(api, projectId, branchId) {
297
316
  * branch-scoped operation needs. Shared by {@link fetchEnv} and {@link fetchEnvReusingSecrets}
298
317
  * so the two agree on which branch they're talking about and what it has enabled.
299
318
  */
300
- async function resolveBranchPolicy(config, options, api) {
301
- const projectId = options.projectId;
302
- const branches = await api.listBranches(projectId);
303
- if (branches.length === 0) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: project ${projectId} has no branches.`, "Deploy your neon.ts policy (or create a branch) first, or pick a different project id."].join(" "), { details: { projectId } });
304
- const branchRef = options.branch ?? options.branchId;
305
- if (!branchRef) throw new PlatformError(ErrorCode.BranchNotFound, ["fetchEnv: no branch provided.", "Pass `branch` with a branch name (e.g. `main`) or id (`br-…`)."].join(" "), { details: { projectId } });
306
- const branch = resolveBranch(branchRef, branches);
319
+ async function resolveBranchPolicy(config, options, api, resolved) {
320
+ const branch = resolved ?? await listAndResolveBranch(options, api);
307
321
  return {
308
322
  branch,
309
323
  desired: resolveConfig(config, {
@@ -317,6 +331,14 @@ async function resolveBranchPolicy(config, options, api) {
317
331
  })
318
332
  };
319
333
  }
334
+ async function listAndResolveBranch(options, api) {
335
+ const projectId = options.projectId;
336
+ const branches = await api.listBranches(projectId);
337
+ if (branches.length === 0) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: project ${projectId} has no branches.`, "Deploy your neon.ts policy (or create a branch) first, or pick a different project id."].join(" "), { details: { projectId } });
338
+ const branchRef = options.branch ?? options.branchId;
339
+ if (!branchRef) throw new PlatformError(ErrorCode.BranchNotFound, ["fetchEnv: no branch provided.", "Pass `branch` with a branch name (e.g. `main`) or id (`br-…`)."].join(" "), { details: { projectId } });
340
+ return resolveBranch(branchRef, branches);
341
+ }
320
342
  /**
321
343
  * Scopes a minted fallback credential should carry. Only object storage and the AI Gateway
322
344
  * *require* secrets; functions never force a credential. `functions:invoke` rides along only
@@ -402,12 +424,14 @@ async function resolveBranchCredentialSecrets(args) {
402
424
  secretAccessKey: "",
403
425
  apiToken: ""
404
426
  };
405
- if (needsStorage && storageDefault) {
406
- const revealed = await args.api.revealCredential(args.projectId, args.branchId, storageDefault.tokenId);
427
+ const storageReveal = needsStorage && storageDefault ? started(() => args.api.revealCredential(args.projectId, args.branchId, storageDefault.tokenId)) : null;
428
+ const gatewayReveal = needsGateway && gatewayDefault ? started(() => args.api.revealCredential(args.projectId, args.branchId, gatewayDefault.tokenId)) : null;
429
+ if (storageReveal) {
430
+ const revealed = await storageReveal;
407
431
  secrets.accessKeyId = revealed.tokenId;
408
432
  secrets.secretAccessKey = revealed.s3SecretAccessKey;
409
433
  }
410
- if (needsGateway && gatewayDefault) secrets.apiToken = (await args.api.revealCredential(args.projectId, args.branchId, gatewayDefault.tokenId)).apiToken;
434
+ if (gatewayReveal) secrets.apiToken = (await gatewayReveal).apiToken;
411
435
  const missingStorage = needsStorage && storageDefault === null;
412
436
  const missingGateway = needsGateway && gatewayDefault === null;
413
437
  if (missingStorage || missingGateway) {
@@ -4,7 +4,7 @@ import { log } from "../log.js";
4
4
  import { getApiClient } from "../api.js";
5
5
  import { contextBranch, ensureDirectoryGitignored, readContextFile } from "../context.js";
6
6
  import { getCliName } from "../utils/cli_name.js";
7
- import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
7
+ import { NEON_SERVICE_LABELS, deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
8
8
  import { CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, CONFIG_INIT_UNAVAILABLE, FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, renderFunctionSource, renderNeonConfig, renderNeonConfigFromView } from "../config_template.js";
9
9
  import { editNeonConfig } from "../config_edit.js";
10
10
  import { toNeonConfigView } from "../config_format.js";
@@ -820,17 +820,7 @@ const applyCmd = async (props) => {
820
820
  * mistaken for being silently dropped. Service enablement is static top-level config (it never
821
821
  * lives in the per-branch closure), so reading it straight off `config` is accurate.
822
822
  */
823
- const utilizedServices = (config) => {
824
- const labels = {
825
- postgres: "Postgres",
826
- auth: "Neon Auth",
827
- "data-api": "Data API",
828
- "object-storage": "Object Storage",
829
- functions: "Functions",
830
- "ai-gateway": "AI Gateway"
831
- };
832
- return [labels.postgres, ...declaredNeonServices(config).map((service) => labels[service])];
833
- };
823
+ const utilizedServices = (config) => [NEON_SERVICE_LABELS.postgres, ...declaredNeonServices(config).map((service) => NEON_SERVICE_LABELS[service])];
834
824
  /**
835
825
  * Render a {@link PushResult}. JSON/YAML output emits the raw result (plus a `services`
836
826
  * summary) verbatim so it can be piped; the human-readable path renders the actual changes
@@ -3,12 +3,12 @@ import { log } from "../log.js";
3
3
  import { isClaimableEnvTarget } from "../claimable/state.js";
4
4
  import { getCliName } from "../utils/cli_name.js";
5
5
  import { readEnvFile, resolveEnvFilePath } from "../env_file.js";
6
- import { l as functionBaseUrlKey } from "../_chunks/env-Dkl0TY44.js";
7
- import { a as resolveDevEnv } from "../_chunks/env-HVRMArlT.js";
8
- import { findConfigFunctionBySource, resolveFunctionsFromConfig } from "../dev/functions.js";
9
- import { resolveWatchInputs } from "../dev/inputs.js";
6
+ import { l as functionBaseUrlKey } from "../_chunks/env-HYNFtxu5.js";
10
7
  import { branchIdResolve } from "../utils/enrichers.js";
11
8
  import { bundleEntry } from "../utils/esbuild.js";
9
+ import { a as resolveDevEnv } from "../_chunks/env-BWqJqBDJ.js";
10
+ import { findConfigFunctionBySource, resolveFunctionsFromConfig } from "../dev/functions.js";
11
+ import { resolveWatchInputs } from "../dev/inputs.js";
12
12
  import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import { basename, dirname, join, relative, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
@@ -5,12 +5,13 @@ import { isClaimableEnvTarget } from "../claimable/state.js";
5
5
  import { getCliName } from "../utils/cli_name.js";
6
6
  import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
7
7
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
8
- import { t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "../_chunks/env-Dkl0TY44.js";
8
+ import { t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "../_chunks/env-HYNFtxu5.js";
9
9
  import { ENV_PULL_KEY_HELP, ENV_PULL_SERVICES, envKeysForSelection, envPullFlagValue, ownedEnvServiceKeys, parseEnvPullKeys } from "../env_services.js";
10
- import { i as dropClaimableUnsupported, o as resolveNeonEnvVars } from "../_chunks/env-HVRMArlT.js";
11
10
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
12
11
  import { warnAiGateway } from "../utils/ai_gateway_notice.js";
13
12
  import { announceTargetBranch } from "../utils/branch_notice.js";
13
+ import { i as dropClaimableUnsupported, o as resolveNeonEnvVars } from "../_chunks/env-BWqJqBDJ.js";
14
+ import { formatPulledEnv } from "./env_output.js";
14
15
  import { existsSync } from "node:fs";
15
16
  import chalk from "chalk";
16
17
  //#region src/commands/env.ts
@@ -167,13 +168,13 @@ const pull = async (props, opts = {}) => {
167
168
  return { status: "empty" };
168
169
  }
169
170
  const { written, removed } = mergeEnvFile(targetPath, neonVars, { managedKeys: managedKeysFor(selectedKeys, unreachedButCurrent(skipped, existingEnv, branchId), existingEnv, props.services !== void 0 ? selectionServices : void 0, props.envKeys !== void 0 ? selectionEnvKeys : void 0) });
170
- log.info("Pulled %d Neon variable%s into %s: %s", written.length, written.length === 1 ? "" : "s", targetPath, written.join(", "));
171
- if (removed.length > 0) log.info("Removed %d stale Neon variable%s not enabled on this branch: %s", removed.length, removed.length === 1 ? "" : "s", removed.join(", "));
172
- if (credential?.issued) {
173
- log.info("Wrote credential secrets — these now hold fresh values: %s", credential.keys.join(", "));
174
- if (credential.revoked.length > 0) log.info("Revoked the credential it replaced (%s).", credential.revoked.join(", "));
175
- else if (credential.superseded.length > 0) log.info("Left the credential it replaced live (%s): an explicitly scoped pull can't tell which other variables still use it. Revoke it in the Neon Console if nothing does.", credential.superseded.join(", "));
176
- }
171
+ process.stderr.write(formatPulledEnv({
172
+ status: "written",
173
+ written,
174
+ removed,
175
+ file: targetPath,
176
+ ...credential && credential.keys.length > 0 ? { credential } : {}
177
+ }, cwd));
177
178
  if (!fileExisted) ensureGitignored(targetPath);
178
179
  const gatewayBaseUrl = neonVars.NEON_AI_GATEWAY_BASE_URL;
179
180
  const gatewayToken = neonVars.NEON_AI_GATEWAY_TOKEN;
@@ -189,6 +190,7 @@ const pull = async (props, opts = {}) => {
189
190
  return {
190
191
  status: "written",
191
192
  written,
193
+ removed,
192
194
  file: targetPath,
193
195
  ...credential && credential.keys.length > 0 ? { credential } : {},
194
196
  ...skipped && skipped.length > 0 ? { skipped } : {}
@@ -0,0 +1,64 @@
1
+ import { NEON_SERVICES, NEON_SERVICE_LABELS } from "../neon_services.js";
2
+ import { u as isFunctionBaseUrlKey } from "../_chunks/env-HYNFtxu5.js";
3
+ import { BRANCH_ENV_KEY, ENV_PULL_KEYS, serviceForEnvKey } from "../env_services.js";
4
+ import { isAbsolute, relative } from "node:path";
5
+ import { homedir } from "node:os";
6
+ //#region src/commands/env_output.ts
7
+ /** Wide enough for the longest label ("Object Storage") plus a gutter, so blocks align. */
8
+ const LABEL_WIDTH = 16;
9
+ const row = (label, value) => ` ${label.padEnd(LABEL_WIDTH)}${value}`;
10
+ /** Relative to `cwd` when the file is inside it, else absolute with the home directory as `~`. */
11
+ const displayPath = (file, cwd) => {
12
+ const rel = relative(cwd, file);
13
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
14
+ const home = homedir();
15
+ return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
16
+ };
17
+ const BRANCH_GROUP = "Branch";
18
+ const OTHER_GROUP = "Other";
19
+ const GROUP_ORDER = [
20
+ NEON_SERVICE_LABELS.postgres,
21
+ BRANCH_GROUP,
22
+ ...NEON_SERVICES.filter((service) => service !== "postgres").map((service) => NEON_SERVICE_LABELS[service]),
23
+ OTHER_GROUP
24
+ ];
25
+ const groupOf = (key) => {
26
+ if (key === BRANCH_ENV_KEY) return BRANCH_GROUP;
27
+ if (isFunctionBaseUrlKey(key)) return NEON_SERVICE_LABELS.functions;
28
+ const pullKey = ENV_PULL_KEYS.find((candidate) => candidate === key);
29
+ const service = pullKey ? serviceForEnvKey(pullKey) : null;
30
+ return service ? NEON_SERVICE_LABELS[service] : OTHER_GROUP;
31
+ };
32
+ /** Canonical key order, so a row reads the same whether a key was rewritten or reused. */
33
+ const keyRank = (key) => {
34
+ const index = ENV_PULL_KEYS.findIndex((candidate) => candidate === key);
35
+ return index === -1 ? ENV_PULL_KEYS.length : index;
36
+ };
37
+ const byKeyRank = (a, b) => keyRank(a) - keyRank(b) || a.localeCompare(b);
38
+ const groupKeys = (keys) => {
39
+ const groups = /* @__PURE__ */ new Map();
40
+ for (const key of [...keys].sort(byKeyRank)) {
41
+ const group = groupOf(key);
42
+ groups.set(group, [...groups.get(group) ?? [], key]);
43
+ }
44
+ return groups;
45
+ };
46
+ const formatPulledEnv = (outcome, cwd) => {
47
+ const fresh = new Set(outcome.credential?.fresh ?? []);
48
+ const count = outcome.written.length;
49
+ const lines = [`Pulled ${count} Neon variable${count === 1 ? "" : "s"} into ${displayPath(outcome.file, cwd)}`];
50
+ const groups = groupKeys(outcome.written);
51
+ for (const group of GROUP_ORDER) {
52
+ const keys = groups.get(group);
53
+ if (!keys) continue;
54
+ lines.push(row(group, keys.map((key) => fresh.has(key) ? `${key}*` : key).join(", ")));
55
+ }
56
+ if (outcome.removed.length > 0) lines.push(row("Removed", `${outcome.removed.join(", ")} (not produced by this pull)`));
57
+ if (fresh.size > 0) lines.push(" * new credential value");
58
+ const credential = outcome.credential;
59
+ if (credential?.issued && credential.revoked.length > 0) lines.push(`Revoked the credential it replaced (${credential.revoked.join(", ")}).`);
60
+ else if (credential?.issued && credential.superseded.length > 0) lines.push(`Left the credential it replaced live (${credential.superseded.join(", ")}): an explicitly scoped pull can't tell which other variables still use it. Revoke it in the Neon Console if nothing does.`);
61
+ return `${lines.join("\n")}\n`;
62
+ };
63
+ //#endregion
64
+ export { displayPath, formatPulledEnv, row };
@@ -5,6 +5,7 @@ import { isNeonApiError, messageFromBody } from "../api.js";
5
5
  import { r as defaultDir } from "../_chunks/paths-DMq0Lt7a.js";
6
6
  import "../config.js";
7
7
  import { applyContext, contextBranch, readContextFile, setContext, updateContextFile } from "../context.js";
8
+ import { isClaimableEnvTarget } from "../claimable/state.js";
8
9
  import { getCliName } from "../utils/cli_name.js";
9
10
  import { helpEpilogue } from "../utils/help_text.js";
10
11
  import { hasNeonConfigFile, projectDir } from "../project.js";
@@ -14,6 +15,7 @@ import { looksLikeBranchId } from "../utils/formats.js";
14
15
  import { listAllProjectBranches } from "../utils/enrichers.js";
15
16
  import { autoPullEnvAfterPin } from "./env.js";
16
17
  import { initCmd } from "./config.js";
18
+ import { formatLinkSummary } from "./link_output.js";
17
19
  import { REGIONS } from "./projects.js";
18
20
  import prompts from "prompts";
19
21
  //#region src/commands/link.ts
@@ -304,13 +306,28 @@ const failWithBranchCandidates = (props, projectId, branches, reason) => {
304
306
  };
305
307
  const listAllBranches = async (props, projectId) => listAllProjectBranches(props.apiClient, projectId);
306
308
  /**
309
+ * The branch listing for {@link resolvePinnedBranch}, started alongside the project check
310
+ * except on Claimable Neon, which does not complete concurrent Management API reads. Only
311
+ * the listing starts early: branch selection, which can prompt or create a branch, still
312
+ * waits for the check, and a listing error is reported only after the check passes.
313
+ */
314
+ const branchListing = (props, projectId) => {
315
+ if (isClaimableEnvTarget({
316
+ apiHost: props.apiHost,
317
+ contextFile: props.contextFile,
318
+ configDir: props.configDir ?? ""
319
+ })) return () => listAllBranches(props, projectId);
320
+ const listing = listAllBranches(props, projectId);
321
+ listing.catch(() => void 0);
322
+ return () => listing;
323
+ };
324
+ /**
307
325
  * Resolve a branch reference (name *or* id) to the matching branch, while
308
326
  * confirming it actually exists in the project. Unlike the shared
309
327
  * `branchIdResolve`, this also verifies references that already look like ids
310
328
  * (so a typo'd `br-…` doesn't silently get written).
311
329
  */
312
- const resolveBranchRef = async (props, projectId, branchRef) => {
313
- const branches = await listAllBranches(props, projectId);
330
+ const resolveBranchRef = (props, projectId, branchRef, branches) => {
314
331
  const match = branches.find((b) => b.id === branchRef) ?? branches.find((b) => b.name === branchRef);
315
332
  if (match) return match;
316
333
  return failWithBranchCandidates(props, projectId, branches, `Branch '${branchRef}' not found in project '${projectId}'. Pass --branch with a name or ID from the list:`);
@@ -336,43 +353,54 @@ const branchPersistValue = (branch) => {
336
353
  * the field is simply omitted.
337
354
  */
338
355
  const resolveOrgForProject = async (props, inputs, projectId) => {
339
- const projectOrg = (await fetchProjectOrThrow(props, projectId)).org_id ?? void 0;
356
+ const project = await fetchProjectOrThrow(props, projectId);
357
+ const projectOrg = project.org_id ?? void 0;
358
+ const projectName = project.name;
340
359
  if (inputs.orgId) {
341
360
  if (projectOrg && projectOrg !== inputs.orgId) throw new LinkInputError(`Project '${projectId}' belongs to organization '${projectOrg}', not '${inputs.orgId}'. Omit --org-id to use the project's own org, or pass the matching ID.`);
342
361
  if (!projectOrg) throw new LinkInputError(`Project '${projectId}' does not report an organization matching --org-id ${inputs.orgId}. Omit --org-id to link using the project's own context.`);
343
- return inputs.orgId;
362
+ return {
363
+ orgId: inputs.orgId,
364
+ projectName
365
+ };
344
366
  }
345
- if (projectOrg) return projectOrg;
367
+ return {
368
+ orgId: projectOrg,
369
+ projectName
370
+ };
346
371
  };
372
+ const pinnedRef = (branch) => ({
373
+ branchId: branch.id,
374
+ branchName: branch.name ?? branch.id,
375
+ usedDefault: false
376
+ });
377
+ const resolution = (branch) => ({
378
+ branch: branchPersistValue(branch),
379
+ ref: pinnedRef(branch)
380
+ });
347
381
  /**
348
382
  * Keep a same-project pin only after it still resolves on the project.
349
383
  */
350
- const resolvePinnedBranch = async (props, inputs, existing, projectId) => {
351
- if (inputs.branch) {
352
- const branch = await resolveBranchRef(props, projectId, inputs.branch);
353
- return { branch: branchPersistValue(branch) };
354
- }
384
+ const resolvePinnedBranch = async (props, inputs, existing, projectId, listBranches) => {
385
+ const branches = await listBranches();
386
+ if (inputs.branch) return resolution(resolveBranchRef(props, projectId, inputs.branch, branches));
355
387
  if (projectId === existing.projectId) {
356
388
  const pinned = contextBranch(existing);
357
- if (pinned) {
358
- const branch = await resolveBranchRef(props, projectId, pinned);
359
- return { branch: branchPersistValue(branch) };
360
- }
389
+ if (pinned) return resolution(resolveBranchRef(props, projectId, pinned, branches));
361
390
  }
362
- return resolveBranchFromList(props, projectId);
391
+ return resolveBranchFromList(props, projectId, branches);
363
392
  };
364
- const resolveBranchFromList = async (props, projectId) => {
365
- const branches = await listAllBranches(props, projectId);
393
+ const resolveBranchFromList = async (props, projectId, branches) => {
366
394
  if (branches.length === 0) throw new LinkInputError(`Project '${projectId}' has no branches to link. Create or restore a branch before retrying, or select another project with --project-id.`);
367
395
  if (branches.length === 1) {
368
396
  const [only] = branches;
369
397
  if (!only) throw new LinkInputError(`Project '${projectId}' has no branches to link. Create or restore a branch before retrying, or select another project with --project-id.`);
370
- return { branch: branchPersistValue(only) };
398
+ return resolution(only);
371
399
  }
372
400
  if (props.yes) {
373
401
  const def = branches.find((b) => b.default);
374
402
  if (!def) return failWithBranchCandidates(props, projectId, branches, `Project '${projectId}' has no default branch. Pass --branch with a name or ID from the list:`);
375
- return { branch: branchPersistValue(def) };
403
+ return resolution(def);
376
404
  }
377
405
  if (canPromptInteractively()) {
378
406
  const picked = await pickBranchInteractively(branches, {
@@ -381,13 +409,19 @@ const resolveBranchFromList = async (props, projectId) => {
381
409
  });
382
410
  if (picked.kind === "existing") {
383
411
  const existing = branches.find((b) => b.id === picked.branchId);
384
- return { branch: existing ? branchPersistValue(existing) : picked.branchId };
412
+ return existing ? resolution(existing) : {
413
+ branch: picked.branchId,
414
+ ref: pinnedRef({ id: picked.branchId })
415
+ };
385
416
  }
386
- const created = await createBranch(props.apiClient, projectId, picked.name, branches);
387
- return { branch: branchPersistValue({
388
- id: created,
417
+ const createdBranch = {
418
+ id: await createBranch(props.apiClient, projectId, picked.name, branches),
389
419
  name: picked.name
390
- }) };
420
+ };
421
+ return {
422
+ branch: branchPersistValue(createdBranch),
423
+ ref: pinnedRef(createdBranch)
424
+ };
391
425
  }
392
426
  return failWithBranchCandidates(props, projectId, branches, `Project '${projectId}' has multiple branches. Pass --branch <name-or-id>, or -y to pin its default branch.`);
393
427
  };
@@ -410,6 +444,10 @@ const runNonInteractive = async (props, inputs, existing) => {
410
444
  orgId,
411
445
  projectId: created.project.id,
412
446
  branch: created.branchName,
447
+ branchRef: pinnedRef({
448
+ id: created.branchId,
449
+ name: created.branchName
450
+ }),
413
451
  created: true,
414
452
  projectName: created.project.name,
415
453
  regionId: created.project.region_id
@@ -417,8 +455,9 @@ const runNonInteractive = async (props, inputs, existing) => {
417
455
  return;
418
456
  }
419
457
  if (inputs.projectId) {
420
- const orgId = await resolveOrgForProject(props, inputs, inputs.projectId);
421
- const resolved = await resolvePinnedBranch(props, inputs, existing, inputs.projectId);
458
+ const listBranches = branchListing(props, inputs.projectId);
459
+ const { orgId, projectName } = await resolveOrgForProject(props, inputs, inputs.projectId);
460
+ const resolved = await resolvePinnedBranch(props, inputs, existing, inputs.projectId, listBranches);
422
461
  applyContext(props.contextFile, {
423
462
  orgId,
424
463
  projectId: inputs.projectId,
@@ -428,15 +467,18 @@ const runNonInteractive = async (props, inputs, existing) => {
428
467
  contextFile: props.contextFile,
429
468
  orgId,
430
469
  projectId: inputs.projectId,
470
+ projectName,
431
471
  branch: resolved.branch,
472
+ branchRef: resolved.ref,
432
473
  created: false
433
474
  });
434
475
  return;
435
476
  }
436
477
  if (inputs.branch && existing.projectId) {
437
478
  const projectId = existing.projectId;
438
- const orgId = await resolveOrgForProject(props, inputs, projectId);
439
- const resolved = await resolvePinnedBranch(props, inputs, existing, projectId);
479
+ const listBranches = branchListing(props, projectId);
480
+ const { orgId, projectName } = await resolveOrgForProject(props, inputs, projectId);
481
+ const resolved = await resolvePinnedBranch(props, inputs, existing, projectId, listBranches);
440
482
  applyContext(props.contextFile, {
441
483
  orgId,
442
484
  projectId,
@@ -446,7 +488,9 @@ const runNonInteractive = async (props, inputs, existing) => {
446
488
  contextFile: props.contextFile,
447
489
  orgId,
448
490
  projectId,
491
+ projectName,
449
492
  branch: resolved.branch,
493
+ branchRef: resolved.ref,
450
494
  created: false
451
495
  });
452
496
  return;
@@ -481,6 +525,10 @@ const runInteractive = async (props, inputs) => {
481
525
  orgId,
482
526
  projectId: created.project.id,
483
527
  branch: created.branchName,
528
+ branchRef: pinnedRef({
529
+ id: created.branchId,
530
+ name: created.branchName
531
+ }),
484
532
  created: true,
485
533
  projectName: created.project.name,
486
534
  regionId: created.project.region_id
@@ -491,7 +539,7 @@ const runInteractive = async (props, inputs) => {
491
539
  const action = await promptProjectChoice(projects, inputs.projectName, { allowCreate: !inputs.branch });
492
540
  if (action.type === "existing") {
493
541
  const existing = readContextFile(props.contextFile);
494
- const resolved = await resolvePinnedBranch(props, inputs, existing, action.projectId);
542
+ const resolved = await resolvePinnedBranch(props, inputs, existing, action.projectId, () => listAllBranches(props, action.projectId));
495
543
  applyContext(props.contextFile, {
496
544
  orgId,
497
545
  projectId: action.projectId,
@@ -502,6 +550,7 @@ const runInteractive = async (props, inputs) => {
502
550
  orgId,
503
551
  projectId: action.projectId,
504
552
  branch: resolved.branch,
553
+ branchRef: resolved.ref,
505
554
  created: false,
506
555
  projectName: action.name,
507
556
  regionId: action.regionId
@@ -526,6 +575,10 @@ const runInteractive = async (props, inputs) => {
526
575
  orgId,
527
576
  projectId: created.project.id,
528
577
  branch: created.branchName,
578
+ branchRef: pinnedRef({
579
+ id: created.branchId,
580
+ name: created.branchName
581
+ }),
529
582
  created: true,
530
583
  projectName: created.project.name,
531
584
  regionId: created.project.region_id
@@ -800,19 +853,8 @@ const createProject = async (props, args) => {
800
853
  branchName: data.branch.name ?? data.branch.id
801
854
  };
802
855
  };
803
- const printSummary = (_props, summary) => {
804
- const lines = [];
805
- if (summary.created) lines.push(`Created project ${summary.projectId}${summary.projectName ? ` ("${summary.projectName}")` : ""}${summary.regionId ? ` in ${summary.regionId}` : ""}.`);
806
- lines.push(`Linked ${summary.contextFile}:`);
807
- if (summary.orgId) lines.push(` orgId: ${summary.orgId}`);
808
- lines.push(` projectId: ${summary.projectId}`);
809
- lines.push(` branch: ${summary.branch}`);
810
- if (summary.noChecks) {
811
- lines.push("");
812
- lines.push("Written offline (--no-checks): nothing was verified.");
813
- }
814
- lines.push("");
815
- process.stdout.write(`${lines.join("\n")}\n`);
856
+ const printSummary = (props, summary) => {
857
+ process.stdout.write(`${formatLinkSummary(summary, props.cwd ?? process.cwd())}\n`);
816
858
  };
817
859
  /**
818
860
  * Print the link summary, then run the bundled `env pull` so a completed `link` ends with
@@ -830,7 +872,7 @@ const finalizeLink = async (props, summary) => {
830
872
  projectId: summary.projectId,
831
873
  branch: summary.branch,
832
874
  envPull: props.envPull
833
- });
875
+ }, summary.branchRef);
834
876
  };
835
877
  /**
836
878
  * Interactive `link` finalize: the shared {@link finalizeLink} (summary + env
@@ -876,7 +918,7 @@ const maybeOfferConfigInit = async (props, summary) => {
876
918
  projectId: summary.projectId,
877
919
  branch: summary.branch,
878
920
  envPull: props.envPull
879
- });
921
+ }, summary.branchRef);
880
922
  }
881
923
  };
882
924
  const onPromptState = (state) => {
@@ -0,0 +1,17 @@
1
+ import { displayPath, row } from "./env_output.js";
2
+ //#region src/commands/link_output.ts
3
+ const formatLinkSummary = (summary, cwd) => {
4
+ const lines = [];
5
+ if (summary.created) lines.push(`Created project ${summary.projectName ?? summary.projectId}${summary.regionId ? ` in ${summary.regionId}` : ""}`);
6
+ lines.push(`Linked ${displayPath(summary.contextFile, cwd)}`);
7
+ lines.push(row("Project", summary.projectName ? `${summary.projectName} (${summary.projectId})` : summary.projectId));
8
+ lines.push(row("Branch", summary.branch));
9
+ if (summary.orgId) lines.push(row("Org", summary.orgId));
10
+ if (summary.noChecks) {
11
+ lines.push("");
12
+ lines.push("Written offline (--no-checks): nothing was verified.");
13
+ }
14
+ return `${lines.join("\n")}\n`;
15
+ };
16
+ //#endregion
17
+ export { formatLinkSummary };
package/dist/dev/env.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as resolveDevEnv, i as dropClaimableUnsupported, n as MissingBranchContextError, o as resolveNeonEnvVars, r as ServiceNotOnBranchError, t as DevEnvMismatchError } from "../_chunks/env-HVRMArlT.js";
1
+ import { a as resolveDevEnv, i as dropClaimableUnsupported, n as MissingBranchContextError, o as resolveNeonEnvVars, r as ServiceNotOnBranchError, t as DevEnvMismatchError } from "../_chunks/env-BWqJqBDJ.js";
2
2
  export { DevEnvMismatchError, MissingBranchContextError, ServiceNotOnBranchError, dropClaimableUnsupported, resolveDevEnv, resolveNeonEnvVars };
@@ -1,5 +1,5 @@
1
1
  import { NEON_SERVICES } from "./neon_services.js";
2
- import { f as parseFunctionBaseUrlKey, t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "./_chunks/env-Dkl0TY44.js";
2
+ import { f as parseFunctionBaseUrlKey, t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "./_chunks/env-HYNFtxu5.js";
3
3
  //#region src/env_services.ts
4
4
  const ENV_PULL_SERVICES = NEON_SERVICES;
5
5
  const ENV_PULL_KEYS = [
@@ -25,6 +25,15 @@ const NEON_SERVICES = [
25
25
  "object-storage",
26
26
  "ai-gateway"
27
27
  ];
28
+ /** How output names each service. */
29
+ const NEON_SERVICE_LABELS = {
30
+ postgres: "Postgres",
31
+ auth: "Neon Auth",
32
+ "data-api": "Data API",
33
+ "object-storage": "Object Storage",
34
+ functions: "Functions",
35
+ "ai-gateway": "AI Gateway"
36
+ };
28
37
  /**
29
38
  * Spellings that used to be canonical, and the service they now mean. Accepted so a scripted
30
39
  * `--services storage` keeps working, warned about so it does not quietly become a second
@@ -115,4 +124,4 @@ const servicesOption = (params) => ({
115
124
  */
116
125
  const servicesFlagValue = (value) => Array.isArray(value) ? value.map(String) : void 0;
117
126
  //#endregion
118
- export { NEON_SERVICES, NO_SERVICES, deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption };
127
+ export { NEON_SERVICES, NEON_SERVICE_LABELS, NO_SERVICES, deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption };
@@ -131,11 +131,12 @@ const assertAiGatewayProvisionable = async (params) => {
131
131
  * fetching the plan or catalog is swallowed so it can never break the underlying command.
132
132
  */
133
133
  const warnAiGateway = async (params) => {
134
+ const modelsRead = params.gateway ? fetchGatewayModelIds(params.gateway.baseUrl, params.gateway.token) : null;
134
135
  try {
135
136
  const { data } = await params.apiClient.getProject(params.projectId);
136
137
  const subscriptionType = data.project.owner?.subscription_type;
137
138
  const orgId = data.project.org_id ?? void 0;
138
- const modelIds = params.gateway ? await fetchGatewayModelIds(params.gateway.baseUrl, params.gateway.token) ?? void 0 : void 0;
139
+ const modelIds = modelsRead ? await modelsRead ?? void 0 : void 0;
139
140
  const notice = buildAiGatewayNotice({
140
141
  subscriptionType,
141
142
  modelIds,
@@ -1,6 +1,6 @@
1
1
  import { log } from "../log.js";
2
2
  import { loadProjectConfig } from "../project.js";
3
- import { s as fetchEnvKeys } from "../_chunks/env-Dkl0TY44.js";
3
+ import { s as fetchEnvKeys } from "../_chunks/env-HYNFtxu5.js";
4
4
  import { runHook } from "@neon/config-runtime";
5
5
  //#region src/utils/hooks.ts
6
6
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "8.0.8",
3
+ "version": "8.0.10",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -64,9 +64,9 @@
64
64
  "yaml": "^2.9.0",
65
65
  "yargs": "17.7.2",
66
66
  "yoctocolors": "^2.1.2",
67
- "@neon/sdk": "7.0.0",
68
- "@neon/config-runtime": "1.7.5",
69
- "@neon/config": "1.8.3"
67
+ "@neon/config": "1.8.4",
68
+ "@neon/config-runtime": "1.7.6",
69
+ "@neon/sdk": "7.0.1"
70
70
  },
71
71
  "optionalDependencies": {
72
72
  "@napi-rs/keyring": "1.3.0",
@@ -100,8 +100,8 @@
100
100
  "typescript": "^5.9.0",
101
101
  "vitest": "^3.0.9",
102
102
  "@neon-internals/cli-core": "0.0.0",
103
- "@neon-internals/env-core": "0.0.26",
104
103
  "@neon/e2e-harness": "0.0.0",
104
+ "@neon-internals/env-core": "0.0.27",
105
105
  "@neon/functions": "0.11.0"
106
106
  },
107
107
  "publishConfig": {