neonctl 2.23.1 → 2.24.1
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 +109 -18
- package/commands/auth.js +12 -0
- package/commands/branches.js +14 -6
- package/commands/bucket.js +368 -0
- package/commands/checkout.js +49 -61
- package/commands/config.js +249 -0
- package/commands/deploy.js +25 -0
- package/commands/dev.js +743 -0
- package/commands/env.js +121 -0
- package/commands/index.js +10 -0
- package/commands/link.js +76 -12
- package/config_format.js +66 -0
- package/dev/env.js +199 -0
- package/dev/functions.js +72 -0
- package/dev/inputs.js +63 -0
- package/dev/runtime.js +146 -0
- package/env_file.js +146 -0
- package/index.js +2 -0
- package/package.json +7 -2
- package/pkg.js +23 -1
- package/psql/command/cmd_format.js +12 -12
- package/psql/command/cmd_io.js +2 -2
- package/psql/command/cmd_restrict.js +8 -8
- package/psql/describe/formatters.js +1 -1
- package/psql/print/aligned.js +30 -30
- package/psql/print/json.js +5 -5
- package/psql/scanner/sql.js +4 -4
- package/storage_api.js +114 -0
- package/utils/branch_picker.js +88 -0
- package/utils/esbuild.js +8 -5
- package/utils/zip.js +1 -1
package/commands/env.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
|
|
3
|
+
import { log } from '../log.js';
|
|
4
|
+
import { resolveNeonEnvVars } from '../dev/env.js';
|
|
5
|
+
import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
|
|
6
|
+
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
7
|
+
export const command = 'env';
|
|
8
|
+
export const describe = "Manage a branch's Neon env variables locally";
|
|
9
|
+
/**
|
|
10
|
+
* Shown (to stderr) when `link` / `checkout` skip the bundled env pull because the user passed
|
|
11
|
+
* `--no-env-pull`. Names the two ways to get the branch's vars without an on-disk file written
|
|
12
|
+
* eagerly: an explicit `neonctl env pull`, or runtime injection via `neon-env run`.
|
|
13
|
+
*/
|
|
14
|
+
export const ENV_PULL_SKIPPED_HINT = 'Skipped env pull (--no-env-pull). Run `neonctl env pull` to write this branch’s env vars ' +
|
|
15
|
+
'(DATABASE_URL, …) into a local .env, or inject them at runtime with `neon-env run -- <your dev command>`.';
|
|
16
|
+
export const builder = (argv) => argv
|
|
17
|
+
.usage('$0 env <sub-command> [options]')
|
|
18
|
+
.options({
|
|
19
|
+
'project-id': { describe: 'Project ID', type: 'string' },
|
|
20
|
+
branch: { describe: 'Branch ID or name', type: 'string' },
|
|
21
|
+
})
|
|
22
|
+
.middleware(fillSingleProject)
|
|
23
|
+
.command('pull', "Write the branch's Neon env variables to a local .env file", (yargs) => yargs
|
|
24
|
+
.usage('$0 env pull [options]')
|
|
25
|
+
.options({
|
|
26
|
+
file: {
|
|
27
|
+
describe: 'Target .env file to write. Defaults to an existing .env, ' +
|
|
28
|
+
'otherwise .env.local. Only Neon variables are updated; other ' +
|
|
29
|
+
'lines are preserved.',
|
|
30
|
+
type: 'string',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
.example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
|
|
34
|
+
.example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), async (args) => {
|
|
35
|
+
await pull(args);
|
|
36
|
+
})
|
|
37
|
+
.demandCommand(1);
|
|
38
|
+
export const handler = (args) => args;
|
|
39
|
+
/** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
|
|
40
|
+
const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Object.values(group));
|
|
41
|
+
export const pull = async (props) => {
|
|
42
|
+
const cwd = props.cwd ?? process.cwd();
|
|
43
|
+
const branchId = await branchIdFromProps(props);
|
|
44
|
+
// Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
|
|
45
|
+
// pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
|
|
46
|
+
// `env pull` is an explicit action, so it should error rather than write nothing.
|
|
47
|
+
const vars = await resolveNeonEnvVars({
|
|
48
|
+
cwd,
|
|
49
|
+
projectId: props.projectId,
|
|
50
|
+
branchId,
|
|
51
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
52
|
+
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
53
|
+
});
|
|
54
|
+
const neonVars = pickNeonVars(vars);
|
|
55
|
+
if (Object.keys(neonVars).length === 0) {
|
|
56
|
+
log.info('No Neon env variables to pull for this branch (no DATABASE_URL or ' +
|
|
57
|
+
'enabled Auth / Data API).');
|
|
58
|
+
return { status: 'empty' };
|
|
59
|
+
}
|
|
60
|
+
const targetPath = resolveEnvFilePath(cwd, props.file);
|
|
61
|
+
const { written } = mergeEnvFile(targetPath, neonVars);
|
|
62
|
+
log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
|
|
63
|
+
return { status: 'written', written, file: targetPath };
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Pull a freshly-pinned branch's Neon env vars into a local `.env`, bundled into `link` and
|
|
67
|
+
* `checkout` so the branch-first loop is just *link + checkout* — `env pull` runs for you.
|
|
68
|
+
*
|
|
69
|
+
* On by default; `--no-env-pull` opts out (e.g. when env is injected at runtime via
|
|
70
|
+
* `neon-env run` / `neon dev`, or to keep secrets out of the working tree). The pin is the
|
|
71
|
+
* command's primary effect and has already succeeded by the time this runs, so a pull failure
|
|
72
|
+
* degrades to a warning rather than failing the command. Returns what happened so
|
|
73
|
+
* `link --agent` can fold an accurate note into its JSON message.
|
|
74
|
+
*/
|
|
75
|
+
export const autoPullEnvAfterPin = async (props) => {
|
|
76
|
+
if (!props.envPull) {
|
|
77
|
+
log.info(chalk.dim(ENV_PULL_SKIPPED_HINT));
|
|
78
|
+
return { status: 'skipped' };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await pull(props);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
log.warning('Branch pinned, but pulling its Neon env vars failed: %s\n' +
|
|
86
|
+
'Run `neonctl env pull` once resolved (e.g. `neonctl deploy` if a declared service ' +
|
|
87
|
+
'is missing), or inject them at runtime with `neon-env run -- <your dev command>`.', message);
|
|
88
|
+
return { status: 'failed', message };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Render the one-line env-pull note appended to `link --agent`'s JSON `message`, so an agent
|
|
93
|
+
* reading the structured output knows whether its branch env is already on disk.
|
|
94
|
+
*/
|
|
95
|
+
export const renderAgentPullNote = (result) => {
|
|
96
|
+
switch (result.status) {
|
|
97
|
+
case 'written':
|
|
98
|
+
return ` Pulled ${result.written.length} Neon env var${result.written.length === 1 ? '' : 's'} into ${result.file}.`;
|
|
99
|
+
case 'empty':
|
|
100
|
+
return ' No Neon env vars to pull for this branch yet.';
|
|
101
|
+
case 'skipped':
|
|
102
|
+
return (' Skipped env pull (--no-env-pull); run `neonctl env pull` later, ' +
|
|
103
|
+
'or inject env at runtime with `neon-env run -- <your dev command>`.');
|
|
104
|
+
case 'failed':
|
|
105
|
+
return ` Could not pull env vars (${result.message}); run \`neonctl env pull\` once resolved.`;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Keep only the recognized Neon variables from the resolved set, so a stray inherited
|
|
110
|
+
* value never lands in the user's `.env` file. (Today `resolveNeonEnvVars` only emits Neon
|
|
111
|
+
* vars, but filtering keeps the contract explicit and future-proof.)
|
|
112
|
+
*/
|
|
113
|
+
const pickNeonVars = (vars) => {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const name of NEON_VAR_NAMES) {
|
|
116
|
+
const value = vars[name];
|
|
117
|
+
if (value !== undefined)
|
|
118
|
+
out[name] = value;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
};
|
package/commands/index.js
CHANGED
|
@@ -17,6 +17,11 @@ import * as init from './init.js';
|
|
|
17
17
|
import * as dataApi from './data_api.js';
|
|
18
18
|
import * as neonAuth from './neon_auth.js';
|
|
19
19
|
import * as functions from './functions.js';
|
|
20
|
+
import * as dev from './dev.js';
|
|
21
|
+
import * as config from './config.js';
|
|
22
|
+
import * as deploy from './deploy.js';
|
|
23
|
+
import * as env from './env.js';
|
|
24
|
+
import * as bucket from './bucket.js';
|
|
20
25
|
export default [
|
|
21
26
|
auth,
|
|
22
27
|
users,
|
|
@@ -37,4 +42,9 @@ export default [
|
|
|
37
42
|
init,
|
|
38
43
|
dataApi,
|
|
39
44
|
functions,
|
|
45
|
+
dev,
|
|
46
|
+
config,
|
|
47
|
+
deploy,
|
|
48
|
+
env,
|
|
49
|
+
bucket,
|
|
40
50
|
];
|
package/commands/link.js
CHANGED
|
@@ -3,6 +3,8 @@ import prompts from 'prompts';
|
|
|
3
3
|
import { applyContext, readContextFile } from '../context.js';
|
|
4
4
|
import { isCi } from '../env.js';
|
|
5
5
|
import { log } from '../log.js';
|
|
6
|
+
import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
|
|
7
|
+
import { autoPullEnvAfterPin, renderAgentPullNote } from './env.js';
|
|
6
8
|
import { REGIONS } from './projects.js';
|
|
7
9
|
const PROJECTS_LIST_LIMIT = 100;
|
|
8
10
|
const CREATE_NEW_SENTINEL = '__create_new__';
|
|
@@ -40,6 +42,13 @@ export const builder = (argv) => argv.usage('$0 link [options]').options({
|
|
|
40
42
|
type: 'boolean',
|
|
41
43
|
default: false,
|
|
42
44
|
},
|
|
45
|
+
'env-pull': {
|
|
46
|
+
describe: "Pull the linked branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
|
|
47
|
+
'linking. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
|
|
48
|
+
'runtime with `neon-env run` / `neon dev`).',
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
default: true,
|
|
51
|
+
},
|
|
43
52
|
});
|
|
44
53
|
export const handler = async (props) => {
|
|
45
54
|
if (props.agent) {
|
|
@@ -134,7 +143,7 @@ const runNonInteractive = async (props, inputs) => {
|
|
|
134
143
|
projectId: inputs.projectId,
|
|
135
144
|
branchId,
|
|
136
145
|
});
|
|
137
|
-
|
|
146
|
+
await finalizeHumanLink(props, {
|
|
138
147
|
contextFile: props.contextFile,
|
|
139
148
|
orgId,
|
|
140
149
|
projectId: inputs.projectId,
|
|
@@ -153,7 +162,7 @@ const runNonInteractive = async (props, inputs) => {
|
|
|
153
162
|
projectId: created.project.id,
|
|
154
163
|
branchId: created.branchId,
|
|
155
164
|
});
|
|
156
|
-
|
|
165
|
+
await finalizeHumanLink(props, {
|
|
157
166
|
contextFile: props.contextFile,
|
|
158
167
|
orgId,
|
|
159
168
|
projectId: created.project.id,
|
|
@@ -187,13 +196,13 @@ const runInteractive = async (props, inputs) => {
|
|
|
187
196
|
orgId = await promptOrgFromList(orgResolution.orgs);
|
|
188
197
|
}
|
|
189
198
|
if (inputs.projectId) {
|
|
190
|
-
const branchId = await
|
|
199
|
+
const branchId = await resolveInteractiveBranchId(props, inputs.projectId);
|
|
191
200
|
applyContext(props.contextFile, {
|
|
192
201
|
orgId,
|
|
193
202
|
projectId: inputs.projectId,
|
|
194
203
|
branchId,
|
|
195
204
|
});
|
|
196
|
-
|
|
205
|
+
await finalizeHumanLink(props, {
|
|
197
206
|
contextFile: props.contextFile,
|
|
198
207
|
orgId,
|
|
199
208
|
projectId: inputs.projectId,
|
|
@@ -213,7 +222,7 @@ const runInteractive = async (props, inputs) => {
|
|
|
213
222
|
projectId: created.project.id,
|
|
214
223
|
branchId: created.branchId,
|
|
215
224
|
});
|
|
216
|
-
|
|
225
|
+
await finalizeHumanLink(props, {
|
|
217
226
|
contextFile: props.contextFile,
|
|
218
227
|
orgId,
|
|
219
228
|
projectId: created.project.id,
|
|
@@ -228,13 +237,13 @@ const runInteractive = async (props, inputs) => {
|
|
|
228
237
|
const projects = await listAllProjects(props, orgId);
|
|
229
238
|
const action = await promptProjectChoice(projects, inputs.projectName);
|
|
230
239
|
if (action.type === 'existing') {
|
|
231
|
-
const branchId = await
|
|
240
|
+
const branchId = await resolveInteractiveBranchId(props, action.projectId);
|
|
232
241
|
applyContext(props.contextFile, {
|
|
233
242
|
orgId,
|
|
234
243
|
projectId: action.projectId,
|
|
235
244
|
branchId,
|
|
236
245
|
});
|
|
237
|
-
|
|
246
|
+
await finalizeHumanLink(props, {
|
|
238
247
|
contextFile: props.contextFile,
|
|
239
248
|
orgId,
|
|
240
249
|
projectId: action.projectId,
|
|
@@ -257,7 +266,7 @@ const runInteractive = async (props, inputs) => {
|
|
|
257
266
|
projectId: created.project.id,
|
|
258
267
|
branchId: created.branchId,
|
|
259
268
|
});
|
|
260
|
-
|
|
269
|
+
await finalizeHumanLink(props, {
|
|
261
270
|
contextFile: props.contextFile,
|
|
262
271
|
orgId,
|
|
263
272
|
projectId: created.project.id,
|
|
@@ -303,19 +312,21 @@ const promptOrgFromList = async (orgs) => {
|
|
|
303
312
|
};
|
|
304
313
|
const promptProjectChoice = async (projects, suggestedName) => {
|
|
305
314
|
const choices = [
|
|
315
|
+
{ title: '+ Create new project…', value: CREATE_NEW_SENTINEL },
|
|
306
316
|
...projects.map((project) => ({
|
|
307
317
|
title: `${project.name} (${project.id})`,
|
|
308
318
|
value: project.id,
|
|
309
319
|
})),
|
|
310
|
-
{ title: '+ Create new project', value: CREATE_NEW_SENTINEL },
|
|
311
320
|
];
|
|
321
|
+
// Create sits at the top, so default to the first existing project (index 1) when there
|
|
322
|
+
// is one; with no projects to show, the create option (index 0) is the only choice.
|
|
312
323
|
const { selection } = await prompts({
|
|
313
324
|
onState: onPromptState,
|
|
314
325
|
type: 'select',
|
|
315
326
|
name: 'selection',
|
|
316
327
|
message: 'Which project would you like to link?',
|
|
317
328
|
choices,
|
|
318
|
-
initial:
|
|
329
|
+
initial: projects.length > 0 ? 1 : 0,
|
|
319
330
|
});
|
|
320
331
|
if (selection === CREATE_NEW_SENTINEL) {
|
|
321
332
|
return { type: 'create', suggestedName };
|
|
@@ -380,12 +391,18 @@ const runAgent = async (props, inputs) => {
|
|
|
380
391
|
if (projectId) {
|
|
381
392
|
const branchId = await resolveDefaultBranchId(props, projectId);
|
|
382
393
|
applyContext(props.contextFile, { orgId, projectId, branchId });
|
|
394
|
+
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
395
|
+
...props,
|
|
396
|
+
projectId,
|
|
397
|
+
branch: branchId,
|
|
398
|
+
envPull: props.envPull,
|
|
399
|
+
}));
|
|
383
400
|
emitAgent({
|
|
384
401
|
status: 'linked',
|
|
385
402
|
context_file: props.contextFile,
|
|
386
403
|
context: { orgId, projectId, branchId },
|
|
387
404
|
project: { id: projectId },
|
|
388
|
-
message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}
|
|
405
|
+
message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}.${pullNote}`,
|
|
389
406
|
});
|
|
390
407
|
return;
|
|
391
408
|
}
|
|
@@ -414,6 +431,12 @@ const runAgent = async (props, inputs) => {
|
|
|
414
431
|
projectId: created.project.id,
|
|
415
432
|
branchId: created.branchId,
|
|
416
433
|
});
|
|
434
|
+
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
435
|
+
...props,
|
|
436
|
+
projectId: created.project.id,
|
|
437
|
+
branch: created.branchId,
|
|
438
|
+
envPull: props.envPull,
|
|
439
|
+
}));
|
|
417
440
|
emitAgent({
|
|
418
441
|
status: 'linked',
|
|
419
442
|
context_file: props.contextFile,
|
|
@@ -427,7 +450,7 @@ const runAgent = async (props, inputs) => {
|
|
|
427
450
|
name: created.project.name,
|
|
428
451
|
region_id: created.project.region_id,
|
|
429
452
|
},
|
|
430
|
-
message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}
|
|
453
|
+
message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.${pullNote}`,
|
|
431
454
|
});
|
|
432
455
|
return;
|
|
433
456
|
}
|
|
@@ -589,6 +612,33 @@ const resolveDefaultBranchId = async (props, projectId) => {
|
|
|
589
612
|
}
|
|
590
613
|
return branch.id;
|
|
591
614
|
};
|
|
615
|
+
/**
|
|
616
|
+
* Resolve which branch to pin for an interactively-chosen project. When the project has a
|
|
617
|
+
* single branch there is nothing to choose, so we pin it silently. Otherwise we offer the
|
|
618
|
+
* shared branch picker (the same "+ Create a new branch…" + list as `neonctl checkout`),
|
|
619
|
+
* creating the branch when the user opts to. This makes `link` a full org → project →
|
|
620
|
+
* branch flow instead of always pinning the default branch.
|
|
621
|
+
*/
|
|
622
|
+
const resolveInteractiveBranchId = async (props, projectId) => {
|
|
623
|
+
const { data } = await props.apiClient.listProjectBranches({ projectId });
|
|
624
|
+
const branches = data.branches;
|
|
625
|
+
if (branches.length <= 1) {
|
|
626
|
+
const only = branches.find((b) => b.default) ?? branches[0];
|
|
627
|
+
if (!only) {
|
|
628
|
+
throw new Error(`Could not find a default branch for project ${projectId}.`);
|
|
629
|
+
}
|
|
630
|
+
return only.id;
|
|
631
|
+
}
|
|
632
|
+
const picked = await pickBranchInteractively(branches, {
|
|
633
|
+
message: 'Which branch would you like to link?',
|
|
634
|
+
nonInteractiveMessage: 'No branch could be selected without an interactive terminal. ' +
|
|
635
|
+
'Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.',
|
|
636
|
+
});
|
|
637
|
+
if (picked.kind === 'existing') {
|
|
638
|
+
return picked.branchId;
|
|
639
|
+
}
|
|
640
|
+
return createBranch(props.apiClient, projectId, picked.name, branches);
|
|
641
|
+
};
|
|
592
642
|
const fetchRegions = async (props) => {
|
|
593
643
|
try {
|
|
594
644
|
const { data } = await props.apiClient.getActiveRegions();
|
|
@@ -646,6 +696,20 @@ const printHumanSummary = (_props, summary) => {
|
|
|
646
696
|
lines.push('');
|
|
647
697
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
648
698
|
};
|
|
699
|
+
/**
|
|
700
|
+
* Print the link summary, then run the bundled `env pull` so a human `link` ends with the
|
|
701
|
+
* branch's connection string already on disk — the branch-first loop is just link + checkout.
|
|
702
|
+
* `--no-env-pull` opts out (env pull's own status / skip hint is logged to stderr).
|
|
703
|
+
*/
|
|
704
|
+
const finalizeHumanLink = async (props, summary) => {
|
|
705
|
+
printHumanSummary(props, summary);
|
|
706
|
+
await autoPullEnvAfterPin({
|
|
707
|
+
...props,
|
|
708
|
+
projectId: summary.projectId,
|
|
709
|
+
branch: summary.branchId,
|
|
710
|
+
envPull: props.envPull,
|
|
711
|
+
});
|
|
712
|
+
};
|
|
649
713
|
const onPromptState = (state) => {
|
|
650
714
|
if (state.aborted) {
|
|
651
715
|
process.stdout.write('\x1B[?25h');
|
package/config_format.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a TTL in whole seconds back to the canonical `neon.ts` duration string (e.g.
|
|
3
|
+
* `604800` -> `"7d"`), falling back to seconds when no clean unit boundary matches. Mirrors
|
|
4
|
+
* the formatter `@neondatabase/config` uses when it emits a TTL, so `config status` shows
|
|
5
|
+
* the same value a user would write in `neon.ts`.
|
|
6
|
+
*/
|
|
7
|
+
export const formatDurationSeconds = (totalSeconds) => {
|
|
8
|
+
const units = [
|
|
9
|
+
['w', 7 * 24 * 60 * 60],
|
|
10
|
+
['d', 24 * 60 * 60],
|
|
11
|
+
['h', 60 * 60],
|
|
12
|
+
['m', 60],
|
|
13
|
+
];
|
|
14
|
+
for (const [unit, perUnit] of units) {
|
|
15
|
+
if (totalSeconds % perUnit === 0)
|
|
16
|
+
return `${totalSeconds / perUnit}${unit}`;
|
|
17
|
+
}
|
|
18
|
+
return `${totalSeconds}s`;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Project a resolved branch config (plus the separately-pulled preview state, since
|
|
22
|
+
* functions/buckets don't live on the closure) into a {@link NeonConfigView}.
|
|
23
|
+
*
|
|
24
|
+
* - Service toggles are surfaced as `true` only when enabled (disabled is the absence of
|
|
25
|
+
* the key, exactly as in `neon.ts`).
|
|
26
|
+
* - `ttlSeconds` is rendered back to a duration string (`7d`).
|
|
27
|
+
* - The `branch` section is the JSON-able part of what would otherwise be the `branch`
|
|
28
|
+
* closure: `parent` / `ttl` / `protected` / `postgres.computeSettings`.
|
|
29
|
+
* - `branch` and `preview` are omitted entirely when they would be empty.
|
|
30
|
+
*/
|
|
31
|
+
export const toNeonConfigView = (resolved, preview) => {
|
|
32
|
+
const view = {};
|
|
33
|
+
if (resolved.authEnabled)
|
|
34
|
+
view.auth = true;
|
|
35
|
+
if (resolved.dataApiEnabled)
|
|
36
|
+
view.dataApi = true;
|
|
37
|
+
const previewView = toPreviewView(preview);
|
|
38
|
+
if (previewView)
|
|
39
|
+
view.preview = previewView;
|
|
40
|
+
const branch = {};
|
|
41
|
+
if (resolved.parent !== undefined)
|
|
42
|
+
branch.parent = resolved.parent;
|
|
43
|
+
if (resolved.ttlSeconds !== undefined)
|
|
44
|
+
branch.ttl = formatDurationSeconds(resolved.ttlSeconds);
|
|
45
|
+
if (resolved.protected !== undefined)
|
|
46
|
+
branch.protected = resolved.protected;
|
|
47
|
+
if (resolved.postgres?.computeSettings)
|
|
48
|
+
branch.postgres = resolved.postgres;
|
|
49
|
+
if (Object.keys(branch).length > 0)
|
|
50
|
+
view.branch = branch;
|
|
51
|
+
return view;
|
|
52
|
+
};
|
|
53
|
+
const toPreviewView = (preview) => {
|
|
54
|
+
if (!preview)
|
|
55
|
+
return undefined;
|
|
56
|
+
const out = {};
|
|
57
|
+
if (preview.aiGatewayEnabled)
|
|
58
|
+
out.aiGateway = true;
|
|
59
|
+
if (preview.functions.length > 0) {
|
|
60
|
+
out.functions = Object.fromEntries(preview.functions.map((fn) => [fn.slug, { name: fn.name }]));
|
|
61
|
+
}
|
|
62
|
+
if (preview.buckets.length > 0) {
|
|
63
|
+
out.buckets = Object.fromEntries(preview.buckets.map((b) => [b.name, { access: b.access }]));
|
|
64
|
+
}
|
|
65
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
66
|
+
};
|
package/dev/env.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { loadConfigFromFile, } from '@neondatabase/config';
|
|
2
|
+
import { plan, pullConfig, } from '@neondatabase/config-runtime';
|
|
3
|
+
import { fetchEnv, toEntries } from '@neondatabase/env';
|
|
4
|
+
import { log } from '../log.js';
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when a `neon.ts` policy declares a branch-level resource (Neon Auth,
|
|
7
|
+
* Data API, a bucket, the AI Gateway) that the linked remote branch does not
|
|
8
|
+
* have yet. Unlike every other failure in {@link resolveDevEnv} — which degrades
|
|
9
|
+
* to "run without injection" — this is a hard stop: the user's intent (a policy)
|
|
10
|
+
* cannot be honored, and silently dropping the secret would be more confusing
|
|
11
|
+
* than refusing to start. The fix is to provision the resource first.
|
|
12
|
+
*/
|
|
13
|
+
export class DevEnvMismatchError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.name = 'DevEnvMismatchError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Signals that no project/branch context could be resolved, so there is nothing to
|
|
21
|
+
* resolve env from. `resolveDevEnv` degrades on this (dev runs without injection);
|
|
22
|
+
* `env pull` surfaces it (an explicit pull needs a branch).
|
|
23
|
+
*/
|
|
24
|
+
export class MissingBranchContextError extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super(...arguments);
|
|
27
|
+
this.name = 'MissingBranchContextError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the branch's Neon env vars (pooled / direct `DATABASE_URL`, plus Auth /
|
|
32
|
+
* Data API when enabled) into a `{ KEY: value }` map. Shared by `neon dev` (which
|
|
33
|
+
* injects them) and `neon env pull` (which writes them to a `.env` file).
|
|
34
|
+
*
|
|
35
|
+
* Tiered:
|
|
36
|
+
*
|
|
37
|
+
* 1. a `neon.ts` policy is found -> the policy is the source of truth. We first
|
|
38
|
+
* check it against the branch's live state (`plan`); if it declares a resource
|
|
39
|
+
* the branch is missing, we stop with a {@link DevEnvMismatchError} pointing at
|
|
40
|
+
* `neonctl deploy`. Otherwise `fetchEnv` evaluates the policy.
|
|
41
|
+
* 2. no `neon.ts`, but a project + branch are known -> `pullConfig` reads the
|
|
42
|
+
* branch's live state (incl. Auth / Data API enablement) into a config, then
|
|
43
|
+
* `fetchEnv` resolves what is actually enabled.
|
|
44
|
+
* 3. otherwise -> throw {@link MissingBranchContextError}.
|
|
45
|
+
*
|
|
46
|
+
* Unlike {@link resolveDevEnv}, this never swallows errors — callers decide how to
|
|
47
|
+
* handle them.
|
|
48
|
+
*/
|
|
49
|
+
export const resolveNeonEnvVars = async (ctx) => {
|
|
50
|
+
const config = await loadNeonConfig(ctx.cwd);
|
|
51
|
+
if (config) {
|
|
52
|
+
if (!ctx.projectId || !ctx.branchId) {
|
|
53
|
+
throw new MissingBranchContextError('Found a neon.ts but could not resolve the project/branch. ' +
|
|
54
|
+
'Run `neonctl link` and `neonctl checkout <branch>`, or pass ' +
|
|
55
|
+
'--project-id / --branch.');
|
|
56
|
+
}
|
|
57
|
+
// Resolve env from the policy with its `preview.functions` removed. Functions carry no
|
|
58
|
+
// branch-level secrets — their env comes from the local `neon.ts` `functions.<slug>.env`,
|
|
59
|
+
// layered per-function by the dev server — so env resolution never needs the functions
|
|
60
|
+
// API. Probing it (via `plan`/`fetchEnv`) only adds a failure mode: an undeployed
|
|
61
|
+
// function, or a project where the Functions Preview isn't enabled, would error and sink
|
|
62
|
+
// ALL injection (including DATABASE_URL). Stripping functions keeps env resolution honest
|
|
63
|
+
// while leaving buckets / AI Gateway / Auth / Data API fully checked — those DO carry
|
|
64
|
+
// secrets, so a declared-but-missing one still hard-stops (see assertPolicyMatchesBranch).
|
|
65
|
+
const envConfig = withoutPreviewFunctions(config);
|
|
66
|
+
await assertPolicyMatchesBranch(envConfig, ctx);
|
|
67
|
+
return await fetchAndProject(envConfig, ctx);
|
|
68
|
+
}
|
|
69
|
+
if (ctx.projectId && ctx.branchId) {
|
|
70
|
+
const pulled = await pullConfig({
|
|
71
|
+
projectId: ctx.projectId,
|
|
72
|
+
branchId: ctx.branchId,
|
|
73
|
+
...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
|
|
74
|
+
...(ctx.api ? { api: ctx.api } : {}),
|
|
75
|
+
});
|
|
76
|
+
// `pulled.config` is already a `Config` (static auth/dataApi toggles + a branch
|
|
77
|
+
// tuning closure), so it feeds straight into fetchEnv — no wrapping needed.
|
|
78
|
+
return await fetchAndProject(pulled.config, ctx);
|
|
79
|
+
}
|
|
80
|
+
throw new MissingBranchContextError('No project/branch context found. Link a branch (`neonctl link` / ' +
|
|
81
|
+
'`neonctl checkout`) or pass --project-id and --branch.');
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
|
|
85
|
+
*
|
|
86
|
+
* - Success → `{ vars }` (possibly just the always-present Postgres URLs).
|
|
87
|
+
* - No linked branch / project → `{ vars: {}, skipped }` with a "link a branch" hint; the
|
|
88
|
+
* function still runs locally, just without Neon env.
|
|
89
|
+
* - Any other failure (offline, transient API error) → `{ vars: {}, skipped }` naming the
|
|
90
|
+
* cause; again non-fatal.
|
|
91
|
+
* - {@link DevEnvMismatchError} (policy declares a secret-bearing service the branch lacks)
|
|
92
|
+
* is the one hard stop and is re-thrown for the caller to surface.
|
|
93
|
+
*/
|
|
94
|
+
export const resolveDevEnv = async (ctx) => {
|
|
95
|
+
try {
|
|
96
|
+
return { vars: await resolveNeonEnvVars(ctx) };
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof DevEnvMismatchError)
|
|
100
|
+
throw err;
|
|
101
|
+
if (err instanceof MissingBranchContextError) {
|
|
102
|
+
log.debug('dev: %s; skipping env injection', err.message);
|
|
103
|
+
return {
|
|
104
|
+
vars: {},
|
|
105
|
+
skipped: {
|
|
106
|
+
reason: 'no linked Neon branch — run `neonctl link`, then ' +
|
|
107
|
+
'`neonctl checkout <branch>`, to inject DATABASE_URL and friends',
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
112
|
+
log.debug('dev: env resolution failed: %s', detail);
|
|
113
|
+
return {
|
|
114
|
+
vars: {},
|
|
115
|
+
skipped: {
|
|
116
|
+
reason: `could not reach Neon (${detail}); running without Neon env`,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Return the policy with its `preview.functions` removed, so the env path never enumerates
|
|
123
|
+
* functions against the Neon API. Functions are local-source-bundled and produce no
|
|
124
|
+
* branch-level secrets, so they are irrelevant to env resolution; probing them only risks
|
|
125
|
+
* failing the whole resolve (undeployed function, or Functions Preview disabled on the
|
|
126
|
+
* project). Buckets / AI Gateway and the top-level Auth / Data API toggles are preserved —
|
|
127
|
+
* they DO carry env, so they must still be checked and resolved. Returns the config
|
|
128
|
+
* unchanged when it declares no functions.
|
|
129
|
+
*/
|
|
130
|
+
const withoutPreviewFunctions = (config) => {
|
|
131
|
+
const preview = config.preview;
|
|
132
|
+
if (!preview?.functions)
|
|
133
|
+
return config;
|
|
134
|
+
const previewWithoutFunctions = { ...preview };
|
|
135
|
+
delete previewWithoutFunctions.functions;
|
|
136
|
+
return { ...config, preview: previewWithoutFunctions };
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Tier-1 guard. Dry-run the policy against the branch's live state and stop if
|
|
140
|
+
* it declares a branch-level resource the branch is missing. Built on `plan` so
|
|
141
|
+
* it covers every present and future provisionable resource for free: any
|
|
142
|
+
* `create` action is a resource `neonctl deploy` would provision.
|
|
143
|
+
*
|
|
144
|
+
* Called with functions already stripped (see {@link withoutPreviewFunctions}), so the
|
|
145
|
+
* `plan` probe never enumerates the functions API — an undeployed function, or a project
|
|
146
|
+
* without the Functions Preview, must never block local dev or sink env injection.
|
|
147
|
+
*/
|
|
148
|
+
const assertPolicyMatchesBranch = async (config, ctx) => {
|
|
149
|
+
const result = await plan(config, {
|
|
150
|
+
projectId: ctx.projectId,
|
|
151
|
+
branchId: ctx.branchId,
|
|
152
|
+
...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
|
|
153
|
+
...(ctx.api ? { api: ctx.api } : {}),
|
|
154
|
+
});
|
|
155
|
+
const missing = result.applied.filter(isMissingResource);
|
|
156
|
+
if (missing.length === 0)
|
|
157
|
+
return;
|
|
158
|
+
const names = missing.map((change) => change.identifier).join(', ');
|
|
159
|
+
throw new DevEnvMismatchError(`Your neon.ts declares ${names} for branch ${ctx.branchId}, but the branch ` +
|
|
160
|
+
'does not have it yet, so the matching env vars cannot be injected. ' +
|
|
161
|
+
'Provision it first with `neonctl deploy` (or `neonctl config apply`), ' +
|
|
162
|
+
'then re-run `neonctl dev`.');
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* A planned change that provisions a branch-level resource the branch lacks: a
|
|
166
|
+
* `create` on a service (Neon Auth, Data API, a bucket, the AI Gateway). Branch
|
|
167
|
+
* setting drift (`update`) and `noop`s are ignored — they don't block local dev
|
|
168
|
+
* — and functions are excluded (see {@link assertPolicyMatchesBranch}).
|
|
169
|
+
*/
|
|
170
|
+
const isMissingResource = (change) => change.kind === 'service' &&
|
|
171
|
+
change.action === 'create' &&
|
|
172
|
+
!change.identifier.startsWith('function:');
|
|
173
|
+
const fetchAndProject = async (config, ctx) => {
|
|
174
|
+
const env = await fetchEnv(config, {
|
|
175
|
+
projectId: ctx.projectId,
|
|
176
|
+
branchId: ctx.branchId,
|
|
177
|
+
...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
|
|
178
|
+
...(ctx.api ? { api: ctx.api } : {}),
|
|
179
|
+
});
|
|
180
|
+
return toEntries(env);
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Load a `neon.ts` policy if one exists on the path from `cwd` up to the repo
|
|
184
|
+
* root. Returns `null` when there is none (the common "no config" case), and
|
|
185
|
+
* surfaces real load errors (e.g. a syntax error in an existing file).
|
|
186
|
+
*/
|
|
187
|
+
const loadNeonConfig = async (cwd) => {
|
|
188
|
+
try {
|
|
189
|
+
const { config } = await loadConfigFromFile({ cwd });
|
|
190
|
+
return config;
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
194
|
+
if (/Could not find a Neon config file/i.test(message)) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
};
|