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/README.md +49 -3
- package/dist/_shared/env-core/env.js +558 -0
- package/dist/_shared/env-core/reuse-secrets.js +223 -0
- package/dist/analytics.js +82 -25
- package/dist/commands/config.js +66 -18
- package/dist/commands/dev.js +114 -20
- package/dist/commands/env.js +127 -6
- package/dist/commands/functions.js +16 -2
- package/dist/config_template.js +20 -42
- package/dist/dev/env.js +207 -4
- package/dist/dev/functions.js +5 -1
- package/dist/env_services.js +51 -0
- package/dist/neon_services.js +143 -0
- package/dist/parameters.gen.js +42 -42
- package/dist/utils/esbuild.js +51 -6
- package/dist/utils/package_manager.js +51 -4
- package/dist/utils/service_picker.js +6 -6
- package/package.json +5 -5
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 {
|
|
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
|
-
|
|
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
|
package/dist/dev/functions.js
CHANGED
|
@@ -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
|
-
? {
|
|
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;
|