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/commands/dev.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { describeNativeFinding, findUndeclaredNativePackages, } from "@neon/config-runtime";
|
|
6
7
|
import chalk from "chalk";
|
|
7
8
|
import { resolveDevEnv } from "../dev/env.js";
|
|
8
9
|
import { resolveFunctionsFromConfig, } from "../dev/functions.js";
|
|
9
10
|
import { resolveWatchInputs } from "../dev/inputs.js";
|
|
11
|
+
import { readEnvFile, resolveEnvFilePath } from "../env_file.js";
|
|
10
12
|
import { log } from "../log.js";
|
|
13
|
+
import { getCliName } from "../utils/cli_name.js";
|
|
11
14
|
import { branchIdResolve } from "../utils/enrichers.js";
|
|
12
15
|
import { bundleEntry } from "../utils/esbuild.js";
|
|
13
16
|
export const command = "dev";
|
|
@@ -30,7 +33,68 @@ export const builder = (argv) => argv
|
|
|
30
33
|
type: "number",
|
|
31
34
|
},
|
|
32
35
|
})
|
|
36
|
+
.epilogue([
|
|
37
|
+
"",
|
|
38
|
+
"Functions run with the linked branch's Neon env injected, the same set the",
|
|
39
|
+
"deployed runtime gives them: DATABASE_URL, plus Neon Auth, the Data API,",
|
|
40
|
+
"object storage and the AI Gateway where the branch has them. A neon.ts in",
|
|
41
|
+
"this directory decides instead, exactly as it does for `env pull`.",
|
|
42
|
+
"",
|
|
43
|
+
"`dev` reads your .env / .env.local to reuse the branch credential behind the",
|
|
44
|
+
"AI Gateway and object storage, and never writes to them. With no such file it",
|
|
45
|
+
"issues a credential on every start, so run `env pull` once if you restart often.",
|
|
46
|
+
].join("\n"))
|
|
33
47
|
.strict();
|
|
48
|
+
/**
|
|
49
|
+
* The resolver context for a `neon dev` run.
|
|
50
|
+
*
|
|
51
|
+
* Two things here are what make local dev match the deployed runtime, which injects a
|
|
52
|
+
* branch's whole env into a function:
|
|
53
|
+
*
|
|
54
|
+
* - **The AI Gateway is asked for**, like `env pull` does, because nothing can detect it.
|
|
55
|
+
* Without this, a function that works deployed fails locally with no `NEON_AI_GATEWAY_*`,
|
|
56
|
+
* which is exactly the difference `dev` exists to eliminate.
|
|
57
|
+
* - **The local dotenv file is layered in**, so the branch credential behind the gateway and
|
|
58
|
+
* object storage is *reused* rather than re-minted. `dev` writes no file of its own, so
|
|
59
|
+
* without a source of persisted secrets every start would mint a credential and leave the
|
|
60
|
+
* last one live — one orphan per restart. `neon-env run` already reads the file for this
|
|
61
|
+
* reason; `dev` was the one that didn't.
|
|
62
|
+
*/
|
|
63
|
+
export const devEnvContext = (props, branchId, cwd) => {
|
|
64
|
+
const envFile = resolveEnvFilePath(cwd);
|
|
65
|
+
return {
|
|
66
|
+
cwd,
|
|
67
|
+
implyAiGateway: true,
|
|
68
|
+
env: {
|
|
69
|
+
...process.env,
|
|
70
|
+
...(existsSync(envFile) ? readEnvFile(envFile) : {}),
|
|
71
|
+
},
|
|
72
|
+
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
73
|
+
...(branchId ? { branchId } : {}),
|
|
74
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
75
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Say when a run issued a branch credential.
|
|
80
|
+
*
|
|
81
|
+
* `dev` has nowhere to persist one — it writes no file — so on a branch with nothing to reuse
|
|
82
|
+
* it mints per start and cannot name the previous one to revoke it. Every other command that
|
|
83
|
+
* mints says so; this is the one that runs dozens of times a day, and the server banner listing
|
|
84
|
+
* `NEON_AI_GATEWAY_TOKEN` reads as "fetched", not "just created, and the last one is still
|
|
85
|
+
* live". The note names the one action that stops it, so it disappears once followed.
|
|
86
|
+
*/
|
|
87
|
+
export const reportDevCredential = (credential) => {
|
|
88
|
+
if (!credential?.issued)
|
|
89
|
+
return;
|
|
90
|
+
if (credential.revoked.length > 0) {
|
|
91
|
+
log.info("Issued a new branch credential — %s changed. Revoked the one it replaced (%s).", credential.keys.join(", "), credential.revoked.join(", "));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
log.warning("Issued a branch credential for this run (%s) and left any previous one live — a dev " +
|
|
95
|
+
`server has nowhere to keep it. Run \`${getCliName()} env pull\` once to write it to ` +
|
|
96
|
+
"your .env, and restarts will reuse it instead of issuing another.", credential.keys.join(", "));
|
|
97
|
+
};
|
|
34
98
|
export const handler = async (props) => {
|
|
35
99
|
if (props.source !== undefined) {
|
|
36
100
|
await runSingleSource(props);
|
|
@@ -51,17 +115,12 @@ const runSingleSource = async (props) => {
|
|
|
51
115
|
throw new Error(`Source file not found: ${source}`);
|
|
52
116
|
}
|
|
53
117
|
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
|
-
});
|
|
118
|
+
const { vars: neonEnv, skipped, credential, } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
|
|
119
|
+
reportDevCredential(credential);
|
|
61
120
|
const unit = {
|
|
62
121
|
slug: null,
|
|
63
122
|
source,
|
|
64
|
-
bundleDir:
|
|
123
|
+
bundleDir: devBundleDir(process.cwd()),
|
|
65
124
|
childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
|
|
66
125
|
label: null,
|
|
67
126
|
envSummary: { neon: Object.keys(neonEnv), fn: [] },
|
|
@@ -89,13 +148,8 @@ const runFromConfig = async (props) => {
|
|
|
89
148
|
throw new Error("neon.ts has no functions to serve. Add at least one under " +
|
|
90
149
|
"`preview.functions`, or pass --source <path>.");
|
|
91
150
|
}
|
|
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
|
-
});
|
|
151
|
+
const { vars: neonEnv, skipped, credential, } = await resolveDevEnv(devEnvContext(props, branchId, process.cwd()));
|
|
152
|
+
reportDevCredential(credential);
|
|
99
153
|
const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
|
|
100
154
|
// Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
|
|
101
155
|
// functions without restarting the dev server. `searchBase` lets a freshly-added unit
|
|
@@ -160,6 +214,19 @@ const portFromProps = (port) => {
|
|
|
160
214
|
}
|
|
161
215
|
return { mode: "search", from: DEFAULT_PORT_BASE };
|
|
162
216
|
};
|
|
217
|
+
/**
|
|
218
|
+
* Where a locally-served function's bundle is written.
|
|
219
|
+
*
|
|
220
|
+
* The location inside the project's own `node_modules` is load-bearing, not a tidiness
|
|
221
|
+
* choice. A function's `externalPackages` are left unbundled, and Node resolves an unbundled
|
|
222
|
+
* import by walking up from the importing file — so from here it reaches the project's real
|
|
223
|
+
* `node_modules` and finds them, at the host architecture that can actually run locally.
|
|
224
|
+
* Moving this directory anywhere outside `node_modules` breaks `neon dev` for those
|
|
225
|
+
* functions with a `Cannot find module`, so the path is pinned by a test.
|
|
226
|
+
*/
|
|
227
|
+
export const devBundleDir = (cwd, slug) => slug === undefined
|
|
228
|
+
? join(cwd, "node_modules", ".neon-dev")
|
|
229
|
+
: join(cwd, "node_modules", ".neon-dev", slug);
|
|
163
230
|
/**
|
|
164
231
|
* Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
|
|
165
232
|
* - explicit `dev.port`: bind exactly, fail if taken.
|
|
@@ -174,7 +241,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
|
|
|
174
241
|
return {
|
|
175
242
|
slug: fn.slug,
|
|
176
243
|
source: fn.source,
|
|
177
|
-
bundleDir:
|
|
244
|
+
bundleDir: devBundleDir(process.cwd(), fn.slug),
|
|
178
245
|
childEnv,
|
|
179
246
|
label: fn.slug,
|
|
180
247
|
envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
|
|
@@ -233,7 +300,9 @@ const runSupervisor = async (units, options = {}) => {
|
|
|
233
300
|
const bundleAndStart = async (r) => {
|
|
234
301
|
let bundlePath;
|
|
235
302
|
try {
|
|
236
|
-
bundlePath = await writeBundle(r.unit.source, r.unit.bundleDir, r.unit.externalPackages
|
|
303
|
+
bundlePath = await writeBundle(r.unit.source, r.unit.bundleDir, r.unit.externalPackages,
|
|
304
|
+
// The `neon.ts` key, so a function is named the same way here as at deploy.
|
|
305
|
+
r.unit.slug ?? undefined);
|
|
237
306
|
}
|
|
238
307
|
catch (err) {
|
|
239
308
|
r.status = "error";
|
|
@@ -481,10 +550,35 @@ const spawnChild = (unit, runtimePath, bundlePath) => {
|
|
|
481
550
|
detached: true,
|
|
482
551
|
});
|
|
483
552
|
};
|
|
484
|
-
|
|
485
|
-
|
|
553
|
+
/**
|
|
554
|
+
* Findings already reported for a served unit, so an advisory that cannot change between
|
|
555
|
+
* saves is not reprinted on every one. A dev session on a project with a standing false
|
|
556
|
+
* positive would otherwise repeat the whole block for its lifetime.
|
|
557
|
+
*/
|
|
558
|
+
const reportedFindings = new Map();
|
|
559
|
+
const writeBundle = async (source, bundleDir, externalPackages, label) => {
|
|
560
|
+
// Left unbundled only. `bundleDir` sits inside the project's node_modules, so an
|
|
561
|
+
// externalized package resolves from the real tree at the host architecture — no install
|
|
562
|
+
// or copy is needed or wanted locally, whatever `includeFiles` says for a deploy.
|
|
563
|
+
const { files, metafile, warnings } = await bundleEntry(source, {
|
|
486
564
|
...(externalPackages ? { externalPackages } : {}),
|
|
487
565
|
});
|
|
566
|
+
for (const warning of warnings)
|
|
567
|
+
log.warning(warning);
|
|
568
|
+
// Local runs resolve native packages from the real tree, so a missing declaration is
|
|
569
|
+
// invisible until deploy. Reporting it here is the only signal before then.
|
|
570
|
+
const findings = findUndeclaredNativePackages({
|
|
571
|
+
metafile,
|
|
572
|
+
declared: externalPackages ?? [],
|
|
573
|
+
projectDir: dirname(source),
|
|
574
|
+
});
|
|
575
|
+
const signature = findings.map((f) => f.name).join(",");
|
|
576
|
+
if (reportedFindings.get(source) !== signature) {
|
|
577
|
+
reportedFindings.set(source, signature);
|
|
578
|
+
for (const finding of findings) {
|
|
579
|
+
log.warning(describeNativeFinding(label ?? basename(source), finding));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
488
582
|
mkdirSync(bundleDir, { recursive: true });
|
|
489
583
|
// bundleEntry emits a single `index.mjs` (no source map). The `.mjs` extension makes Node
|
|
490
584
|
// load it as ESM directly, so no `package.json` `"type": "module"` marker is needed.
|
package/dist/commands/env.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { NEON_ENV_VAR_KEYS } from "@neon/env";
|
|
3
2
|
import chalk from "chalk";
|
|
3
|
+
import { NEON_ENV_VAR_KEYS } from "../_shared/env-core/env.js";
|
|
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":
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { describeNativeFinding, findUndeclaredNativePackages, } from "@neon/config-runtime";
|
|
3
4
|
import { isNeonApiError, retryOnLock } from "../api.js";
|
|
4
5
|
import { createDeployment, deleteFunction, getFunction, listFunctions, } from "../functions_api.js";
|
|
5
6
|
import { log } from "../log.js";
|
|
@@ -194,7 +195,20 @@ const deploy = async (props) => {
|
|
|
194
195
|
throw new Error(`No entry file found in ${src}. Expected one of: ${ENTRY_CANDIDATES.join(", ")}.`);
|
|
195
196
|
}
|
|
196
197
|
// Bundle before any network round-trip so a bundling failure fails fast.
|
|
197
|
-
const
|
|
198
|
+
const bundled = await bundleEntry(source);
|
|
199
|
+
for (const warning of bundled.warnings)
|
|
200
|
+
log.warning(warning);
|
|
201
|
+
// `--src` bypasses `neon.ts`, so there is no policy to declare anything in and no way to
|
|
202
|
+
// stage files here. The advisory still runs: this is the shortest path to a function that
|
|
203
|
+
// deploys clean and then fails at invoke, so it is the last place to stay silent.
|
|
204
|
+
for (const finding of findUndeclaredNativePackages({
|
|
205
|
+
metafile: bundled.metafile,
|
|
206
|
+
declared: [],
|
|
207
|
+
projectDir: dirname(source),
|
|
208
|
+
})) {
|
|
209
|
+
log.warning(describeNativeFinding(props.slug, finding));
|
|
210
|
+
}
|
|
211
|
+
const zip = zipBundle(bundled.files);
|
|
198
212
|
const branchId = await branchIdFromProps(props);
|
|
199
213
|
// Snapshot the current version before deploy so we can detect the new one
|
|
200
214
|
// afterward. A missing function (404) or no deployment yet → undefined.
|
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
|
]));
|