neon 2.46.0 → 3.0.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.
@@ -0,0 +1,223 @@
1
+ import { credentialScopesSatisfied, } from "@neon/config/v1";
2
+ import { createApiFromOptions, credentialEnvKeys, credentialName, fetchEnvKeys, NEON_ENV_VAR_KEYS, policyEnvKeys, previewCredentialScopes, resolveBranchPolicy, toEntries, } from "./env.js";
3
+ /**
4
+ * Resolve a branch's env while keeping one-time secrets the caller already holds.
5
+ *
6
+ * {@link fetchEnvKeys} — and the public `fetchEnv` — only ever *fetch*. The Neon API returns a
7
+ * credential's `api_token` / `s3_secret_access_key` exactly once, at mint time, so "fetching"
8
+ * them means minting a new credential; a plain `fetchEnv` on every `neon dev` start or `env
9
+ * pull` would leave a live credential behind each time. This is the wrapper that avoids that:
10
+ * it looks at what the caller already has, decides what is still usable, and asks `fetchEnv`
11
+ * for only the rest.
12
+ *
13
+ * The check is a real verification, not a presence test. A persisted secret is kept only when
14
+ * it names a credential that still exists on this branch, is not revoked or expired, and
15
+ * carries every scope the policy needs. A `.env.example` placeholder, a credential revoked in
16
+ * the console, one copied in from another branch, or one predating a newly-enabled feature all
17
+ * fail that check and get replaced.
18
+ *
19
+ * None of this needs local bookkeeping, because the secrets carry their own credential id:
20
+ * `AWS_ACCESS_KEY_ID` **is** the credential's `tokenId` (the storage gateway authenticates
21
+ * against the full id), and the AI Gateway token is minted as `nt_live_<tokenIdShort>_<secret>`,
22
+ * where `tokenIdShort` is what the credentials list reports. The env source being replaced is
23
+ * the record of what the last call issued.
24
+ *
25
+ * ```ts
26
+ * import { fetchEnvReusingSecrets } from "../_shared/env-core/reuse-secrets.js";
27
+ *
28
+ * const { vars, credential } = await fetchEnvReusingSecrets(config, {
29
+ * projectId,
30
+ * branch: "main",
31
+ * env: { ...process.env, ...readEnvFile(".env") },
32
+ * });
33
+ * if (credential.issued) console.log(`new values for ${credential.keys.join(", ")}`);
34
+ * ```
35
+ */
36
+ export async function fetchEnvReusingSecrets(config, options) {
37
+ const { env: source = process.env, revokeSuperseded = true, ...fetchOptions } = options;
38
+ const api = options.api ?? createApiFromOptions(options);
39
+ const { branch, desired } = await resolveBranchPolicy(config, options, api);
40
+ const storageEnabled = (desired.preview?.buckets.length ?? 0) > 0;
41
+ const gatewayEnabled = desired.preview?.aiGatewayEnabled ?? false;
42
+ const secretKeys = credentialEnvKeys({
43
+ storage: storageEnabled,
44
+ aiGateway: gatewayEnabled,
45
+ });
46
+ // Nothing credential-backed on this branch, so there is nothing to preserve and no
47
+ // credential to spend: fetch everything and skip the credentials endpoint entirely.
48
+ if (secretKeys.length === 0) {
49
+ const fetched = await fetchEnvKeys(config, fetchOptions, null);
50
+ return {
51
+ vars: preferPersisted(toEntries(fetched), source),
52
+ credential: {
53
+ issued: false,
54
+ keys: [],
55
+ revoked: [],
56
+ superseded: [],
57
+ },
58
+ };
59
+ }
60
+ const persisted = readPersistedSecrets(source);
61
+ const complete = (!storageEnabled ||
62
+ Boolean(persisted.accessKeyId && persisted.secretAccessKey)) &&
63
+ (!gatewayEnabled || Boolean(persisted.apiToken));
64
+ // Look the persisted secrets up whenever there are any — not only when they're complete.
65
+ // An incomplete set still names the credential a newly-enabled feature is about to
66
+ // supersede (a storage-only credential on a branch that just gained the AI Gateway), and
67
+ // that one should be revoked rather than left live.
68
+ const named = persisted.accessKeyId !== "" || persisted.apiToken !== ""
69
+ ? namedCredentials(await api.listCredentials(options.projectId, branch.id), persisted)
70
+ : { storage: null, gateway: null };
71
+ const reusable = complete
72
+ ? reusableCredential(named, { storageEnabled, gatewayEnabled })
73
+ : null;
74
+ const scopes = previewCredentialScopes(desired.preview);
75
+ const keep = reusable !== null && credentialScopesSatisfied(reusable.scopes, scopes);
76
+ // Ask for everything the policy produces, minus the secrets we're keeping — which is what
77
+ // stops `fetchEnv` from minting a credential it doesn't need.
78
+ const allKeys = policyEnvKeys(desired);
79
+ const fetchKeys = keep
80
+ ? allKeys.filter((key) => !secretKeys.includes(key))
81
+ : allKeys;
82
+ const fetched = await fetchEnvKeys(config,
83
+ // Pass the resolved id so `fetchEnv` targets the same branch this call verified against,
84
+ // even if `options.branch` was a name that has since been reused.
85
+ { ...fetchOptions, branchId: branch.id, api }, fetchKeys);
86
+ const vars = preferPersisted(toEntries(fetched), source);
87
+ if (keep) {
88
+ for (const key of secretKeys) {
89
+ const value = source[key];
90
+ if (value !== undefined)
91
+ vars[key] = value;
92
+ }
93
+ return {
94
+ vars,
95
+ credential: {
96
+ issued: false,
97
+ keys: secretKeys,
98
+ revoked: [],
99
+ superseded: [],
100
+ },
101
+ };
102
+ }
103
+ // A replacement was minted, so revoke what it supersedes: the credentials the old secrets
104
+ // named, minus any this tool did not issue. Their secrets lived nowhere but the env source
105
+ // this call replaces, so revoking them strands nothing — and it keeps a branch from
106
+ // accumulating a live credential per call. Everything else on the branch is left alone: it
107
+ // may belong to a teammate, another checkout, or a deployed function, and nothing
108
+ // observable distinguishes those from an orphan of our own.
109
+ //
110
+ // Revoked *after* the fetch, so a failed fetch leaves the caller's existing secrets working.
111
+ const ours = new Set();
112
+ for (const meta of [named.storage, named.gateway]) {
113
+ if (meta !== null &&
114
+ meta.principalType === "user" &&
115
+ meta.name === credentialName(branch.name)) {
116
+ ours.add(meta.tokenId);
117
+ }
118
+ }
119
+ if (revokeSuperseded) {
120
+ for (const tokenId of ours) {
121
+ await api.revokeCredential(options.projectId, branch.id, tokenId);
122
+ }
123
+ }
124
+ return {
125
+ vars,
126
+ credential: {
127
+ issued: true,
128
+ keys: secretKeys,
129
+ revoked: revokeSuperseded ? [...ours] : [],
130
+ superseded: revokeSuperseded ? [] : [...ours],
131
+ },
132
+ };
133
+ }
134
+ /** Read the branch credential's secrets out of an env source. */
135
+ function readPersistedSecrets(source) {
136
+ const storage = NEON_ENV_VAR_KEYS.storage;
137
+ const gateway = NEON_ENV_VAR_KEYS.aiGateway;
138
+ return {
139
+ accessKeyId: source[storage.accessKeyId] ?? "",
140
+ secretAccessKey: source[storage.secretAccessKey] ?? "",
141
+ apiToken: source[gateway.apiKey] ?? "",
142
+ };
143
+ }
144
+ /**
145
+ * Keep a persisted value rather than overwriting it with an empty fetched one.
146
+ *
147
+ * Neon Auth's `base_url` is the case that needs this: integrations created before the API
148
+ * returned it answer with an empty string, and the persisted copy is the only one left. An
149
+ * empty fetched value never carries more information than a non-empty persisted one, so
150
+ * preferring the latter is safe for every var — and it keeps a pull from blanking a working
151
+ * line in someone's `.env`.
152
+ */
153
+ function preferPersisted(vars, source) {
154
+ const out = { ...vars };
155
+ for (const [key, value] of Object.entries(out)) {
156
+ if (value !== "")
157
+ continue;
158
+ const persisted = source[key];
159
+ if (persisted !== undefined && persisted !== "")
160
+ out[key] = persisted;
161
+ }
162
+ return out;
163
+ }
164
+ /**
165
+ * The credential id embedded in an AI Gateway token. The API mints them as
166
+ * `nt_live_<tokenIdShort>_<secret>`, and `tokenIdShort` is the public identifier the credentials
167
+ * list reports — so a persisted token names the credential that issued it. Returns `null` for
168
+ * anything not in that shape (a `.env.example` placeholder, a hand-typed value), which callers
169
+ * treat as unverifiable.
170
+ */
171
+ function gatewayTokenIdShort(apiToken) {
172
+ return /^nt_live_([^_]+)_.+$/.exec(apiToken)?.[1] ?? null;
173
+ }
174
+ /** Whether an issued credential can still be used: not revoked, not past its expiry. */
175
+ function isLiveCredential(meta, now) {
176
+ if (meta.revokedAt !== undefined)
177
+ return false;
178
+ if (meta.expiresAt === undefined)
179
+ return true;
180
+ const expiresAt = Date.parse(meta.expiresAt);
181
+ return Number.isNaN(expiresAt) || expiresAt > now;
182
+ }
183
+ /**
184
+ * The live credentials the persisted secrets name — at most one per half. A half that names
185
+ * nothing contributes nothing, which is what a placeholder, a credential revoked in the
186
+ * console, and one copied in from another branch all look like from here.
187
+ */
188
+ function namedCredentials(live, persisted) {
189
+ const usable = live.filter((meta) => isLiveCredential(meta, Date.now()));
190
+ const shortId = persisted.apiToken
191
+ ? gatewayTokenIdShort(persisted.apiToken)
192
+ : null;
193
+ return {
194
+ storage: persisted.accessKeyId
195
+ ? (usable.find((meta) => meta.tokenId === persisted.accessKeyId) ??
196
+ null)
197
+ : null,
198
+ gateway: shortId
199
+ ? (usable.find((meta) => meta.tokenIdShort === shortId) ?? null)
200
+ : null,
201
+ };
202
+ }
203
+ /**
204
+ * The credential the persisted secrets can be *reused* as, or `null`.
205
+ *
206
+ * Strict on purpose: every half the policy enables has to name a live credential, and when both
207
+ * features are enabled they must name the *same* one — they share a single credential, so
208
+ * halves that disagree came from two different calls and neither can be trusted.
209
+ */
210
+ function reusableCredential(named, enabled) {
211
+ if (enabled.storageEnabled && enabled.gatewayEnabled) {
212
+ return named.storage &&
213
+ named.gateway &&
214
+ named.storage.tokenId === named.gateway.tokenId
215
+ ? named.storage
216
+ : null;
217
+ }
218
+ if (enabled.storageEnabled)
219
+ return named.storage;
220
+ if (enabled.gatewayEnabled)
221
+ return named.gateway;
222
+ return null;
223
+ }
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);
@@ -1,15 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { resolveConfig } from "@neon/config";
4
- import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranchCreateError, loadConfigFromFile, PushConflictError, plan, } from "@neon/config-runtime";
2
+ import { dirname, join } from "node:path";
3
+ import { packagesToStage, resolveConfig } from "@neon/config";
4
+ import { apply, assertZipWithinLimits, createBranch as createBranchFromPolicy, describeNativeFinding, enforceLimits, findUndeclaredNativePackages, inspect, isPartialBranchCreateError, loadConfigFromFile, PushConflictError, plan, traceNativePackages, } from "@neon/config-runtime";
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";
@@ -27,11 +28,45 @@ import { autoPullEnvAfterPin } from "./env.js";
27
28
  * out of config-runtime's static module graph — and therefore out of the packaged
28
29
  * neonctl snapshot, which resolves esbuild dynamically at deploy time.
29
30
  */
30
- const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source, {
31
- ...(fn.externalPackages
32
- ? { externalPackages: fn.externalPackages }
33
- : {}),
34
- }));
31
+ const neonctlBundler = async (fn) => {
32
+ const externalPackages = fn.externalPackages ?? [];
33
+ const { files, metafile, warnings } = await bundleEntry(fn.source, {
34
+ externalPackages: externalPackages.map((pkg) => pkg.name),
35
+ });
36
+ for (const warning of warnings)
37
+ log.warning(warning);
38
+ // Advisory only — the evidence cannot prove the code path is reached, so a package with a
39
+ // working JavaScript fallback must not have its deploy blocked. See native-detect.
40
+ for (const finding of findUndeclaredNativePackages({
41
+ metafile,
42
+ declared: externalPackages.map((pkg) => pkg.name),
43
+ projectDir: dirname(fn.source),
44
+ })) {
45
+ log.warning(describeNativeFinding(fn.slug, finding));
46
+ }
47
+ const staged = packagesToStage(externalPackages);
48
+ // Nothing to stage is the pre-existing path: zip the esbuild output and nothing else,
49
+ // producing the archive it always did.
50
+ if (staged.length === 0)
51
+ return zipBundle(files);
52
+ // Staged packages are installed for the runtime target, traced, and merged in under their
53
+ // `node_modules/...` paths. The tree layout is load-bearing — a `.node` addon finds its
54
+ // sibling shared libraries relative to its own directory.
55
+ const traced = await traceNativePackages({
56
+ slug: fn.slug,
57
+ packages: staged,
58
+ projectDir: dirname(fn.source),
59
+ });
60
+ for (const warning of traced.warnings)
61
+ log.warning(warning);
62
+ const entries = { ...files, ...traced.entries };
63
+ // Re-checked against the final archive: the staged files were measured without the
64
+ // bundle, so the entry count and uncompressed total are only complete now.
65
+ enforceLimits(fn.slug, entries);
66
+ const zip = zipBundle(entries);
67
+ assertZipWithinLimits(fn.slug, zip, entries);
68
+ return zip;
69
+ };
35
70
  const INSPECT_FIELDS = ["project", "branch", "config"];
36
71
  /**
37
72
  * Shared `--env` flag for `config plan|apply` and `deploy`. Loads a `.env` into
@@ -120,7 +155,13 @@ const missingDependencies = (cwd) => {
120
155
  */
121
156
  const resolveServices = async (props) => {
122
157
  if (props.services !== undefined) {
123
- return parseServices(props.services);
158
+ return parseServices(props.services, {
159
+ allowed: CONFIG_INIT_SERVICES,
160
+ whyUnavailable: CONFIG_INIT_UNAVAILABLE,
161
+ flag: "--services",
162
+ noneMeans: CONFIG_INIT_NONE_MEANS,
163
+ onDeprecated: (used, canonical) => log.warning(deprecatedServiceMessage(used, canonical)),
164
+ });
124
165
  }
125
166
  if (props.pickServices) {
126
167
  return props.pickServices();
@@ -217,7 +258,7 @@ export const initCmd = async (props) => {
217
258
  log.info("%s are already installed.", REQUIRED_PACKAGES.join(" and "));
218
259
  }
219
260
  else {
220
- const pm = resolvePackageManager();
261
+ const pm = resolvePackageManager(cwd);
221
262
  const args = addDependenciesArgs(pm, missing);
222
263
  if (props.install === false) {
223
264
  log.info("Install the Neon config packages to use neon.ts: %s %s", pm, args.join(" "));
@@ -285,12 +326,14 @@ export const builder = (argv) => argv
285
326
  type: "boolean",
286
327
  default: true,
287
328
  },
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
- },
329
+ services: servicesOption({
330
+ key: "services",
331
+ allowed: CONFIG_INIT_SERVICES,
332
+ noneMeans: CONFIG_INIT_NONE_MEANS,
333
+ describe: "Services the scaffolded neon.ts declares",
334
+ also: "Omitted: pick interactively on a terminal, starter policy in " +
335
+ "CI or without a TTY.",
336
+ }),
294
337
  "from-branch": {
295
338
  describe: "Seed neon.ts from a branch's live Neon state instead of asking. Uses the " +
296
339
  "branch pinned in .neon, or --branch <name|id>, or the project's default " +
@@ -300,7 +343,12 @@ export const builder = (argv) => argv
300
343
  // `default: false` makes `conflicts` reject every `--services` run.
301
344
  conflicts: "services",
302
345
  },
303
- }), (args) => initCmd(args));
346
+ }), (args) => initCmd({
347
+ ...args,
348
+ // `args` is untyped here, and "flag omitted" has to stay distinct from
349
+ // "flag given" — it decides whether the picker runs at all.
350
+ services: servicesFlagValue(args.services),
351
+ }));
304
352
  export const handler = (args) => {
305
353
  return args;
306
354
  };