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.
package/dist/dev/env.js CHANGED
@@ -1,6 +1,7 @@
1
- import { loadConfigFromFile } from "@neon/config";
1
+ import { createNeonApiFromOptions, loadConfigFromFile, } from "@neon/config";
2
2
  import { plan, pullConfig } from "@neon/config-runtime";
3
- import { fetchEnvReusingSecrets, } from "@neon/env/runtime";
3
+ import { NEON_ENV_VAR_KEYS } from "../_shared/env-core/env.js";
4
+ import { fetchEnvReusingSecrets, } from "../_shared/env-core/reuse-secrets.js";
4
5
  import { log } from "../log.js";
5
6
  import { getCliName } from "../utils/cli_name.js";
6
7
  /** The API-targeting options every runtime call forwards from the context. */
@@ -34,6 +35,18 @@ export class MissingBranchContextError extends Error {
34
35
  this.name = "MissingBranchContextError";
35
36
  }
36
37
  }
38
+ /**
39
+ * Thrown when an explicit `--service` selection names a service the branch does not have.
40
+ * Unlike the policy path — where the same situation is a {@link DevEnvMismatchError} pointing
41
+ * at `deploy` — the user named the service on the command line, so the fix is to provision it
42
+ * or drop it from the selection.
43
+ */
44
+ export class ServiceNotOnBranchError extends Error {
45
+ constructor() {
46
+ super(...arguments);
47
+ this.name = "ServiceNotOnBranchError";
48
+ }
49
+ }
37
50
  /**
38
51
  * Resolve the branch's Neon env vars (pooled / direct `DATABASE_URL`, plus Auth /
39
52
  * Data API when enabled) into a `{ KEY: value }` map. Shared by `neon dev` (which
@@ -41,6 +54,8 @@ export class MissingBranchContextError extends Error {
41
54
  *
42
55
  * Tiered:
43
56
  *
57
+ * 0. {@link DevEnvContext.services} is set -> that selection *is* the policy, and any
58
+ * `neon.ts` is ignored. See {@link resolveSelectedServices}.
44
59
  * 1. a `neon.ts` policy is found -> the policy is the source of truth. We first
45
60
  * check it against the branch's live state (`plan`); if it declares a resource
46
61
  * the branch is missing, we stop with a {@link DevEnvMismatchError} pointing at
@@ -49,12 +64,17 @@ export class MissingBranchContextError extends Error {
49
64
  * branch's live state (Auth / Data API enablement plus any object-storage
50
65
  * buckets) into a config, then `fetchEnv` resolves what is actually enabled —
51
66
  * so a branch with a bucket gets its `AWS_*` storage vars pulled with no policy.
67
+ * With {@link DevEnvContext.implyAiGateway}, the AI Gateway is added on top, since
68
+ * `pullConfig` cannot read it back.
52
69
  * 3. otherwise -> throw {@link MissingBranchContextError}.
53
70
  *
54
71
  * Unlike {@link resolveDevEnv}, this never swallows errors — callers decide how to
55
72
  * handle them.
56
73
  */
57
74
  export const resolveNeonEnvVars = async (ctx) => {
75
+ if (ctx.services) {
76
+ return await resolveSelectedServices(ctx, ctx.services);
77
+ }
58
78
  const config = await loadNeonConfig(ctx.cwd);
59
79
  if (config) {
60
80
  if (!ctx.projectId || !ctx.branchId) {
@@ -85,11 +105,193 @@ export const resolveNeonEnvVars = async (ctx) => {
85
105
  // straight into fetchEnv — no wrapping needed. pullConfig excludes functions and
86
106
  // the AI Gateway (neither can be faithfully read back), so fetchEnv never probes
87
107
  // the functions API here and only mints a storage credential when a bucket exists.
88
- return await fetchAndProject(pulled.config, ctx);
108
+ if (!ctx.implyAiGateway) {
109
+ return await fetchAndProject(pulled.config, ctx);
110
+ }
111
+ return await resolveWithImpliedGateway(pulled.config, ctx, {
112
+ projectId: ctx.projectId,
113
+ branchId: ctx.branchId,
114
+ });
89
115
  }
90
116
  throw new MissingBranchContextError(`No project/branch context found. Link a branch (\`${getCliName()} link\` / ` +
91
117
  `\`${getCliName()} checkout\`) or pass --project-id and --branch.`);
92
118
  };
119
+ /** The same config with the AI Gateway enabled, leaving any other `preview` entries intact. */
120
+ const withAiGateway = (config) => ({
121
+ ...config,
122
+ preview: { ...config.preview, aiGateway: true },
123
+ });
124
+ /**
125
+ * Tier-2 resolution with the AI Gateway added on top of the branch's read-back state.
126
+ *
127
+ * The gateway is not detectable — `pullConfig` reports no enabled flag for it — so it is
128
+ * implied rather than observed. Nobody named it, so it must never be the reason the whole
129
+ * resolve fails: a project outside the regions where branch credentials exist would otherwise
130
+ * lose its `DATABASE_URL` too, and `neon dev` would start with no env at all.
131
+ *
132
+ * So the gateway is only added once its credential endpoint has been shown to answer, by
133
+ * reading the branch's credentials first. A project that does not have them says so on a
134
+ * read, before anything is minted — which is the whole question, since the gateway's env is a
135
+ * credential and nothing else.
136
+ *
137
+ * Deciding this **before** resolving, rather than by catching and retrying, is what keeps it
138
+ * honest. A retry re-runs every call the first attempt made, so it would blame the gateway for
139
+ * a one-off failure in shared work, and — worse — a first attempt that minted a credential and
140
+ * then failed would be papered over by a second that succeeds without one, swallowing the
141
+ * error and stranding a secret nobody holds. Once the read succeeds, a later failure is a real
142
+ * failure and propagates: the same thing already happens on a branch with object storage,
143
+ * whose credential is minted whether or not the gateway is involved.
144
+ */
145
+ const resolveWithImpliedGateway = async (config, ctx,
146
+ /** Resolved by the caller, which is the branch this env belongs to. */
147
+ branch) => {
148
+ const unreachable = await credentialsUnreachable(ctx, branch);
149
+ if (unreachable === null) {
150
+ return await fetchAndProject(withAiGateway(config), ctx);
151
+ }
152
+ // Deliberately does not assert that the project lacks the gateway: a read can also fail
153
+ // for a reason that has nothing to do with the feature, and this is not the place to
154
+ // guess which. Name both, and the command that answers it.
155
+ log.warning("Could not reach the AI Gateway's credentials, so %s were not resolved. Everything " +
156
+ "else was. Either this project does not have the AI Gateway, or the call failed — " +
157
+ `\`${getCliName()} env pull -s ai-gateway\` will say which.\nDetails: %s`, [
158
+ NEON_ENV_VAR_KEYS.aiGateway.apiKey,
159
+ NEON_ENV_VAR_KEYS.aiGateway.baseUrl,
160
+ ].join(" and "), unreachable);
161
+ return {
162
+ ...(await fetchAndProject(config, ctx)),
163
+ skipped: ["ai-gateway"],
164
+ };
165
+ };
166
+ /**
167
+ * Why the branch's credentials could not be read, or `null` when they could. A plain read: it
168
+ * mints nothing, revokes nothing, and changes nothing, so asking is free of the side effects
169
+ * that make a failed resolve ambiguous.
170
+ */
171
+ const credentialsUnreachable = async (ctx, branch) => {
172
+ try {
173
+ await apiFor(ctx).listCredentials(branch.projectId, branch.branchId);
174
+ return null;
175
+ }
176
+ catch (err) {
177
+ return err instanceof Error ? err.message : String(err);
178
+ }
179
+ };
180
+ /** The adapter for direct branch reads: the injected one in tests, else built from options. */
181
+ const apiFor = (ctx) => ctx.api ??
182
+ createNeonApiFromOptions("neon env", {
183
+ ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
184
+ ...(ctx.apiHost ? { apiHost: ctx.apiHost } : {}),
185
+ });
186
+ /**
187
+ * Tier-0: resolve exactly the services `--service` named, with `neon.ts` out of the picture.
188
+ *
189
+ * The selection is checked against the branch's live state so a service that is named but not
190
+ * provisioned fails by name, instead of quietly contributing no vars. `postgres` and the AI
191
+ * Gateway are not checked: every branch has Postgres, and the gateway has no branch-level
192
+ * state to check (an unavailable one surfaces when its credential is minted).
193
+ */
194
+ const resolveSelectedServices = async (ctx, services) => {
195
+ const { projectId, branchId } = ctx;
196
+ if (!projectId || !branchId) {
197
+ throw new MissingBranchContextError("--service needs a project and branch to read from. " +
198
+ `Run \`${getCliName()} link\` and \`${getCliName()} checkout <branch>\`, or pass ` +
199
+ "--project-id / --branch.");
200
+ }
201
+ // Read only the services that were named, rather than going through `pullConfig`. That
202
+ // keeps a selection independent of everything else on the branch — `pullConfig` also
203
+ // enumerates functions and credentials, so a failure there would abort `-s auth` — and it
204
+ // keeps an "object storage isn't available for this project" error intact, which
205
+ // `pullConfig` degrades to an empty bucket list and would report as "no buckets".
206
+ const api = apiFor(ctx);
207
+ const has = (service) => services.includes(service);
208
+ const [auth, dataApiEnabled, buckets] = await Promise.all([
209
+ has("auth") ? api.getNeonAuth(projectId, branchId) : null,
210
+ has("data-api") ? readDataApiEnabled(api, projectId, branchId) : null,
211
+ has("object-storage")
212
+ ? api.listBranchBuckets(projectId, branchId)
213
+ : null,
214
+ ]);
215
+ const config = configForServices(services, branchId, {
216
+ authEnabled: auth !== null,
217
+ dataApiEnabled,
218
+ buckets: buckets ?? [],
219
+ });
220
+ // A selection resolves part of the branch, so it must not revoke: the credential its
221
+ // persisted secrets name may also back a service it is not resolving. See
222
+ // `fetchEnvReusingSecrets`'s `revokeSuperseded`.
223
+ return await fetchAndProject(config, ctx, { revokeSuperseded: false });
224
+ };
225
+ /**
226
+ * Whether the branch has a Data API integration — or `null` when that cannot be determined.
227
+ *
228
+ * It is enabled per branch *and database*, so this has to probe the database `fetchEnv` will
229
+ * resolve the URL from, or the two would disagree. That is Neon's default `neondb`, else the
230
+ * only database; several databases with no `neondb` is a case `fetchEnv` refuses to auto-pick
231
+ * at all. Reporting "no Data API integration" there would be a claim this read cannot support,
232
+ * so it answers `null` and lets `fetchEnv` raise its own ambiguity error, which names the
233
+ * databases and the fix.
234
+ */
235
+ const readDataApiEnabled = async (api, projectId, branchId) => {
236
+ const databases = await api.listBranchDatabases(projectId, branchId);
237
+ const database = databases.find((db) => db.name === NEON_DEFAULT_DATABASE) ??
238
+ (databases.length === 1 ? databases[0] : undefined);
239
+ if (!database)
240
+ return databases.length === 0 ? false : null;
241
+ const dataApi = await api.getNeonDataApi(projectId, branchId, database.name);
242
+ return dataApi !== null;
243
+ };
244
+ /** Neon's default database, and the one `fetchEnv` prefers when a branch has several. */
245
+ const NEON_DEFAULT_DATABASE = "neondb";
246
+ /**
247
+ * Build the `Config` an explicit `--service` selection stands for, raising
248
+ * {@link ServiceNotOnBranchError} for anything the branch does not have. Naming a service
249
+ * that isn't there has to fail rather than contribute no vars, or a scoped pull would report
250
+ * "no Neon env variables to pull" — which reads as a statement about the branch rather than
251
+ * about the selection.
252
+ */
253
+ const configForServices = (services, branchId, branch) => {
254
+ // The command that provisions each one, for a user who may well have no `neon.ts` — in
255
+ // which case `deploy` / `config apply` would be no help at all.
256
+ const provisionWith = {
257
+ auth: `${getCliName()} neon-auth enable`,
258
+ "data-api": `${getCliName()} data-api create`,
259
+ "object-storage": `${getCliName()} buckets create <name>`,
260
+ };
261
+ const notOnBranch = (service, what) => {
262
+ throw new ServiceNotOnBranchError(`--service ${service}: branch ${branchId} has no ${what}, so there are no ` +
263
+ `${service} env vars to pull. Provision it first (\`${provisionWith[service]}\`, ` +
264
+ `or in the Neon Console), or drop ${service} from --service.`);
265
+ };
266
+ const config = {};
267
+ if (services.includes("auth")) {
268
+ if (!branch.authEnabled)
269
+ notOnBranch("auth", "Neon Auth integration");
270
+ config.auth = true;
271
+ }
272
+ if (services.includes("data-api")) {
273
+ // Only a positive "not there" is an error; an undecidable read defers to `fetchEnv`.
274
+ if (branch.dataApiEnabled === false) {
275
+ notOnBranch("data-api", "Data API integration");
276
+ }
277
+ config.dataApi = true;
278
+ }
279
+ const preview = {};
280
+ if (services.includes("object-storage")) {
281
+ if (branch.buckets.length === 0) {
282
+ notOnBranch("object-storage", "object-storage buckets");
283
+ }
284
+ preview.buckets = Object.fromEntries(branch.buckets.map((bucket) => [
285
+ bucket.name,
286
+ { access: bucket.accessLevel },
287
+ ]));
288
+ }
289
+ if (services.includes("ai-gateway"))
290
+ preview.aiGateway = true;
291
+ if (Object.keys(preview).length > 0)
292
+ config.preview = preview;
293
+ return config;
294
+ };
93
295
  /**
94
296
  * `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
95
297
  *
@@ -180,11 +382,12 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
180
382
  const isMissingResource = (change) => change.kind === "service" &&
181
383
  change.action === "create" &&
182
384
  !change.identifier.startsWith("function:");
183
- const fetchAndProject = async (config, ctx) => fetchEnvReusingSecrets(config, {
385
+ const fetchAndProject = async (config, ctx, opts = {}) => fetchEnvReusingSecrets(config, {
184
386
  projectId: ctx.projectId,
185
387
  branch: ctx.branchId,
186
388
  ...apiOptions(ctx),
187
389
  ...(ctx.env ? { env: ctx.env } : {}),
390
+ ...(opts.revokeSuperseded === false ? { revokeSuperseded: false } : {}),
188
391
  });
189
392
  /**
190
393
  * Load a `neon.ts` policy if one exists on the path from `cwd` up to the repo
@@ -36,8 +36,12 @@ export const resolveFunctionsFromConfig = async (cwd, branchName) => {
36
36
  ? { port: devPort(fn.dev) }
37
37
  : {}),
38
38
  env: { ...fn.env },
39
+ // Names only: locally every entry is simply left unbundled, and `includeFiles`
40
+ // governs the deployed archive, which `neon dev` does not build.
39
41
  ...(fn.externalPackages
40
- ? { externalPackages: [...fn.externalPackages] }
42
+ ? {
43
+ externalPackages: fn.externalPackages.map((pkg) => pkg.name),
44
+ }
41
45
  : {}),
42
46
  };
43
47
  });
@@ -0,0 +1,51 @@
1
+ import { NEON_ENV_VAR_KEYS } from "./_shared/env-core/env.js";
2
+ import { NEON_SERVICES } from "./neon_services.js";
3
+ /**
4
+ * The services `env pull --service` can select: every Neon service that produces branch env
5
+ * vars. `functions` is the one left out — a function's env comes from the local `neon.ts`,
6
+ * never from the branch, so there is nothing to pull.
7
+ */
8
+ export const ENV_PULL_SERVICES = NEON_SERVICES.filter((service) => service !== "functions");
9
+ /** Why the services `env pull` leaves out are not selectable, for the refusal message. */
10
+ export const ENV_PULL_UNAVAILABLE = {
11
+ functions: "a function's env comes from your neon.ts, not from the branch, so there is nothing to pull",
12
+ };
13
+ /** The OS-level env vars each service contributes to a pulled `.env`. */
14
+ const SERVICE_ENV_KEYS = {
15
+ postgres: Object.values(NEON_ENV_VAR_KEYS.postgres),
16
+ auth: Object.values(NEON_ENV_VAR_KEYS.auth),
17
+ "data-api": Object.values(NEON_ENV_VAR_KEYS.dataApi),
18
+ "object-storage": Object.values(NEON_ENV_VAR_KEYS.storage),
19
+ "ai-gateway": Object.values(NEON_ENV_VAR_KEYS.aiGateway),
20
+ functions: [],
21
+ };
22
+ /**
23
+ * The subset of {@link SERVICE_ENV_KEYS} a pull *owns*, and so may prune from the target file
24
+ * when the branch no longer has it. Object storage is deliberately absent: it is emitted under
25
+ * the third-party `AWS_*` names, which collide with credentials a user may set by hand, so
26
+ * `env pull` only ever writes them.
27
+ */
28
+ const SERVICE_OWNED_ENV_KEYS = {
29
+ ...SERVICE_ENV_KEYS,
30
+ "object-storage": [],
31
+ };
32
+ /**
33
+ * Branch identity. Not a service — every branch has a name — so a scoped pull refreshes it
34
+ * alongside whatever services were selected.
35
+ */
36
+ export const BRANCH_ENV_KEY = NEON_ENV_VAR_KEYS.branch.name;
37
+ /** Every env var the selected services contribute, plus branch identity. */
38
+ export const envServiceKeys = (services) => {
39
+ const keys = new Set([BRANCH_ENV_KEY]);
40
+ for (const service of services) {
41
+ for (const key of SERVICE_ENV_KEYS[service])
42
+ keys.add(key);
43
+ }
44
+ return keys;
45
+ };
46
+ /**
47
+ * The env vars a pull scoped to `services` may prune. Narrower than the unscoped set on
48
+ * purpose: `env pull -s ai-gateway` says nothing about `DATABASE_URL`, so it must leave it
49
+ * alone rather than treat its absence from this pull as "the branch no longer has it".
50
+ */
51
+ export const ownedEnvServiceKeys = (services) => services.flatMap((service) => SERVICE_OWNED_ENV_KEYS[service]);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Every Neon service a `--service` flag can name, spelled the way a user types it — one
3
+ * vocabulary for the whole CLI.
4
+ *
5
+ * Kebab-case rather than the `neon.ts` field names (`aiGateway`, `buckets`) so a flag reads
6
+ * like a flag, and the full product name rather than a shortening (`object-storage`, not
7
+ * `storage`) so nothing is ambiguous when read on its own.
8
+ *
9
+ * Commands take a **subset** of this via {@link ParseServicesOptions.allowed} — `config init`
10
+ * can only declare what a `neon.ts` has a field for, `env pull` can only pull what produces
11
+ * env vars — but the spelling of a service never varies between them. The order here is the
12
+ * canonical one: parsing sorts into it, so a command's output never depends on the order the
13
+ * flags were typed in.
14
+ *
15
+ * Not to be confused with `NeonFeature` in `init/bootstrap.ts`, which is what a *template*
16
+ * requires. That list comes from remote manifests (`neondatabase/examples/bootstrap.yaml`),
17
+ * spells Postgres `database`, and is not ours to rename.
18
+ */
19
+ export const NEON_SERVICES = [
20
+ "postgres",
21
+ "auth",
22
+ "data-api",
23
+ "functions",
24
+ "object-storage",
25
+ "ai-gateway",
26
+ ];
27
+ /**
28
+ * Spellings that used to be canonical, and the service they now mean. Accepted so a scripted
29
+ * `--services storage` keeps working, warned about so it does not quietly become a second
30
+ * vocabulary, and absent from help text, errors, and docs so nobody learns it fresh.
31
+ */
32
+ const DEPRECATED_SERVICE_ALIASES = {
33
+ // `config init --services storage` shipped before the vocabulary was unified.
34
+ storage: "object-storage",
35
+ };
36
+ /** An explicit empty selection, for commands where "declare nothing" is a real answer. */
37
+ export const NO_SERVICES = "none";
38
+ /**
39
+ * What to tell someone still using a retired spelling. A message rather than a log call, so
40
+ * the parser stays free of the CLI's writer and each command can surface it in its own voice.
41
+ */
42
+ export const deprecatedServiceMessage = (used, canonical) => `"${used}" is the old name for "${canonical}" and still works, but it will be removed. ` +
43
+ `Use "${canonical}".`;
44
+ /**
45
+ * Parse the raw values of a services flag into a canonical selection.
46
+ *
47
+ * Accepts the flag repeated (`-s auth -s postgres`) and comma-separated
48
+ * (`-s auth,postgres`), since both read naturally and users will try either. The result is
49
+ * deduplicated and sorted into {@link NEON_SERVICES} order, so what a command does never
50
+ * depends on typing order.
51
+ *
52
+ * An unrecognized name is rejected rather than dropped: a typo would otherwise act on
53
+ * everything *except* the service that was asked for, and report success. A name that is a
54
+ * real service but not one this command supports says so specifically — "functions has no env
55
+ * variables" is a different problem from a typo, and has a different fix.
56
+ */
57
+ export const parseServices = (raw, options) => {
58
+ const { allowed, flag, noneMeans, whyUnavailable = {}, onDeprecated, } = options;
59
+ const supported = `Supported values: ${allowed.join(", ")}${noneMeans !== undefined ? `, ${NO_SERVICES}` : ""}.`;
60
+ const names = raw
61
+ .flatMap((value) => value.split(","))
62
+ .map((name) => name.trim())
63
+ .filter((name) => name !== "");
64
+ if (names.length === 0) {
65
+ throw new Error(`${flag} needs at least one service. ${supported}`);
66
+ }
67
+ if (noneMeans !== undefined && names.includes(NO_SERVICES)) {
68
+ // Deduplicate before deciding it was combined with something: a repeated value is
69
+ // a no-op everywhere else in this parser, so `-s none -s none` must be too.
70
+ if (new Set(names).size > 1) {
71
+ throw new Error(`${flag} ${NO_SERVICES} cannot be combined with other services.`);
72
+ }
73
+ return [];
74
+ }
75
+ // Canonicalize first and unconditionally, so a retired spelling is reported against the
76
+ // service it means rather than as a word nobody recognizes.
77
+ const deprecated = new Map();
78
+ const resolved = names.map((name) => {
79
+ const canonical = DEPRECATED_SERVICE_ALIASES[name];
80
+ if (canonical === undefined)
81
+ return name;
82
+ deprecated.set(name, canonical);
83
+ return canonical;
84
+ });
85
+ const unsupported = resolved.filter((name) => !allowed.some((service) => service === name));
86
+ if (unsupported.length > 0) {
87
+ throw new Error(`${unsupportedMessage(unsupported, flag, whyUnavailable)} ${supported}`);
88
+ }
89
+ // Warned only once the selection is valid: a run that fails validation should not also
90
+ // carry a "still works" claim about a value that never took effect.
91
+ for (const [used, canonical] of deprecated)
92
+ onDeprecated?.(used, canonical);
93
+ return NEON_SERVICES.filter((service) => allowed.includes(service) && resolved.includes(service));
94
+ };
95
+ /**
96
+ * The sentences explaining why a selection was refused. A real Neon service this command
97
+ * cannot act on is a different mistake from a typo — different cause, different fix — so the
98
+ * two are never answered with the same word, and each service carries its reason where the
99
+ * command supplied one.
100
+ */
101
+ const unsupportedMessage = (unsupported, flag, whyUnavailable) => {
102
+ const known = unsupported.filter((name) => NEON_SERVICES.some((service) => service === name));
103
+ const unknown = unsupported.filter((name) => !known.some((service) => service === name));
104
+ return [
105
+ unknown.length > 0
106
+ ? `Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}.`
107
+ : undefined,
108
+ ...known.map((service) => {
109
+ const why = whyUnavailable[service];
110
+ return `${service} is not something ${flag} can select${why ? `: ${why}` : ""}.`;
111
+ }),
112
+ ]
113
+ .filter((part) => part !== undefined)
114
+ .join(" ");
115
+ };
116
+ /** Every spelling of the services flag, so a habit picked up on one command works on another. */
117
+ const SERVICE_FLAG_NAMES = ["s", "service", "services"];
118
+ /**
119
+ * The yargs option for a services flag, so every command that has one accepts the same
120
+ * spellings (`-s`, `--service`, `--services`) and the same value syntax. `key` is the name the
121
+ * command reads off `argv`; the rest become aliases.
122
+ */
123
+ export const servicesOption = (params) => ({
124
+ alias: SERVICE_FLAG_NAMES.filter((name) => name !== params.key),
125
+ describe: [
126
+ `${params.describe}: ${params.allowed.join(", ")}.`,
127
+ params.noneMeans !== undefined
128
+ ? `Pass "${NO_SERVICES}" for ${params.noneMeans}.`
129
+ : undefined,
130
+ "Repeat the flag or comma-separate.",
131
+ params.also,
132
+ ]
133
+ .filter((part) => part !== undefined)
134
+ .join(" "),
135
+ type: "array",
136
+ string: true,
137
+ });
138
+ /**
139
+ * Narrow a yargs value for a services flag to the raw strings, or `undefined` when the flag
140
+ * was not given. `argv` is untyped at the handler, and `string: true` only guarantees the
141
+ * element type when the flag was actually parsed as an array.
142
+ */
143
+ export const servicesFlagValue = (value) => Array.isArray(value) ? value.map(String) : undefined;