neon 2.46.0 → 2.47.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/analytics.js +82 -25
- package/dist/commands/config.js +24 -10
- package/dist/commands/dev.js +67 -14
- package/dist/commands/env.js +126 -5
- package/dist/config_template.js +20 -42
- package/dist/dev/env.js +206 -3
- package/dist/env_services.js +51 -0
- package/dist/neon_services.js +143 -0
- package/dist/utils/package_manager.js +51 -4
- package/dist/utils/service_picker.js +6 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -417,9 +417,15 @@ The human-readable summary line goes to stderr and the diff body to stdout, so `
|
|
|
417
417
|
|
|
418
418
|
### env pull
|
|
419
419
|
|
|
420
|
-
`env pull` writes the linked branch's Neon environment variables into a local dotenv file: an existing `.env` if you have one, otherwise `.env.local` (override with `--file <path>`). Only Neon-managed keys
|
|
420
|
+
`env pull` writes the linked branch's Neon environment variables into a local dotenv file: an existing `.env` if you have one, otherwise `.env.local` (override with `--file <path>`). Only Neon-managed keys are written (see the table below); any other lines in the file are preserved. The branch comes from the closest `.neon` file, so no `--branch` is needed (pass `--branch <id|name>` to target another branch).
|
|
421
421
|
|
|
422
|
-
|
|
422
|
+
**What gets pulled**, in precedence order:
|
|
423
|
+
|
|
424
|
+
1. **`--service`**, when you pass it — exactly those services, whatever else is on the branch and whatever a `neon.ts` says.
|
|
425
|
+
2. **`neon.ts`**, when the working directory has one — the policy is the source of truth, same as `neon dev` and `neon deploy`.
|
|
426
|
+
3. **Everything the branch has** otherwise — Postgres, Neon Auth, the Data API, and object storage read back from the branch, plus the AI Gateway. The gateway has no branch-level state to read back (it is credential-gated, not provisioned), so a bare `env pull` asks for it rather than detecting it, which mints a branch credential. To leave it out, name the services you do want with `--service`.
|
|
427
|
+
|
|
428
|
+
If the gateway can't be resolved, it is dropped with a warning and the rest of the pull still lands. Gateway variables already in your file for *this* branch are left alone — a pull that couldn't reach the gateway is no evidence the branch has stopped having one — while ones left over from a different branch are pruned like any other stale value.
|
|
423
429
|
|
|
424
430
|
```bash
|
|
425
431
|
# Refresh the linked branch's vars in place
|
|
@@ -427,10 +433,45 @@ neon env pull
|
|
|
427
433
|
|
|
428
434
|
# Pull a specific branch into a specific file
|
|
429
435
|
neon env pull --branch preview --file .env.preview
|
|
436
|
+
|
|
437
|
+
# Only the AI Gateway
|
|
438
|
+
neon env pull --service ai-gateway
|
|
439
|
+
|
|
440
|
+
# Repeat the flag or comma-separate; -s, --service and --services are all accepted
|
|
441
|
+
neon env pull -s postgres -s data-api
|
|
442
|
+
neon env pull -s postgres,auth
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Every services flag in the CLI takes those three spellings, the same value syntax, and the same service names — see [`config init --services`](#getting-a-neonts-config-init).
|
|
446
|
+
|
|
447
|
+
| `--service` | Variables |
|
|
448
|
+
| --- | --- |
|
|
449
|
+
| `postgres` | `DATABASE_URL`, `DATABASE_URL_UNPOOLED` |
|
|
450
|
+
| `auth` | `NEON_AUTH_BASE_URL`, `NEON_AUTH_JWKS_URL` |
|
|
451
|
+
| `data-api` | `NEON_DATA_API_URL` |
|
|
452
|
+
| `object-storage` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `AWS_REGION` |
|
|
453
|
+
| `ai-gateway` | `NEON_AI_GATEWAY_TOKEN`, `NEON_AI_GATEWAY_BASE_URL` |
|
|
454
|
+
|
|
455
|
+
`NEON_BRANCH` is written by every pull — it is branch identity, not a service.
|
|
456
|
+
|
|
457
|
+
**A scoped pull is scoped in both directions.** An unscoped `env pull` owns the Neon-named variables: pointing a directory at a branch without Neon Auth prunes the stale `NEON_AUTH_*` lines. `--service` narrows that to the services you named, so `env pull -s ai-gateway` never touches your `DATABASE_URL`. (`AWS_*` is never pruned by any pull: those names collide with credentials you may set yourself, so `env pull` only ever writes them.)
|
|
458
|
+
|
|
459
|
+
**A scoped pull also never revokes a credential.** Where an unscoped pull revokes the credential it replaces, a scoped one leaves the old one live — it can't tell which other services still use it. It says so when it happens; revoke it in the Neon Console if nothing does.
|
|
460
|
+
|
|
461
|
+
Naming a service the branch does not have is an error, not an empty pull:
|
|
462
|
+
|
|
463
|
+
```
|
|
464
|
+
--service auth: branch br-snowy-frost-12345 has no Neon Auth integration, so there are no
|
|
465
|
+
auth env vars to pull. Provision it first (`neon deploy`, `neon config apply`, or the Neon
|
|
466
|
+
Console), or drop auth from --service.
|
|
430
467
|
```
|
|
431
468
|
|
|
469
|
+
`link`, `checkout`, and `config apply` invoke `env pull` automatically (see above). Those bundled pulls follow rules 2 and 3 above **without** the implied AI Gateway: minting a credential for a service you never named isn't something a side effect of another command should do. Run `neon env pull` to get it.
|
|
470
|
+
|
|
432
471
|
If you'd rather not keep env vars on disk, inject them at runtime instead with `neon-env run -- <your dev command>` (from `@neon/env`) or `neon dev`, and pass `--no-env-pull` to `link` / `checkout`.
|
|
433
472
|
|
|
473
|
+
**`neon dev` resolves the same set, by the same rules** — including the AI Gateway on a branch with no `neon.ts`. A function running locally gets what the deployed runtime would inject into it, which is the whole point of `dev`; a handler that reads `NEON_AI_GATEWAY_BASE_URL` should not work in production and fail on your machine. `dev` writes nothing, but it does *read* your `.env` / `.env.local` to reuse the branch credential behind the AI Gateway and object storage. Without a file to read from it issues one on every start and leaves the last one live — it has nowhere to keep it, and so cannot name it to revoke it. It says so when it happens; run `env pull` (or just `link` / `checkout`) once and restarts reuse the credential instead.
|
|
474
|
+
|
|
434
475
|
**Where `.neon` lives**: `link` writes `.neon` into the **current working directory** by default. If an existing `.neon` is found in any parent directory, that file is reused — so commands run from a sub-directory of a linked project still pick up the project's context. To pin the location explicitly, pass `--context-file <path>`.
|
|
435
476
|
|
|
436
477
|
**`.gitignore` scaffolding**: when `.neon` is **created** for the first time, the CLI also makes sure a `.gitignore` sits alongside it listing `.neon`. If `.gitignore` doesn't exist it's created with a single `.neon` line; if it does exist, `.neon` is appended only when missing (no duplicates, your other entries are left alone). On subsequent updates to an existing `.neon`, `.gitignore` is left untouched — so if you deliberately un-ignore `.neon` (e.g. to commit shared context), the entry is not re-added on every command.
|
|
@@ -479,7 +520,10 @@ Selecting nothing is a valid answer: you get the starter policy, which is also w
|
|
|
479
520
|
neon config init
|
|
480
521
|
|
|
481
522
|
# Declare services with no prompt
|
|
482
|
-
neon config init --services auth,functions,storage,ai-gateway
|
|
523
|
+
neon config init --services auth,functions,object-storage,ai-gateway
|
|
524
|
+
|
|
525
|
+
# Repeat the flag instead, and shorten it — every services flag takes all three spellings
|
|
526
|
+
neon config init -s auth -s functions
|
|
483
527
|
|
|
484
528
|
# Explicitly ask for the bare starter policy
|
|
485
529
|
neon config init --services none
|
|
@@ -488,6 +532,8 @@ neon config init --services none
|
|
|
488
532
|
neon config init --no-install
|
|
489
533
|
```
|
|
490
534
|
|
|
535
|
+
Object storage is spelled `object-storage` here, matching [`env pull --service`](#env-pull) and the rest of the CLI. The old `storage` still works and warns; it will be removed.
|
|
536
|
+
|
|
491
537
|
Choosing **Functions** also writes the handler the policy points at, since `source` is only resolved when `apply` bundles it — a declared function with no file on disk fails at deploy:
|
|
492
538
|
|
|
493
539
|
```ts
|
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:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
log.debug("
|
|
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 (
|
|
134
|
+
if (keyToQuery) {
|
|
79
135
|
const apiClient = getApiClient({
|
|
80
|
-
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
|
-
|
|
97
|
-
args.
|
|
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
|
|
161
|
+
userId: analyticsUserId(userId),
|
|
105
162
|
});
|
|
106
163
|
client.track({
|
|
107
|
-
userId: userId
|
|
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
|
|
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
|
|
216
|
+
userId: analyticsUserId(userId),
|
|
160
217
|
properties,
|
|
161
218
|
});
|
|
162
219
|
log.debug("Sent CLI event: %s", event);
|
package/dist/commands/config.js
CHANGED
|
@@ -5,11 +5,12 @@ import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranch
|
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
import { getApiClient } from "../api.js";
|
|
7
7
|
import { toNeonConfigView } from "../config_format.js";
|
|
8
|
-
import {
|
|
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";
|
|
@@ -120,7 +121,13 @@ const missingDependencies = (cwd) => {
|
|
|
120
121
|
*/
|
|
121
122
|
const resolveServices = async (props) => {
|
|
122
123
|
if (props.services !== undefined) {
|
|
123
|
-
return parseServices(props.services
|
|
124
|
+
return parseServices(props.services, {
|
|
125
|
+
allowed: CONFIG_INIT_SERVICES,
|
|
126
|
+
whyUnavailable: CONFIG_INIT_UNAVAILABLE,
|
|
127
|
+
flag: "--services",
|
|
128
|
+
noneMeans: CONFIG_INIT_NONE_MEANS,
|
|
129
|
+
onDeprecated: (used, canonical) => log.warning(deprecatedServiceMessage(used, canonical)),
|
|
130
|
+
});
|
|
124
131
|
}
|
|
125
132
|
if (props.pickServices) {
|
|
126
133
|
return props.pickServices();
|
|
@@ -217,7 +224,7 @@ export const initCmd = async (props) => {
|
|
|
217
224
|
log.info("%s are already installed.", REQUIRED_PACKAGES.join(" and "));
|
|
218
225
|
}
|
|
219
226
|
else {
|
|
220
|
-
const pm = resolvePackageManager();
|
|
227
|
+
const pm = resolvePackageManager(cwd);
|
|
221
228
|
const args = addDependenciesArgs(pm, missing);
|
|
222
229
|
if (props.install === false) {
|
|
223
230
|
log.info("Install the Neon config packages to use neon.ts: %s %s", pm, args.join(" "));
|
|
@@ -285,12 +292,14 @@ export const builder = (argv) => argv
|
|
|
285
292
|
type: "boolean",
|
|
286
293
|
default: true,
|
|
287
294
|
},
|
|
288
|
-
services: {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
295
|
+
services: servicesOption({
|
|
296
|
+
key: "services",
|
|
297
|
+
allowed: CONFIG_INIT_SERVICES,
|
|
298
|
+
noneMeans: CONFIG_INIT_NONE_MEANS,
|
|
299
|
+
describe: "Services the scaffolded neon.ts declares",
|
|
300
|
+
also: "Omitted: pick interactively on a terminal, starter policy in " +
|
|
301
|
+
"CI or without a TTY.",
|
|
302
|
+
}),
|
|
294
303
|
"from-branch": {
|
|
295
304
|
describe: "Seed neon.ts from a branch's live Neon state instead of asking. Uses the " +
|
|
296
305
|
"branch pinned in .neon, or --branch <name|id>, or the project's default " +
|
|
@@ -300,7 +309,12 @@ export const builder = (argv) => argv
|
|
|
300
309
|
// `default: false` makes `conflicts` reject every `--services` run.
|
|
301
310
|
conflicts: "services",
|
|
302
311
|
},
|
|
303
|
-
}), (args) => initCmd(
|
|
312
|
+
}), (args) => initCmd({
|
|
313
|
+
...args,
|
|
314
|
+
// `args` is untyped here, and "flag omitted" has to stay distinct from
|
|
315
|
+
// "flag given" — it decides whether the picker runs at all.
|
|
316
|
+
services: servicesFlagValue(args.services),
|
|
317
|
+
}));
|
|
304
318
|
export const handler = (args) => {
|
|
305
319
|
return args;
|
|
306
320
|
};
|
package/dist/commands/dev.js
CHANGED
|
@@ -7,7 +7,9 @@ import chalk from "chalk";
|
|
|
7
7
|
import { resolveDevEnv } from "../dev/env.js";
|
|
8
8
|
import { resolveFunctionsFromConfig, } from "../dev/functions.js";
|
|
9
9
|
import { resolveWatchInputs } from "../dev/inputs.js";
|
|
10
|
+
import { readEnvFile, resolveEnvFilePath } from "../env_file.js";
|
|
10
11
|
import { log } from "../log.js";
|
|
12
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
11
13
|
import { branchIdResolve } from "../utils/enrichers.js";
|
|
12
14
|
import { bundleEntry } from "../utils/esbuild.js";
|
|
13
15
|
export const command = "dev";
|
|
@@ -30,7 +32,68 @@ export const builder = (argv) => argv
|
|
|
30
32
|
type: "number",
|
|
31
33
|
},
|
|
32
34
|
})
|
|
35
|
+
.epilogue([
|
|
36
|
+
"",
|
|
37
|
+
"Functions run with the linked branch's Neon env injected, the same set the",
|
|
38
|
+
"deployed runtime gives them: DATABASE_URL, plus Neon Auth, the Data API,",
|
|
39
|
+
"object storage and the AI Gateway where the branch has them. A neon.ts in",
|
|
40
|
+
"this directory decides instead, exactly as it does for `env pull`.",
|
|
41
|
+
"",
|
|
42
|
+
"`dev` reads your .env / .env.local to reuse the branch credential behind the",
|
|
43
|
+
"AI Gateway and object storage, and never writes to them. With no such file it",
|
|
44
|
+
"issues a credential on every start, so run `env pull` once if you restart often.",
|
|
45
|
+
].join("\n"))
|
|
33
46
|
.strict();
|
|
47
|
+
/**
|
|
48
|
+
* The resolver context for a `neon dev` run.
|
|
49
|
+
*
|
|
50
|
+
* Two things here are what make local dev match the deployed runtime, which injects a
|
|
51
|
+
* branch's whole env into a function:
|
|
52
|
+
*
|
|
53
|
+
* - **The AI Gateway is asked for**, like `env pull` does, because nothing can detect it.
|
|
54
|
+
* Without this, a function that works deployed fails locally with no `NEON_AI_GATEWAY_*`,
|
|
55
|
+
* which is exactly the difference `dev` exists to eliminate.
|
|
56
|
+
* - **The local dotenv file is layered in**, so the branch credential behind the gateway and
|
|
57
|
+
* object storage is *reused* rather than re-minted. `dev` writes no file of its own, so
|
|
58
|
+
* without a source of persisted secrets every start would mint a credential and leave the
|
|
59
|
+
* last one live — one orphan per restart. `neon-env run` already reads the file for this
|
|
60
|
+
* reason; `dev` was the one that didn't.
|
|
61
|
+
*/
|
|
62
|
+
export const devEnvContext = (props, branchId, cwd) => {
|
|
63
|
+
const envFile = resolveEnvFilePath(cwd);
|
|
64
|
+
return {
|
|
65
|
+
cwd,
|
|
66
|
+
implyAiGateway: true,
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
...(existsSync(envFile) ? readEnvFile(envFile) : {}),
|
|
70
|
+
},
|
|
71
|
+
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
72
|
+
...(branchId ? { branchId } : {}),
|
|
73
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
74
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Say when a run issued a branch credential.
|
|
79
|
+
*
|
|
80
|
+
* `dev` has nowhere to persist one — it writes no file — so on a branch with nothing to reuse
|
|
81
|
+
* it mints per start and cannot name the previous one to revoke it. Every other command that
|
|
82
|
+
* mints says so; this is the one that runs dozens of times a day, and the server banner listing
|
|
83
|
+
* `NEON_AI_GATEWAY_TOKEN` reads as "fetched", not "just created, and the last one is still
|
|
84
|
+
* live". The note names the one action that stops it, so it disappears once followed.
|
|
85
|
+
*/
|
|
86
|
+
export const reportDevCredential = (credential) => {
|
|
87
|
+
if (!credential?.issued)
|
|
88
|
+
return;
|
|
89
|
+
if (credential.revoked.length > 0) {
|
|
90
|
+
log.info("Issued a new branch credential — %s changed. Revoked the one it replaced (%s).", credential.keys.join(", "), credential.revoked.join(", "));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
log.warning("Issued a branch credential for this run (%s) and left any previous one live — a dev " +
|
|
94
|
+
`server has nowhere to keep it. Run \`${getCliName()} env pull\` once to write it to ` +
|
|
95
|
+
"your .env, and restarts will reuse it instead of issuing another.", credential.keys.join(", "));
|
|
96
|
+
};
|
|
34
97
|
export const handler = async (props) => {
|
|
35
98
|
if (props.source !== undefined) {
|
|
36
99
|
await runSingleSource(props);
|
|
@@ -51,13 +114,8 @@ const runSingleSource = async (props) => {
|
|
|
51
114
|
throw new Error(`Source file not found: ${source}`);
|
|
52
115
|
}
|
|
53
116
|
const branchId = await resolveBranchId(props);
|
|
54
|
-
const { vars: neonEnv, skipped } = await resolveDevEnv(
|
|
55
|
-
|
|
56
|
-
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
57
|
-
...(branchId ? { branchId } : {}),
|
|
58
|
-
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
59
|
-
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
60
|
-
});
|
|
117
|
+
const { vars: neonEnv, skipped, credential, } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
|
|
118
|
+
reportDevCredential(credential);
|
|
61
119
|
const unit = {
|
|
62
120
|
slug: null,
|
|
63
121
|
source,
|
|
@@ -89,13 +147,8 @@ const runFromConfig = async (props) => {
|
|
|
89
147
|
throw new Error("neon.ts has no functions to serve. Add at least one under " +
|
|
90
148
|
"`preview.functions`, or pass --source <path>.");
|
|
91
149
|
}
|
|
92
|
-
const { vars: neonEnv, skipped } = await resolveDevEnv(
|
|
93
|
-
|
|
94
|
-
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
95
|
-
...(branchId ? { branchId } : {}),
|
|
96
|
-
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
97
|
-
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
98
|
-
});
|
|
150
|
+
const { vars: neonEnv, skipped, credential, } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
|
|
151
|
+
reportDevCredential(credential);
|
|
99
152
|
const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
|
|
100
153
|
// Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
|
|
101
154
|
// functions without restarting the dev server. `searchBase` lets a freshly-added unit
|
package/dist/commands/env.js
CHANGED
|
@@ -4,7 +4,9 @@ import chalk from "chalk";
|
|
|
4
4
|
import { ensureGitignored } from "../context.js";
|
|
5
5
|
import { resolveNeonEnvVars } from "../dev/env.js";
|
|
6
6
|
import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
|
|
7
|
+
import { ENV_PULL_SERVICES, ENV_PULL_UNAVAILABLE, envServiceKeys, ownedEnvServiceKeys, } from "../env_services.js";
|
|
7
8
|
import { log } from "../log.js";
|
|
9
|
+
import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption, } from "../neon_services.js";
|
|
8
10
|
import { warnAiGateway } from "../utils/ai_gateway_notice.js";
|
|
9
11
|
import { announceTargetBranch } from "../utils/branch_notice.js";
|
|
10
12
|
import { getCliName } from "../utils/cli_name.js";
|
|
@@ -34,14 +36,51 @@ export const builder = (argv) => argv
|
|
|
34
36
|
"lines are preserved.",
|
|
35
37
|
type: "string",
|
|
36
38
|
},
|
|
39
|
+
service: servicesOption({
|
|
40
|
+
key: "service",
|
|
41
|
+
allowed: ENV_PULL_SERVICES,
|
|
42
|
+
describe: "Pull only these services' variables",
|
|
43
|
+
also: "Overrides neon.ts, and prunes only within the services you name.",
|
|
44
|
+
}),
|
|
37
45
|
})
|
|
46
|
+
.epilogue([
|
|
47
|
+
"",
|
|
48
|
+
"What gets pulled, in precedence order:",
|
|
49
|
+
" 1. --service, when given — exactly those, ignoring neon.ts.",
|
|
50
|
+
" 2. neon.ts, when this directory has one.",
|
|
51
|
+
" 3. Otherwise everything the branch has, plus the AI Gateway —",
|
|
52
|
+
" which mints a branch credential for it.",
|
|
53
|
+
"",
|
|
54
|
+
"The pull bundled into link / checkout / config apply follows 2 and 3",
|
|
55
|
+
"without the AI Gateway, so it never mints a credential you did not ask",
|
|
56
|
+
"for. Run `env pull` to add it.",
|
|
57
|
+
].join("\n"))
|
|
38
58
|
.example("$0 env pull", "Write the linked branch's Neon vars into .env.local (or .env if present)")
|
|
39
|
-
.example("$0 env pull --branch preview --file .env.preview", "Pull a specific branch into a specific file")
|
|
59
|
+
.example("$0 env pull --branch preview --file .env.preview", "Pull a specific branch into a specific file")
|
|
60
|
+
.example("$0 env pull -s ai-gateway -s postgres", "Pull only the AI Gateway and Postgres variables"), async (args) => {
|
|
61
|
+
const raw = servicesFlagValue(args.service);
|
|
40
62
|
// Explicit `env pull` announces the branch it's reading from up front so the user
|
|
41
63
|
// can catch "pulled env from the wrong branch" before it overwrites their .env. The
|
|
42
64
|
// bundled auto-pull (link / checkout / apply) stays quiet — those already report the
|
|
43
65
|
// branch they pinned/applied to.
|
|
44
|
-
|
|
66
|
+
//
|
|
67
|
+
// It also implies the AI Gateway when there is no neon.ts, so a bare `env pull`
|
|
68
|
+
// really does write everything the branch can give you. The bundled auto-pull does
|
|
69
|
+
// not: minting a credential for a service the user never named is not something a
|
|
70
|
+
// side effect of `link` / `checkout` / `apply` should do.
|
|
71
|
+
await pull({
|
|
72
|
+
...args,
|
|
73
|
+
...(raw
|
|
74
|
+
? {
|
|
75
|
+
services: parseServices(raw, {
|
|
76
|
+
allowed: ENV_PULL_SERVICES,
|
|
77
|
+
whyUnavailable: ENV_PULL_UNAVAILABLE,
|
|
78
|
+
flag: "--service",
|
|
79
|
+
onDeprecated: (used, canonical) => log.warning(deprecatedServiceMessage(used, canonical)),
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
}, { announce: true, implyAiGateway: raw === undefined });
|
|
45
84
|
})
|
|
46
85
|
.demandCommand(1);
|
|
47
86
|
export const handler = (args) => args;
|
|
@@ -83,16 +122,18 @@ export const pull = async (props, opts = {}) => {
|
|
|
83
122
|
// Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
|
|
84
123
|
// pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
|
|
85
124
|
// `env pull` is an explicit action, so it should error rather than write nothing.
|
|
86
|
-
const { vars, credential } = await resolveNeonEnvVars({
|
|
125
|
+
const { vars, credential, skipped } = await resolveNeonEnvVars({
|
|
87
126
|
cwd,
|
|
88
127
|
projectId: props.projectId,
|
|
89
128
|
branchId,
|
|
90
129
|
env: { ...process.env, ...existingEnv },
|
|
130
|
+
...(props.services ? { services: props.services } : {}),
|
|
131
|
+
...(opts.implyAiGateway ? { implyAiGateway: true } : {}),
|
|
91
132
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
92
133
|
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
93
134
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
94
135
|
});
|
|
95
|
-
const neonVars = pickNeonVars(vars);
|
|
136
|
+
const neonVars = pickServiceVars(pickNeonVars(vars), props.services);
|
|
96
137
|
if (Object.keys(neonVars).length === 0) {
|
|
97
138
|
log.info("No Neon env variables to pull for this branch (no DATABASE_URL or " +
|
|
98
139
|
"enabled Auth / Data API).");
|
|
@@ -102,7 +143,7 @@ export const pull = async (props, opts = {}) => {
|
|
|
102
143
|
// Neon-owned vars the branch no longer has (e.g. NEON_AUTH_* / NEON_DATA_API_* carried over
|
|
103
144
|
// from a previous project/branch). Non-Neon lines are always preserved.
|
|
104
145
|
const { written, removed } = mergeEnvFile(targetPath, neonVars, {
|
|
105
|
-
managedKeys:
|
|
146
|
+
managedKeys: managedKeysFor(props.services, unreachedButCurrent(skipped, existingEnv, branchId)),
|
|
106
147
|
});
|
|
107
148
|
log.info("Pulled %d Neon variable%s into %s: %s", written.length, written.length === 1 ? "" : "s", targetPath, written.join(", "));
|
|
108
149
|
if (removed.length > 0) {
|
|
@@ -116,6 +157,17 @@ export const pull = async (props, opts = {}) => {
|
|
|
116
157
|
if (credential.revoked.length > 0) {
|
|
117
158
|
log.info("Revoked the credential it replaced (%s).", credential.revoked.join(", "));
|
|
118
159
|
}
|
|
160
|
+
else if (credential.superseded.length > 0) {
|
|
161
|
+
// An unscoped pull revokes what it supersedes and says so above. A scoped one
|
|
162
|
+
// cannot — it may not be the only service on that credential — so it leaves the
|
|
163
|
+
// old one live. Say that too, rather than letting the identical-looking output
|
|
164
|
+
// imply the branch is not accumulating credentials. Driven by what the resolver
|
|
165
|
+
// actually declined to revoke, so a first pull (which supersedes nothing) does
|
|
166
|
+
// not send the user hunting for a credential that was never there.
|
|
167
|
+
log.info("Left the credential it replaced live (%s): a pull scoped with --service " +
|
|
168
|
+
"can't tell which other services still use it. Revoke it in the Neon " +
|
|
169
|
+
"Console if nothing does.", credential.superseded.join(", "));
|
|
170
|
+
}
|
|
119
171
|
}
|
|
120
172
|
// A dotenv file *we* create holds live branch credentials (DATABASE_URL, Auth keys, service
|
|
121
173
|
// tokens), so ignore it the same way the `.neon` context file is — otherwise a fresh repo is
|
|
@@ -143,8 +195,75 @@ export const pull = async (props, opts = {}) => {
|
|
|
143
195
|
written,
|
|
144
196
|
file: targetPath,
|
|
145
197
|
...(credential && credential.keys.length > 0 ? { credential } : {}),
|
|
198
|
+
...(skipped && skipped.length > 0 ? { skipped } : {}),
|
|
146
199
|
};
|
|
147
200
|
};
|
|
201
|
+
/**
|
|
202
|
+
* The keys this pull is allowed to prune, i.e. the ones it is authoritative for.
|
|
203
|
+
*
|
|
204
|
+
* A `--service` selection narrows that to the services it named: `env pull -s ai-gateway`
|
|
205
|
+
* says nothing about `DATABASE_URL`, so it must not read that variable's absence from this
|
|
206
|
+
* pull as "the branch no longer has it". `unreached` is subtracted for the same reason — see
|
|
207
|
+
* {@link unreachedButCurrent}.
|
|
208
|
+
*/
|
|
209
|
+
const managedKeysFor = (services, unreached) => {
|
|
210
|
+
const owned = services
|
|
211
|
+
? ownedEnvServiceKeys(services)
|
|
212
|
+
: [...NEON_OWNED_ENV_KEYS];
|
|
213
|
+
if (unreached.length === 0)
|
|
214
|
+
return owned;
|
|
215
|
+
const keep = new Set(ownedEnvServiceKeys(unreached));
|
|
216
|
+
return owned.filter((key) => !keep.has(key));
|
|
217
|
+
};
|
|
218
|
+
/**
|
|
219
|
+
* Of the services this pull could not reach, the ones whose variables already on disk belong
|
|
220
|
+
* to the branch being pulled — the only ones worth keeping.
|
|
221
|
+
*
|
|
222
|
+
* Failing to reach a service is not evidence that the branch stopped having it:
|
|
223
|
+
* `PLATFORM_FEATURE_UNAVAILABLE` covers a transient incident as well as a project that
|
|
224
|
+
* genuinely lacks the feature, and pruning would delete a token whose secret exists nowhere
|
|
225
|
+
* else and strand the live credential behind it. But that only argues for keeping *this
|
|
226
|
+
* branch's* values. Variables left over from another branch are stale by definition, and
|
|
227
|
+
* keeping those would leave an app pointed at the wrong branch's gateway — a worse failure
|
|
228
|
+
* than losing a token, because it is silent.
|
|
229
|
+
*
|
|
230
|
+
* The gateway is the only service that can be unreached (only it is implied rather than
|
|
231
|
+
* observed), and its base URL is branch-scoped, so the persisted URL is what tells the two
|
|
232
|
+
* cases apart. Anything that does not resolve to this branch's gateway host is pruned, which
|
|
233
|
+
* is the safe direction: a stale entry costs a re-pull, a wrongly-kept one silently misroutes
|
|
234
|
+
* traffic.
|
|
235
|
+
*/
|
|
236
|
+
const unreachedButCurrent = (skipped, existingEnv, branchId) => {
|
|
237
|
+
if (!skipped?.includes("ai-gateway"))
|
|
238
|
+
return [];
|
|
239
|
+
const baseUrl = existingEnv[NEON_ENV_VAR_KEYS.aiGateway.baseUrl];
|
|
240
|
+
return baseUrl !== undefined && isBranchGatewayUrl(baseUrl, branchId)
|
|
241
|
+
? ["ai-gateway"]
|
|
242
|
+
: [];
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* Whether a persisted `NEON_AI_GATEWAY_BASE_URL` addresses `branchId`'s gateway.
|
|
246
|
+
*
|
|
247
|
+
* Checks the parsed **hostname** against the shape `@neon/env` builds
|
|
248
|
+
* (`<branchId>-api.ai.<host suffix>`), not the raw string: a prefix comparison is satisfied
|
|
249
|
+
* by a URL whose userinfo carries the branch id (`https://<branchId>-api.ai.@other-host/`)
|
|
250
|
+
* while the request actually goes elsewhere. An unparseable value is not this branch's
|
|
251
|
+
* gateway either, which is an answer rather than a swallowed failure.
|
|
252
|
+
*/
|
|
253
|
+
const isBranchGatewayUrl = (baseUrl, branchId) => URL.canParse(baseUrl) &&
|
|
254
|
+
new URL(baseUrl).hostname.startsWith(`${branchId}-api.ai.`);
|
|
255
|
+
/**
|
|
256
|
+
* Narrow the resolved vars to the selected services (plus `NEON_BRANCH`, which every pull
|
|
257
|
+
* refreshes). Needed because the two `DATABASE_URL*` vars are always resolved — `fetchEnv`
|
|
258
|
+
* reads both connection URIs regardless, since the AI Gateway host is derived from the direct
|
|
259
|
+
* one — so `--service ai-gateway` has to drop them here rather than avoid fetching them.
|
|
260
|
+
*/
|
|
261
|
+
const pickServiceVars = (vars, services) => {
|
|
262
|
+
if (!services)
|
|
263
|
+
return vars;
|
|
264
|
+
const wanted = envServiceKeys(services);
|
|
265
|
+
return Object.fromEntries(Object.entries(vars).filter(([key]) => wanted.has(key)));
|
|
266
|
+
};
|
|
148
267
|
/**
|
|
149
268
|
* Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
|
|
150
269
|
* `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
|
|
@@ -183,6 +302,8 @@ export const renderAgentPullNote = (result) => {
|
|
|
183
302
|
const credential = result.credential?.issued
|
|
184
303
|
? ` Issued a new branch credential, so ${result.credential.keys.join(", ")} changed.`
|
|
185
304
|
: "";
|
|
305
|
+
// No `skipped` note: only the implied AI Gateway can be skipped, and the auto-pull
|
|
306
|
+
// this renders never implies it.
|
|
186
307
|
return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? "" : "s"} into ${result.file}.${credential}`;
|
|
187
308
|
}
|
|
188
309
|
case "empty":
|
package/dist/config_template.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NEON_SERVICES } from "./neon_services.js";
|
|
1
2
|
/**
|
|
2
3
|
* The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
|
|
3
4
|
*
|
|
@@ -11,55 +12,32 @@ export const CONFIG_PACKAGE = "@neon/config";
|
|
|
11
12
|
export const ENV_PACKAGE = "@neon/env";
|
|
12
13
|
export const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* The services `config init` can declare in the `neon.ts` it scaffolds — the subset of
|
|
16
|
+
* {@link NEON_SERVICES} a policy has a field for. {@link renderNeonConfig} owns the mapping
|
|
17
|
+
* from these names to the `neon.ts` fields (`aiGateway`, `buckets`).
|
|
17
18
|
*
|
|
18
|
-
* Postgres is absent because every branch has it,
|
|
19
|
-
* with the default `authProvider: "neon"` requires `auth` — a
|
|
20
|
-
* enforce rather than offer.
|
|
19
|
+
* Postgres is absent because every branch has it, so there is nothing to declare. `data-api`
|
|
20
|
+
* is absent because enabling it with the default `authProvider: "neon"` requires `auth` — a
|
|
21
|
+
* pairing the picker would have to enforce rather than offer.
|
|
21
22
|
*/
|
|
22
|
-
export const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
export const CONFIG_INIT_SERVICES = NEON_SERVICES.filter((service) => service !== "postgres" && service !== "data-api");
|
|
24
|
+
/**
|
|
25
|
+
* What `config init --services none` produces. One constant because it is both the help text
|
|
26
|
+
* and what tells the parser `none` is a value here — passing the literal at each call site
|
|
27
|
+
* lets the two drift into documenting something the parser does not accept.
|
|
28
|
+
*/
|
|
29
|
+
export const CONFIG_INIT_NONE_MEANS = "the bare starter policy";
|
|
30
|
+
/** Why the two a policy cannot declare are not selectable, for the refusal message. */
|
|
31
|
+
export const CONFIG_INIT_UNAVAILABLE = {
|
|
32
|
+
postgres: "every branch has Postgres, so a policy has nothing to declare for it",
|
|
33
|
+
"data-api": "enabling it with the default provider requires auth, so declare auth here and turn the Data API on with `neon data-api create`",
|
|
34
|
+
};
|
|
30
35
|
/** Slug, display name, and source path of the function scaffolded for `functions`. */
|
|
31
36
|
export const FUNCTION_SLUG = "hello";
|
|
32
37
|
export const FUNCTION_NAME = "Hello World";
|
|
33
38
|
export const FUNCTION_FILENAME = "hello.ts";
|
|
34
39
|
/** Name of the bucket scaffolded for `storage`. */
|
|
35
40
|
export const BUCKET_NAME = "assets";
|
|
36
|
-
/**
|
|
37
|
-
* Parse a `--services` value into a canonical service list: comma-separated
|
|
38
|
-
* {@link NEON_SERVICES} names, or {@link NO_SERVICES} on its own for none.
|
|
39
|
-
*
|
|
40
|
-
* Unknown names are rejected here rather than silently dropped — a typo'd service would
|
|
41
|
-
* otherwise scaffold a policy missing exactly the service the user asked for. The result is
|
|
42
|
-
* deduplicated and ordered by {@link NEON_SERVICES} so the rendered file doesn't depend on the
|
|
43
|
-
* order they were typed in.
|
|
44
|
-
*/
|
|
45
|
-
export const parseServices = (raw) => {
|
|
46
|
-
const names = raw
|
|
47
|
-
.split(",")
|
|
48
|
-
.map((name) => name.trim())
|
|
49
|
-
.filter((name) => name !== "");
|
|
50
|
-
if (names.includes(NO_SERVICES)) {
|
|
51
|
-
if (names.length > 1) {
|
|
52
|
-
throw new Error(`--services ${NO_SERVICES} cannot be combined with other services.`);
|
|
53
|
-
}
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
const unknown = names.filter((name) => !NEON_SERVICES.includes(name));
|
|
57
|
-
if (unknown.length > 0) {
|
|
58
|
-
throw new Error(`Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}. ` +
|
|
59
|
-
`Supported values: ${NEON_SERVICES.join(", ")}, ${NO_SERVICES}.`);
|
|
60
|
-
}
|
|
61
|
-
return NEON_SERVICES.filter((service) => names.includes(service));
|
|
62
|
-
};
|
|
63
41
|
/**
|
|
64
42
|
* One indentation level in the emitted `neon.ts`. Two spaces, which is what every renderer
|
|
65
43
|
* here produces and what `config_template.format.test.ts` holds them to.
|
|
@@ -88,7 +66,7 @@ const renderPreview = (services) => {
|
|
|
88
66
|
...at(3, `${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`),
|
|
89
67
|
]));
|
|
90
68
|
}
|
|
91
|
-
if (services.includes("storage")) {
|
|
69
|
+
if (services.includes("object-storage")) {
|
|
92
70
|
lines.push(...block(2, "buckets", [
|
|
93
71
|
...at(3, `// "private" is the default; use "public_read" for anonymous reads`, `${BUCKET_NAME}: { access: "private" },`),
|
|
94
72
|
]));
|
package/dist/dev/env.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { loadConfigFromFile } from "@neon/config";
|
|
1
|
+
import { createNeonApiFromOptions, loadConfigFromFile, } from "@neon/config";
|
|
2
2
|
import { plan, pullConfig } from "@neon/config-runtime";
|
|
3
|
+
import { NEON_ENV_VAR_KEYS } from "@neon/env";
|
|
3
4
|
import { fetchEnvReusingSecrets, } from "@neon/env/runtime";
|
|
4
5
|
import { log } from "../log.js";
|
|
5
6
|
import { getCliName } from "../utils/cli_name.js";
|
|
@@ -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
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NEON_ENV_VAR_KEYS } from "@neon/env";
|
|
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;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
2
4
|
import which from "which";
|
|
3
5
|
import { log } from "../log.js";
|
|
4
6
|
// npm first so it's the default/preselected choice; the rest follow in rough
|
|
@@ -9,6 +11,42 @@ export const PACKAGE_MANAGERS = [
|
|
|
9
11
|
"yarn",
|
|
10
12
|
"bun",
|
|
11
13
|
];
|
|
14
|
+
/**
|
|
15
|
+
* Lockfiles, and the package manager each one belongs to. npm is last on
|
|
16
|
+
* purpose: a repo with both a pnpm lockfile and a leftover `package-lock.json`
|
|
17
|
+
* (which a failed run like the one this fixes can leave behind) is a pnpm repo.
|
|
18
|
+
*/
|
|
19
|
+
const LOCKFILES = [
|
|
20
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
21
|
+
["yarn.lock", "yarn"],
|
|
22
|
+
// bun 1.2+ writes the text `bun.lock`; older versions the binary `bun.lockb`.
|
|
23
|
+
["bun.lock", "bun"],
|
|
24
|
+
["bun.lockb", "bun"],
|
|
25
|
+
["package-lock.json", "npm"],
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* The package manager the project at `cwd` uses, from its lockfile. Searches
|
|
29
|
+
* `cwd` and then each parent up to the repo root: in a monorepo the lockfile
|
|
30
|
+
* sits at the root while we scaffold into a package. Stopping at the root keeps
|
|
31
|
+
* a stray lockfile above the repository from deciding how we install into it.
|
|
32
|
+
*/
|
|
33
|
+
export const detectProjectPackageManager = (cwd) => {
|
|
34
|
+
let dir = cwd;
|
|
35
|
+
for (;;) {
|
|
36
|
+
for (const [file, pm] of LOCKFILES) {
|
|
37
|
+
if (existsSync(join(dir, file)))
|
|
38
|
+
return pm;
|
|
39
|
+
}
|
|
40
|
+
// After the lockfiles, not before: the repo root's own lockfile counts.
|
|
41
|
+
// `.git` is a file rather than a directory in a worktree or submodule.
|
|
42
|
+
if (existsSync(join(dir, ".git")))
|
|
43
|
+
return undefined;
|
|
44
|
+
const parent = dirname(dir);
|
|
45
|
+
if (parent === dir)
|
|
46
|
+
return undefined;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
12
50
|
/**
|
|
13
51
|
* The package manager the CLI was invoked through, read from the
|
|
14
52
|
* `npm_config_user_agent` npm sets for `npm exec`/`npx`, `pnpm dlx`, `yarn
|
|
@@ -32,11 +70,20 @@ export const detectPackageManager = () => {
|
|
|
32
70
|
/** The package managers actually on PATH, in {@link PACKAGE_MANAGERS} order. */
|
|
33
71
|
export const installedPackageManagers = () => PACKAGE_MANAGERS.filter((pm) => which.sync(pm, { nothrow: true }) !== null);
|
|
34
72
|
/**
|
|
35
|
-
* Pick a package manager without prompting: the one the
|
|
36
|
-
* else the
|
|
37
|
-
* `config init`) where there's no
|
|
73
|
+
* Pick a package manager without prompting: the one the project at `cwd` uses,
|
|
74
|
+
* else the one the CLI was invoked through, else the first one installed, else
|
|
75
|
+
* npm. Used by non-interactive flows (e.g. `config init`) where there's no
|
|
76
|
+
* scaffold prompt to hang a picker off.
|
|
77
|
+
*
|
|
78
|
+
* The project wins over the invocation on purpose. `npx neon …` inside a pnpm
|
|
79
|
+
* repo should still install with pnpm — which tool launched us says nothing
|
|
80
|
+
* about which one owns that project's `node_modules`, and running npm against
|
|
81
|
+
* pnpm's symlinked tree is what this ordering exists to prevent.
|
|
38
82
|
*/
|
|
39
|
-
export const resolvePackageManager = () =>
|
|
83
|
+
export const resolvePackageManager = (cwd) => detectProjectPackageManager(cwd) ??
|
|
84
|
+
detectPackageManager() ??
|
|
85
|
+
installedPackageManagers()[0] ??
|
|
86
|
+
"npm";
|
|
40
87
|
/**
|
|
41
88
|
* The argv that adds `packages` as runtime dependencies with `pm`. npm spells it
|
|
42
89
|
* `install`; pnpm/yarn/bun use `add`.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import prompts from "prompts";
|
|
2
|
-
import {
|
|
2
|
+
import { CONFIG_INIT_SERVICES } from "../config_template.js";
|
|
3
3
|
/**
|
|
4
|
-
* The picker's rows, in {@link
|
|
5
|
-
* CLI's README ("Managed Better Auth", "Object Storage") rather than the `neon.ts` field
|
|
4
|
+
* The picker's rows, in {@link CONFIG_INIT_SERVICES} order. Titles use the product names from
|
|
5
|
+
* the CLI's README ("Managed Better Auth", "Object Storage") rather than the `neon.ts` field
|
|
6
6
|
* names, since this is the list a user reads before they've seen a policy.
|
|
7
7
|
*/
|
|
8
8
|
const CHOICES = [
|
|
@@ -17,7 +17,7 @@ const CHOICES = [
|
|
|
17
17
|
description: "Long-running, without timeouts, and closer to your database.",
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
value: "storage",
|
|
20
|
+
value: "object-storage",
|
|
21
21
|
title: "Object Storage",
|
|
22
22
|
description: "S3-compatible blob storage that branches with your projects.",
|
|
23
23
|
},
|
|
@@ -58,7 +58,7 @@ export const pickServicesInteractively = async () => {
|
|
|
58
58
|
if (!Array.isArray(services)) {
|
|
59
59
|
throw new Error("Aborted: no services selected.");
|
|
60
60
|
}
|
|
61
|
-
// Order
|
|
61
|
+
// Order canonically rather than by selection order so the rendered neon.ts is
|
|
62
62
|
// independent of the order the rows were toggled in.
|
|
63
|
-
return
|
|
63
|
+
return CONFIG_INIT_SERVICES.filter((service) => services.includes(service));
|
|
64
64
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.47.0",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"yoctocolors": "^2.1.2",
|
|
63
63
|
"@neon/sdk": "1.5.0",
|
|
64
64
|
"@neon/config": "0.14.1",
|
|
65
|
-
"@neon/
|
|
66
|
-
"@neon/
|
|
65
|
+
"@neon/env": "0.15.0",
|
|
66
|
+
"@neon/config-runtime": "0.12.5"
|
|
67
67
|
},
|
|
68
68
|
"optionalDependencies": {
|
|
69
69
|
"esbuild": "0.28.1"
|