neon 4.13.0 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +55 -13
  2. package/dist/_chunks/{credential_io-YeAxg9Xn.js → credential_io-D8AR-jvB.js} +4 -4
  3. package/dist/_chunks/{env-NbA61JR3.js → env-CZmstYrY.js} +71 -59
  4. package/dist/_chunks/{env_services-Tz9G4JeT.js → env_services-CAWrZTWa.js} +138 -41
  5. package/dist/_shared/auth_selection.js +83 -0
  6. package/dist/_shared/credentials.js +188 -0
  7. package/dist/_shared/paths.js +151 -0
  8. package/dist/_shared/profiles.js +226 -0
  9. package/dist/_shared/secure_file.js +41 -0
  10. package/dist/analytics.js +2 -2
  11. package/dist/auth.js +101 -11
  12. package/dist/auth_context.js +2 -11
  13. package/dist/claimable/api.js +2 -1
  14. package/dist/commands/ask.js +2 -1
  15. package/dist/commands/auth.js +155 -63
  16. package/dist/commands/checkout.js +28 -9
  17. package/dist/commands/claim.js +11 -7
  18. package/dist/commands/config.js +9 -9
  19. package/dist/commands/deploy.js +2 -1
  20. package/dist/commands/deploy_help.js +10 -0
  21. package/dist/commands/dev.js +147 -36
  22. package/dist/commands/env.js +33 -44
  23. package/dist/commands/functions.js +63 -2
  24. package/dist/commands/profile.js +2 -2
  25. package/dist/config_services.js +36 -1
  26. package/dist/credential_io.js +1 -1
  27. package/dist/custom_domains_api.js +37 -0
  28. package/dist/dev/env.js +1 -1
  29. package/dist/dev/functions.js +28 -11
  30. package/dist/dev/websocket.js +1 -1
  31. package/dist/env_services.js +2 -2
  32. package/dist/index.js +73 -20
  33. package/dist/init/build_config.js +4 -0
  34. package/dist/neon_services.js +2 -2
  35. package/dist/psql/wire/connection.js +1 -1
  36. package/dist/refresh_lock.js +44 -0
  37. package/dist/retire_credential.js +1 -1
  38. package/dist/test_utils/neon_api_server.js +60 -0
  39. package/dist/test_utils/rotating_oauth_server.js +94 -0
  40. package/dist/utils/config_diff.js +2 -13
  41. package/package.json +7 -5
@@ -2,10 +2,11 @@ import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { log } from "../log.js";
3
3
  import { getCliName } from "../utils/cli_name.js";
4
4
  import { readEnvFile, resolveEnvFilePath } from "../env_file.js";
5
+ import { _ as functionBaseUrlKey } from "../_chunks/env_services-CAWrZTWa.js";
5
6
  import { branchIdResolve } from "../utils/enrichers.js";
6
7
  import { bundleEntry } from "../utils/esbuild.js";
7
- import { i as resolveDevEnv } from "../_chunks/env-NbA61JR3.js";
8
- import { resolveFunctionsFromConfig } from "../dev/functions.js";
8
+ import { i as resolveDevEnv } from "../_chunks/env-CZmstYrY.js";
9
+ import { findConfigFunctionBySource, resolveFunctionsFromConfig } from "../dev/functions.js";
9
10
  import { resolveWatchInputs } from "../dev/inputs.js";
10
11
  import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
11
12
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -14,6 +15,7 @@ import chalk from "chalk";
14
15
  import { spawn, spawnSync } from "node:child_process";
15
16
  import { bundleAsIs, describeNativeFinding, findUndeclaredNativePackages, resolveEsbuildEntry } from "@neon/config-runtime";
16
17
  import { once } from "node:events";
18
+ import { createServer } from "node:net";
17
19
  //#region src/commands/dev.ts
18
20
  var dev_exports = /* @__PURE__ */ __exportAll({
19
21
  builder: () => builder,
@@ -24,7 +26,10 @@ var dev_exports = /* @__PURE__ */ __exportAll({
24
26
  diffUnits: () => diffUnits,
25
27
  formatEnvSummary: () => formatEnvSummary,
26
28
  handler: () => handler,
27
- reportDevCredential: () => reportDevCredential
29
+ localFunctionUrlEnv: () => localFunctionUrlEnv,
30
+ planFunctionsToUnits: () => planFunctionsToUnits,
31
+ reportDevCredential: () => reportDevCredential,
32
+ sourceFunctionUrlOverlay: () => sourceFunctionUrlOverlay
28
33
  });
29
34
  const command = "dev";
30
35
  const describe = "Run Neon Functions locally with a dev server";
@@ -44,6 +49,11 @@ const builder = (argv) => argv.usage("$0 dev [--source <path>] [options]").examp
44
49
  "object storage and the AI Gateway where the branch has them. A neon.ts in",
45
50
  "this directory decides instead, exactly as it does for `env pull`.",
46
51
  "",
52
+ "NEON_FUNCTION_<SLUG>_BASE_URL from `env pull` / `neon-env run` is the production",
53
+ "invocation URL. `dev` rewrites it to `http://localhost:<port>` for each function",
54
+ "this process is serving (every neon.ts function, or the matching slug when",
55
+ "`--source` names one).",
56
+ "",
47
57
  "`dev` reads your .env / .env.local to reuse the branch credential behind the",
48
58
  "AI Gateway and object storage, and never writes to them. With no such file it",
49
59
  "issues a credential on every start, so run `env pull` once if you restart often."
@@ -110,11 +120,16 @@ const runSingleSource = async (props) => {
110
120
  const branchId = await resolveBranchId(props);
111
121
  const { vars: neonEnv, skipped, credential } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
112
122
  reportDevCredential(credential);
123
+ const match = await findConfigFunctionBySource(process.cwd(), source);
124
+ const { overlay, port } = await sourceFunctionUrlOverlay(match, props.port);
113
125
  const unit = {
114
126
  slug: null,
115
127
  source,
116
128
  bundleDir: devBundleDir(process.cwd()),
117
- childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
129
+ childEnv: buildChildEnv({
130
+ ...neonEnv,
131
+ ...overlay
132
+ }, port),
118
133
  label: null,
119
134
  envSummary: {
120
135
  neon: Object.keys(neonEnv),
@@ -135,11 +150,11 @@ const runFromConfig = async (props) => {
135
150
  if (functions.length === 0) throw new Error("neon.ts has no functions to serve. Add at least one under `preview.functions`, or pass --source <path>.");
136
151
  const { vars: neonEnv, skipped, credential } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
137
152
  reportDevCredential(credential);
138
- const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
139
- const replan = async (searchBase) => {
153
+ const units = await planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
154
+ const replan = async (searchBase, keepPorts) => {
140
155
  const re = await resolveFunctionsFromConfig(process.cwd());
141
156
  if (re === null) return null;
142
- return planFunctionsToUnits(re.functions, neonEnv, searchBase);
157
+ return planFunctionsToUnits(re.functions, neonEnv, searchBase, keepPorts);
143
158
  };
144
159
  await runSupervisor(units, {
145
160
  reload: {
@@ -149,22 +164,112 @@ const runFromConfig = async (props) => {
149
164
  ...skipped ? { envNote: skipped.reason } : {}
150
165
  });
151
166
  };
152
- /**
153
- * Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
154
- * base across them so search-mode functions don't all probe the same starting port.
155
- *
156
- * Each search-mode (no `dev.port`) function gets a distinct base starting at
157
- * `searchBase`; the runtime still walks upward from its base, so an occupied base
158
- * self-resolves and this never fails — the offset just makes startup deterministic.
159
- */
160
- const planFunctionsToUnits = (functions, neonEnv, searchBase) => {
167
+ /** Ports are chosen before spawn so every sibling's localhost URL can be overlaid. */
168
+ const planFunctionsToUnits = async (functions, neonEnv, searchBase, keepPorts = /* @__PURE__ */ new Map()) => {
169
+ const reserved = /* @__PURE__ */ new Set();
170
+ const portBySlug = /* @__PURE__ */ new Map();
171
+ for (const fn of functions) {
172
+ if (fn.port === void 0) continue;
173
+ portBySlug.set(fn.slug, fn.port);
174
+ reserved.add(fn.port);
175
+ }
176
+ for (const fn of functions) {
177
+ if (portBySlug.has(fn.slug)) continue;
178
+ const kept = keepPorts.get(fn.slug);
179
+ if (kept !== void 0 && !reserved.has(kept)) {
180
+ portBySlug.set(fn.slug, kept);
181
+ reserved.add(kept);
182
+ }
183
+ }
161
184
  let searchOffset = 0;
162
- return functions.map((fn) => {
163
- const base = searchBase + searchOffset;
164
- if (fn.port === void 0) searchOffset += 1;
165
- return plannedToUnit(fn, neonEnv, base);
185
+ for (const fn of functions) {
186
+ if (portBySlug.has(fn.slug)) continue;
187
+ const port = await pickFreePort(searchBase + searchOffset, reserved);
188
+ portBySlug.set(fn.slug, port);
189
+ reserved.add(port);
190
+ searchOffset = port - searchBase + 1;
191
+ }
192
+ const assigned = functions.map((fn) => {
193
+ const port = portBySlug.get(fn.slug);
194
+ if (port === void 0) throw new Error(`planFunctionsToUnits: no port for ${fn.slug}`);
195
+ return {
196
+ fn,
197
+ port
198
+ };
166
199
  });
200
+ const overlay = localFunctionUrlEnv(assigned.map(({ fn, port }) => ({
201
+ slug: fn.slug,
202
+ port
203
+ })));
204
+ const branchEnv = {
205
+ ...neonEnv,
206
+ ...overlay
207
+ };
208
+ return assigned.map(({ fn, port }) => plannedToUnit(fn, branchEnv, {
209
+ mode: "explicit",
210
+ port
211
+ }, overlay));
212
+ };
213
+ /**
214
+ * Local invocation URLs for functions `neon dev` is serving, so siblings call each
215
+ * other on localhost instead of the production compute host.
216
+ */
217
+ const localFunctionUrlEnv = (functions) => {
218
+ const out = {};
219
+ for (const fn of functions) out[functionBaseUrlKey(fn.slug)] = `http://localhost:${fn.port}`;
220
+ return out;
221
+ };
222
+ /**
223
+ * `--source` serves one file. If that file is a neon.ts function, rewrite only that
224
+ * slug to localhost — this process is not listening for the others.
225
+ */
226
+ const sourceFunctionUrlOverlay = async (match, cliPort) => {
227
+ const portSpec = portFromProps(cliPort);
228
+ if (match === void 0) return {
229
+ overlay: {},
230
+ port: portSpec
231
+ };
232
+ const port = portSpec.mode === "explicit" ? portSpec.port : match.port ?? await pickFreePort(portSpec.from);
233
+ return {
234
+ overlay: localFunctionUrlEnv([{
235
+ slug: match.slug,
236
+ port
237
+ }]),
238
+ port: {
239
+ mode: "explicit",
240
+ port
241
+ }
242
+ };
167
243
  };
244
+ const pickFreePort = (from, reserved = /* @__PURE__ */ new Set()) => new Promise((resolvePort, reject) => {
245
+ const tryPort = (port) => {
246
+ if (port > 65535) {
247
+ reject(/* @__PURE__ */ new Error(`No free port at or above ${from}`));
248
+ return;
249
+ }
250
+ if (reserved.has(port)) {
251
+ tryPort(port + 1);
252
+ return;
253
+ }
254
+ const server = createServer();
255
+ server.once("error", (err) => {
256
+ if (err.code === "EADDRINUSE") {
257
+ tryPort(port + 1);
258
+ return;
259
+ }
260
+ reject(err);
261
+ });
262
+ server.listen(port, "127.0.0.1", () => {
263
+ const addr = server.address();
264
+ const bound = typeof addr === "object" && addr !== null ? addr.port : port;
265
+ server.close((closeErr) => {
266
+ if (closeErr) reject(closeErr);
267
+ else resolvePort(bound);
268
+ });
269
+ });
270
+ };
271
+ tryPort(from);
272
+ });
168
273
  /**
169
274
  * Resolve the selected branch id from props, if any. Best-effort: a failure here only
170
275
  * means env injection is skipped, so it never throws.
@@ -211,19 +316,11 @@ const portFromProps = (port) => {
211
316
  */
212
317
  const devBundleDir = (cwd, slug) => slug === void 0 ? join(cwd, "node_modules", ".neon-dev") : join(cwd, "node_modules", ".neon-dev", slug);
213
318
  /**
214
- * Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
215
- * - explicit `dev.port`: bind exactly, fail if taken.
216
- * - no `dev.port`: search for a free port (base coordinated by the caller).
319
+ * Translate a {@link PlannedFunction} into a {@link ServedUnit}. The bind port is
320
+ * already chosen by the caller so sibling localhost URLs can be overlaid first.
217
321
  * Per-function neon.ts env layers over the shared branch env.
218
322
  */
219
- const plannedToUnit = (fn, branchEnv, searchBase) => {
220
- const port = fn.port !== void 0 ? {
221
- mode: "explicit",
222
- port: fn.port
223
- } : {
224
- mode: "search",
225
- from: searchBase
226
- };
323
+ const plannedToUnit = (fn, branchEnv, port, overlay = {}) => {
227
324
  const childEnv = buildChildEnv({
228
325
  ...branchEnv,
229
326
  ...fn.env
@@ -244,6 +341,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
244
341
  source: fn.source,
245
342
  port: fn.port ?? null,
246
343
  env: fn.env,
344
+ overlay,
247
345
  externalPackages: fn.externalPackages ?? null,
248
346
  bundler: typeof fn.bundler === "function" ? "custom" : fn.bundler ?? null
249
347
  })
@@ -442,7 +540,7 @@ const reconcileOnce = async (running, replan, ops) => {
442
540
  if (ops.isShuttingDown()) return;
443
541
  let desired;
444
542
  try {
445
- desired = await replan(nextSearchBase(running));
543
+ desired = await replan(nextSearchBase(running), keepPortsFromRunning(running));
446
544
  } catch (err) {
447
545
  log.info(chalk.red("neon.ts change ignored: ") + (err instanceof Error ? err.message : String(err)) + chalk.dim(" (fix it and save again)"));
448
546
  return;
@@ -470,10 +568,23 @@ const reconcileOnce = async (running, replan, ops) => {
470
568
  }
471
569
  }
472
570
  };
571
+ const keepPortsFromRunning = (running) => {
572
+ const keep = /* @__PURE__ */ new Map();
573
+ for (const r of running) {
574
+ if (r.unit.slug === null) continue;
575
+ const bound = r.boundPort;
576
+ if (bound !== null) {
577
+ keep.set(r.unit.slug, bound);
578
+ continue;
579
+ }
580
+ const pending = Number(r.unit.childEnv.NEON_DEV_PORT);
581
+ if (Number.isFinite(pending) && pending > 0) keep.set(r.unit.slug, pending);
582
+ }
583
+ return keep;
584
+ };
473
585
  /**
474
- * Choose a port search base above every port the live units already bound, so a hot-added
475
- * search-mode function starts probing where there's room. The runtime still walks upward
476
- * from here, so it never fails even if this guess is taken — it just keeps things tidy.
586
+ * Start pickFreePort above every port live units already bound, so a hot-added
587
+ * function is not probed on a port a sibling is serving.
477
588
  */
478
589
  const nextSearchBase = (running) => {
479
590
  let max = 8786;
@@ -757,4 +868,4 @@ const resolveRuntimePath = () => {
757
868
  return candidate;
758
869
  };
759
870
  //#endregion
760
- export { builder, command, describe, devBundleDir, devEnvContext, diffUnits, formatEnvSummary, handler, reportDevCredential, dev_exports as t };
871
+ export { builder, command, describe, devBundleDir, devEnvContext, diffUnits, formatEnvSummary, handler, localFunctionUrlEnv, planFunctionsToUnits, reportDevCredential, sourceFunctionUrlOverlay, dev_exports as t };
@@ -4,11 +4,11 @@ import { ensureGitignored } from "../context.js";
4
4
  import { getCliName } from "../utils/cli_name.js";
5
5
  import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
6
6
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
7
- import { a as envKeysForSelection, c as ownedEnvServiceKeys, f as NEON_ENV_VAR_KEYS, i as ENV_PULL_UNAVAILABLE, l as parseEnvPullKeys, n as ENV_PULL_KEYS, o as envPullFlagValue, r as ENV_PULL_SERVICES } from "../_chunks/env_services-Tz9G4JeT.js";
7
+ import { a as envKeysForSelection, c as ownedEnvServiceKeys, f as NEON_ENV_VAR_KEYS, i as ENV_PULL_SERVICES, l as parseEnvPullKeys, o as envPullFlagValue, r as ENV_PULL_KEY_HELP, v as isFunctionBaseUrlKey } from "../_chunks/env_services-CAWrZTWa.js";
8
8
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
9
9
  import { warnAiGateway } from "../utils/ai_gateway_notice.js";
10
10
  import { announceTargetBranch } from "../utils/branch_notice.js";
11
- import { a as resolveNeonEnvVars } from "../_chunks/env-NbA61JR3.js";
11
+ import { a as resolveNeonEnvVars } from "../_chunks/env-CZmstYrY.js";
12
12
  import { existsSync } from "node:fs";
13
13
  import chalk from "chalk";
14
14
  //#region src/commands/env.ts
@@ -51,7 +51,7 @@ const builder = (argv) => argv.usage("$0 env <sub-command> [options]").options({
51
51
  }),
52
52
  env: {
53
53
  alias: "e",
54
- describe: `Pull only these individual variables: ${ENV_PULL_KEYS.join(", ")}. Repeat the flag or comma-separate. Overrides neon.ts. Combines with --service as a union; it never narrows a service bundle.`,
54
+ describe: `Pull only these individual variables: ${ENV_PULL_KEY_HELP}. Repeat the flag or comma-separate. Overrides neon.ts. Combines with --service as a union; it never narrows a service bundle.`,
55
55
  type: "array",
56
56
  string: true
57
57
  }
@@ -59,19 +59,19 @@ const builder = (argv) => argv.usage("$0 env <sub-command> [options]").options({
59
59
  "",
60
60
  "What gets pulled, in precedence order:",
61
61
  " 1. --service / --env, when given — their union, ignoring neon.ts.",
62
- " 2. neon.ts, when this directory has one.",
62
+ " 2. neon.ts, when this directory has one — including derived function",
63
+ " URLs (the function does not have to be deployed).",
63
64
  " 3. Otherwise everything the branch has, plus the AI Gateway —",
64
65
  " which mints a branch credential for it.",
65
66
  "",
66
67
  "The pull bundled into link / checkout / config apply follows 2 and 3",
67
68
  "without the AI Gateway, so it never mints a credential you did not ask",
68
69
  "for. Run `env pull` to add it."
69
- ].join("\n")).example("$0 env pull", "Write the linked branch's Neon vars into .env.local (or .env if present)").example("$0 env pull --branch preview --file .env.preview", "Pull a specific branch into a specific file").example("$0 env pull -s ai-gateway -s postgres", "Pull only the AI Gateway and Postgres variables").example("$0 env pull -e DATABASE_URL", "Pull only the pooled Postgres connection string").example("$0 env pull -s auth -e DATABASE_URL", "Pull all Auth variables plus DATABASE_URL").strict(), async (args) => {
70
+ ].join("\n")).example("$0 env pull", "Write the linked branch's Neon vars into .env.local (or .env if present)").example("$0 env pull --branch preview --file .env.preview", "Pull a specific branch into a specific file").example("$0 env pull -s ai-gateway -s postgres", "Pull only the AI Gateway and Postgres variables").example("$0 env pull -e DATABASE_URL", "Pull only the pooled Postgres connection string").example("$0 env pull -s auth -e DATABASE_URL", "Pull all Auth variables plus DATABASE_URL").example("$0 env pull -s functions", "Pull NEON_FUNCTION_<SLUG>_BASE_URL for every deployed function").example("$0 env pull -e NEON_FUNCTION_HELLO_BASE_URL", "Write that function's production invocation URL (no deploy required)").strict(), async (args) => {
70
71
  const rawServices = servicesFlagValue(args.service);
71
72
  const rawEnvKeys = envPullFlagValue(args.env);
72
73
  const services = rawServices ? parseServices(rawServices, {
73
74
  allowed: ENV_PULL_SERVICES,
74
- whyUnavailable: ENV_PULL_UNAVAILABLE,
75
75
  flag: "--service",
76
76
  onDeprecated: (used, canonical) => log.warning(deprecatedServiceMessage(used, canonical))
77
77
  }) : void 0;
@@ -127,16 +127,17 @@ const pull = async (props, opts = {}) => {
127
127
  ...props.services ? { services: props.services } : {},
128
128
  ...props.envKeys ? { envKeys: props.envKeys } : {},
129
129
  ...opts.implyAiGateway ? { implyAiGateway: true } : {},
130
+ omitUnsetFunctionEnv: true,
130
131
  ...props.apiKey ? { apiKey: props.apiKey } : {},
131
132
  ...props.apiHost ? { apiHost: props.apiHost } : {},
132
133
  ...props.runtimeApi ? { api: props.runtimeApi } : {}
133
134
  });
134
- const neonVars = pickSelectedVars(pickNeonVars(vars), selectedKeys);
135
+ const neonVars = pickSelectedVars(pickNeonVars(vars), selectedKeys, props.services);
135
136
  if (Object.keys(neonVars).length === 0) {
136
137
  log.info("No Neon env variables to pull for this branch (no DATABASE_URL or enabled Auth / Data API).");
137
138
  return { status: "empty" };
138
139
  }
139
- const { written, removed } = mergeEnvFile(targetPath, neonVars, { managedKeys: managedKeysFor(selectedKeys, unreachedButCurrent(skipped, existingEnv, branchId)) });
140
+ const { written, removed } = mergeEnvFile(targetPath, neonVars, { managedKeys: managedKeysFor(selectedKeys, unreachedButCurrent(skipped, existingEnv, branchId), existingEnv, props.services, props.envKeys) });
140
141
  log.info("Pulled %d Neon variable%s into %s: %s", written.length, written.length === 1 ? "" : "s", targetPath, written.join(", "));
141
142
  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(", "));
142
143
  if (credential?.issued) {
@@ -164,42 +165,32 @@ const pull = async (props, opts = {}) => {
164
165
  ...skipped && skipped.length > 0 ? { skipped } : {}
165
166
  };
166
167
  };
167
- /**
168
- * The keys this pull is allowed to prune, i.e. the ones it is authoritative for.
169
- *
170
- * An explicit selection narrows that to the service bundles and individual keys it named:
171
- * `env pull -s ai-gateway` and `env pull -e NEON_AI_GATEWAY_TOKEN` both say nothing about
172
- * `DATABASE_URL`. `unreached` is subtracted for the same reason — see
173
- * {@link unreachedButCurrent}.
174
- */
175
- const managedKeysFor = (selectedKeys, unreached) => {
176
- const owned = selectedKeys ? NEON_OWNED_ENV_KEYS.filter((key) => selectedKeys.includes(key)) : [...NEON_OWNED_ENV_KEYS];
168
+ /** Prevents scoped or unavailable services from pruning values they did not resolve. */
169
+ const managedKeysFor = (selectedKeys, unreached, existingEnv, services, envKeys) => {
170
+ const existingFunctionKeys = Object.keys(existingEnv).filter(isFunctionBaseUrlKey);
171
+ const namedFunctionKeys = (envKeys ?? []).filter(isFunctionBaseUrlKey);
172
+ const ownedFunctionKeys = selectedKeys === void 0 || (services?.includes("functions") ?? false) ? existingFunctionKeys : namedFunctionKeys;
173
+ const owned = selectedKeys ? [...NEON_OWNED_ENV_KEYS.filter((key) => selectedKeys.includes(key)), ...ownedFunctionKeys] : [...NEON_OWNED_ENV_KEYS, ...ownedFunctionKeys];
177
174
  if (unreached.length === 0) return owned;
178
175
  const keep = new Set(ownedEnvServiceKeys(unreached));
179
- return owned.filter((key) => !keep.has(key));
176
+ return owned.filter((key) => {
177
+ if (unreached.includes("functions") && isFunctionBaseUrlKey(key)) return false;
178
+ return !keep.has(key);
179
+ });
180
180
  };
181
181
  /**
182
- * Of the services this pull could not reach, the ones whose variables already on disk belong
183
- * to the branch being pulled — the only ones worth keeping.
184
- *
185
- * Failing to reach a service is not evidence that the branch stopped having it:
186
- * `PLATFORM_FEATURE_UNAVAILABLE` covers a transient incident as well as a project that
187
- * genuinely lacks the feature, and pruning would delete a token whose secret exists nowhere
188
- * else and strand the live credential behind it. But that only argues for keeping *this
189
- * branch's* values. Variables left over from another branch are stale by definition, and
190
- * keeping those would leave an app pointed at the wrong branch's gateway — a worse failure
191
- * than losing a token, because it is silent.
192
- *
193
- * The gateway is the only service that can be unreached (only it is implied rather than
194
- * observed), and its base URL is branch-scoped, so the persisted URL is what tells the two
195
- * cases apart. Anything that does not resolve to this branch's gateway host is pruned, which
196
- * is the safe direction: a stale entry costs a re-pull, a wrongly-kept one silently misroutes
197
- * traffic.
182
+ * Endpoint failure is not evidence that existing values are stale; the gateway host check
183
+ * avoids preserving credentials for a different branch.
198
184
  */
199
185
  const unreachedButCurrent = (skipped, existingEnv, branchId) => {
200
- if (!skipped?.includes("ai-gateway")) return [];
201
- const baseUrl = existingEnv[NEON_ENV_VAR_KEYS.aiGateway.baseUrl];
202
- return baseUrl !== void 0 && isBranchGatewayUrl(baseUrl, branchId) ? ["ai-gateway"] : [];
186
+ if (!skipped) return [];
187
+ const keep = [];
188
+ if (skipped.includes("ai-gateway")) {
189
+ const baseUrl = existingEnv[NEON_ENV_VAR_KEYS.aiGateway.baseUrl];
190
+ if (baseUrl !== void 0 && isBranchGatewayUrl(baseUrl, branchId)) keep.push("ai-gateway");
191
+ }
192
+ if (skipped.includes("functions")) keep.push("functions");
193
+ return keep;
203
194
  };
204
195
  /**
205
196
  * Whether a persisted `NEON_AI_GATEWAY_BASE_URL` addresses `branchId`'s gateway.
@@ -216,10 +207,11 @@ const isBranchGatewayUrl = (baseUrl, branchId) => URL.canParse(baseUrl) && new U
216
207
  * supporting values that were not selected (the direct connection URI derives the AI Gateway
217
208
  * host, for example); those must not land in the target file.
218
209
  */
219
- const pickSelectedVars = (vars, selectedKeys) => {
210
+ const pickSelectedVars = (vars, selectedKeys, services) => {
220
211
  if (!selectedKeys) return vars;
221
212
  const wanted = new Set(selectedKeys);
222
- return Object.fromEntries(Object.entries(vars).filter(([key]) => wanted.has(key)));
213
+ const keepFunctionUrls = services?.includes("functions") ?? false;
214
+ return Object.fromEntries(Object.entries(vars).filter(([key]) => wanted.has(key) || keepFunctionUrls && isFunctionBaseUrlKey(key)));
223
215
  };
224
216
  /**
225
217
  * Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
@@ -254,10 +246,7 @@ Run \`${getCliName()} env pull\` once resolved (e.g. \`${getCliName()} deploy\`
254
246
  */
255
247
  const pickNeonVars = (vars) => {
256
248
  const out = {};
257
- for (const name of NEON_VAR_NAMES) {
258
- const value = vars[name];
259
- if (value !== void 0) out[name] = value;
260
- }
249
+ for (const [name, value] of Object.entries(vars)) if (NEON_VAR_NAMES.some((key) => key === name) || isFunctionBaseUrlKey(name)) out[name] = value;
261
250
  return out;
262
251
  };
263
252
  //#endregion
@@ -2,11 +2,13 @@ import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { log } from "../log.js";
3
3
  import { isNeonApiError, retryOnLock } from "../api.js";
4
4
  import { getCliName } from "../utils/cli_name.js";
5
+ import { deleteCustomDomain, listCustomDomains, registerCustomDomain } from "../custom_domains_api.js";
5
6
  import { createDeployment, deleteFunction, getFunction, listFunctions } from "../functions_api.js";
6
7
  import { writer } from "../writer.js";
7
8
  import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
8
9
  import { bundleEntry } from "../utils/esbuild.js";
9
10
  import { zipBundle } from "../utils/zip.js";
11
+ import { DEPLOY_COMMANDS_EPILOGUE } from "./deploy_help.js";
10
12
  import { statSync } from "node:fs";
11
13
  import { dirname } from "node:path";
12
14
  import { FUNCTION_SOURCE_ENTRIES } from "@neon/config";
@@ -26,6 +28,13 @@ const FUNCTION_FIELDS = [
26
28
  "created_at"
27
29
  ];
28
30
  const FUNCTIONS_LIST_LIMIT = 100;
31
+ const CUSTOM_DOMAINS_LIST_LIMIT = 100;
32
+ const CUSTOM_DOMAIN_FIELDS = [
33
+ "domain",
34
+ "entity_type",
35
+ "entity_id",
36
+ "cname_target"
37
+ ];
29
38
  const LIST_TABLE_FIELDS = [
30
39
  "slug",
31
40
  "name",
@@ -107,7 +116,7 @@ const builder = (argv) => argv.usage("$0 function <sub-command> [options]").opti
107
116
  type: "boolean",
108
117
  default: true
109
118
  }
110
- }), (args) => deploy(args)).command("list", "List functions on the branch", (yargs) => yargs, (args) => list(args)).command("get <slug>", "Show a function's details", (yargs) => yargs.positional("slug", {
119
+ }).epilogue(DEPLOY_COMMANDS_EPILOGUE), (args) => deploy(args)).command("list", "List functions on the branch", (yargs) => yargs, (args) => list(args)).command("get <slug>", "Show a function's details", (yargs) => yargs.positional("slug", {
111
120
  describe: "Function slug",
112
121
  type: "string",
113
122
  demandOption: true
@@ -120,7 +129,19 @@ const builder = (argv) => argv.usage("$0 function <sub-command> [options]").opti
120
129
  describe: "Function slug",
121
130
  type: "string",
122
131
  demandOption: true
123
- }), (args) => deleteFn(args));
132
+ }), (args) => deleteFn(args)).command(["domains", "domain"], "Manage custom domains on the branch (beta)", (yargs) => yargs.demandCommand(1).command("list", "List custom domains on the branch", (yargs) => yargs, (args) => listDomains(args)).command("register <domain>", "Point a domain you already own at a function on the branch", (yargs) => yargs.positional("domain", {
133
+ describe: "Domain you already own (for example docs.example.com)",
134
+ type: "string",
135
+ demandOption: true
136
+ }).options({ slug: {
137
+ describe: "Function slug this domain should point at",
138
+ type: "string",
139
+ demandOption: true
140
+ } }), (args) => registerDomain(args)).command("delete <domain>", "Delete a custom domain from the branch", (yargs) => yargs.positional("domain", {
141
+ describe: "Custom domain",
142
+ type: "string",
143
+ demandOption: true
144
+ }), (args) => deleteDomain(args)));
124
145
  const handler = (args) => {
125
146
  return args;
126
147
  };
@@ -314,5 +335,45 @@ const list = async (props) => {
314
335
  emptyMessage: "No functions found on this branch."
315
336
  });
316
337
  };
338
+ const listDomains = async (props) => {
339
+ const branchId = await branchIdFromProps(props);
340
+ const domains = [];
341
+ let cursor;
342
+ for (;;) {
343
+ const page = await listCustomDomains(props.apiClient, props.projectId, branchId, {
344
+ cursor,
345
+ limit: CUSTOM_DOMAINS_LIST_LIMIT
346
+ });
347
+ domains.push(...page.custom_domains);
348
+ if (!page.next || page.next === cursor) break;
349
+ cursor = page.next;
350
+ }
351
+ writer(props).end(domains, {
352
+ fields: CUSTOM_DOMAIN_FIELDS,
353
+ emptyMessage: "No custom domains found on this branch."
354
+ });
355
+ };
356
+ const registerDomain = async (props) => {
357
+ if (!SLUG_PATTERN.test(props.slug)) throw new Error(`Invalid function slug "${props.slug}". ${SLUG_HELP}`);
358
+ const branchId = await branchIdFromProps(props);
359
+ const registered = await retryOnLock(() => registerCustomDomain(props.apiClient, props.projectId, branchId, {
360
+ domain: props.domain,
361
+ entity_type: "function",
362
+ entity_id: props.slug
363
+ }));
364
+ writer(props).end(registered, { fields: CUSTOM_DOMAIN_FIELDS });
365
+ if (registered.cname_target) log.info(`CNAME ${registered.domain} to ${registered.cname_target}`);
366
+ else log.info(`No CNAME target for ${registered.domain}; this region has no custom-domains front door.`);
367
+ };
368
+ const deleteDomain = async (props) => {
369
+ const branchId = await branchIdFromProps(props);
370
+ try {
371
+ await retryOnLock(() => deleteCustomDomain(props.apiClient, props.projectId, branchId, props.domain));
372
+ } catch (err) {
373
+ if (isNeonApiError(err) && err.status === 404) throw new Error(`Custom domain "${props.domain}" not found on branch ${branchId}.`);
374
+ throw err;
375
+ }
376
+ log.info(`Custom domain ${props.domain} deleted from branch ${branchId}`);
377
+ };
317
378
  //#endregion
318
379
  export { aliases, builder, command, describe, handler, functions_exports as t };
@@ -1,11 +1,11 @@
1
1
  import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
2
2
  import { t as writeSecretFile } from "../_chunks/secure_file-BucZj4yQ.js";
3
- import { c as describeScope, d as scopeOf, l as interpretCredentials, n as KEYRING_SERVICE, o as apiKeyCredentials, r as keyringAccount, s as credentialLabel, t as storeFor } from "../_chunks/credential_io-YeAxg9Xn.js";
3
+ import { c as describeScope, d as scopeOf, l as interpretCredentials, n as KEYRING_SERVICE, o as apiKeyCredentials, r as keyringAccount, s as credentialLabel, t as storeFor } from "../_chunks/credential_io-D8AR-jvB.js";
4
4
  import { isCi } from "../env.js";
5
5
  import { log } from "../log.js";
6
6
  import { getApiClient, isNeonApiError } from "../api.js";
7
- import { a as isOwnedCredentialPath, n as credentialsPath } from "../_chunks/paths-DMq0Lt7a.js";
8
7
  import { setAuthContext } from "../auth_context.js";
8
+ import { a as isOwnedCredentialPath, n as credentialsPath } from "../_chunks/paths-DMq0Lt7a.js";
9
9
  import { _ as upsertProfile, a as canDropProfilesFile, c as listProfiles, d as newProfileLocation, f as profilesFilePath, g as selectProfileName, h as resolveProfile, i as assertValidProfileName, l as locationForName, m as readProfiles, n as KEYRING_CREDENTIALS, o as credentialsDisplay, p as profilesUsingPath, r as assertProfilesUsable, s as isKeyringPointer, u as locationOf, v as CRED_STORAGE_FILE, y as CRED_STORAGE_KEYRING } from "../_chunks/profiles-CvnFEQyd.js";
10
10
  import { auth, revokeToken } from "../auth.js";
11
11
  import { t as credentialInputs } from "../_chunks/auth_selection-pitMFzDe.js";
@@ -14,5 +14,40 @@ const declaredNeonServices = (config) => {
14
14
  if (isToggleEnabled(config.preview?.aiGateway)) services.push("ai-gateway");
15
15
  return services;
16
16
  };
17
+ const dataApiSettingsToSnake = (settings) => {
18
+ const out = {};
19
+ if (settings.dbAggregatesEnabled !== void 0) out.db_aggregates_enabled = settings.dbAggregatesEnabled;
20
+ if (settings.dbAnonRole !== void 0) out.db_anon_role = settings.dbAnonRole;
21
+ if (settings.dbExtraSearchPath !== void 0) out.db_extra_search_path = settings.dbExtraSearchPath;
22
+ if (settings.dbMaxRows !== void 0) out.db_max_rows = settings.dbMaxRows;
23
+ if (settings.dbSchemas !== void 0) out.db_schemas = settings.dbSchemas;
24
+ if (settings.jwtRoleClaimKey !== void 0) out.jwt_role_claim_key = settings.jwtRoleClaimKey;
25
+ if (settings.jwtCacheMaxLifetime !== void 0) out.jwt_cache_max_lifetime = settings.jwtCacheMaxLifetime;
26
+ if (settings.openapiMode !== void 0) out.openapi_mode = settings.openapiMode;
27
+ if (settings.serverCorsAllowedOrigins !== void 0) out.server_cors_allowed_origins = settings.serverCorsAllowedOrigins;
28
+ if (settings.serverTimingEnabled !== void 0) out.server_timing_enabled = settings.serverTimingEnabled;
29
+ return out;
30
+ };
31
+ /** Omitting this body preserves the server heuristic used by `--service data-api`. */
32
+ const claimableDataApiCreateBody = (config) => {
33
+ if (!isToggleEnabled(config.dataApi)) return void 0;
34
+ const input = config.dataApi;
35
+ if (typeof input !== "object") return { auth_provider: "neon_auth" };
36
+ const settings = input.settings ? dataApiSettingsToSnake(input.settings) : void 0;
37
+ const withSettings = (body) => settings && Object.keys(settings).length > 0 ? {
38
+ ...body,
39
+ settings
40
+ } : body;
41
+ if (input.authProvider === "external") {
42
+ if (input.jwksUrl === void 0 || input.jwksUrl.length === 0) throw new Error("neon.ts dataApi.authProvider \"external\" requires dataApi.jwksUrl.");
43
+ return withSettings({
44
+ auth_provider: "external",
45
+ jwks_url: input.jwksUrl,
46
+ ...input.providerName !== void 0 ? { provider_name: input.providerName } : {},
47
+ ...input.jwtAudience !== void 0 ? { jwt_audience: input.jwtAudience } : {}
48
+ });
49
+ }
50
+ return withSettings({ auth_provider: "neon_auth" });
51
+ };
17
52
  //#endregion
18
- export { declaredNeonServices };
53
+ export { claimableDataApiCreateBody, declaredNeonServices };
@@ -1,2 +1,2 @@
1
- import { t as storeFor } from "./_chunks/credential_io-YeAxg9Xn.js";
1
+ import { t as storeFor } from "./_chunks/credential_io-D8AR-jvB.js";
2
2
  export { storeFor };
@@ -0,0 +1,37 @@
1
+ //#region src/custom_domains_api.ts
2
+ const customDomainsPath = (projectId, branchId) => `/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/custom-domains`;
3
+ const listCustomDomains = async (apiClient, projectId, branchId, { cursor, limit } = {}) => {
4
+ const { data } = await apiClient.request({
5
+ path: customDomainsPath(projectId, branchId),
6
+ method: "GET",
7
+ query: {
8
+ cursor,
9
+ limit
10
+ },
11
+ secure: true,
12
+ format: "json"
13
+ });
14
+ return {
15
+ custom_domains: data.custom_domains ?? [],
16
+ next: data.pagination?.next
17
+ };
18
+ };
19
+ const registerCustomDomain = async (apiClient, projectId, branchId, input) => {
20
+ const { data } = await apiClient.request({
21
+ path: customDomainsPath(projectId, branchId),
22
+ method: "POST",
23
+ body: input,
24
+ format: "json",
25
+ secure: true
26
+ });
27
+ return data;
28
+ };
29
+ const deleteCustomDomain = async (apiClient, projectId, branchId, domain) => {
30
+ await apiClient.request({
31
+ path: `${customDomainsPath(projectId, branchId)}/${encodeURIComponent(domain)}`,
32
+ method: "DELETE",
33
+ secure: true
34
+ });
35
+ };
36
+ //#endregion
37
+ export { deleteCustomDomain, listCustomDomains, registerCustomDomain };
package/dist/dev/env.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as resolveNeonEnvVars, i as resolveDevEnv, n as MissingBranchContextError, r as ServiceNotOnBranchError, t as DevEnvMismatchError } from "../_chunks/env-NbA61JR3.js";
1
+ import { a as resolveNeonEnvVars, i as resolveDevEnv, n as MissingBranchContextError, r as ServiceNotOnBranchError, t as DevEnvMismatchError } from "../_chunks/env-CZmstYrY.js";
2
2
  export { DevEnvMismatchError, MissingBranchContextError, ServiceNotOnBranchError, resolveDevEnv, resolveNeonEnvVars };