neonctl 2.24.0 → 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 +51 -19
- package/commands/branches.js +14 -6
- package/commands/bucket.js +368 -0
- package/commands/checkout.js +24 -81
- package/commands/dev.js +48 -14
- package/commands/env.js +56 -2
- package/commands/index.js +2 -0
- package/commands/link.js +72 -10
- package/dev/env.js +24 -8
- package/package.json +1 -1
- package/pkg.js +23 -1
- 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/dev.js
CHANGED
|
@@ -51,7 +51,7 @@ const runSingleSource = async (props) => {
|
|
|
51
51
|
throw new Error(`Source file not found: ${source}`);
|
|
52
52
|
}
|
|
53
53
|
const branchId = await resolveBranchId(props);
|
|
54
|
-
const neonEnv = await resolveDevEnv({
|
|
54
|
+
const { vars: neonEnv, skipped } = await resolveDevEnv({
|
|
55
55
|
cwd: process.cwd(),
|
|
56
56
|
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
57
57
|
...(branchId ? { branchId } : {}),
|
|
@@ -63,10 +63,13 @@ const runSingleSource = async (props) => {
|
|
|
63
63
|
bundleDir: join(process.cwd(), 'node_modules', '.neon-dev'),
|
|
64
64
|
childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
|
|
65
65
|
label: null,
|
|
66
|
+
envSummary: { neon: Object.keys(neonEnv), fn: [] },
|
|
66
67
|
};
|
|
67
68
|
// No config reload in single-source mode: there's exactly one file to serve, and
|
|
68
69
|
// nothing to add or remove. neon.ts hot-reload is config-mode only.
|
|
69
|
-
await runSupervisor([unit]
|
|
70
|
+
await runSupervisor([unit], {
|
|
71
|
+
...(skipped ? { envNote: skipped.reason } : {}),
|
|
72
|
+
});
|
|
70
73
|
};
|
|
71
74
|
/**
|
|
72
75
|
* Multi-function mode: serve every function declared in neon.ts. Requires a neon.ts
|
|
@@ -85,7 +88,7 @@ const runFromConfig = async (props) => {
|
|
|
85
88
|
throw new Error('neon.ts has no functions to serve. Add at least one under ' +
|
|
86
89
|
'`preview.functions`, or pass --source <path>.');
|
|
87
90
|
}
|
|
88
|
-
const neonEnv = await resolveDevEnv({
|
|
91
|
+
const { vars: neonEnv, skipped } = await resolveDevEnv({
|
|
89
92
|
cwd: process.cwd(),
|
|
90
93
|
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
91
94
|
...(branchId ? { branchId } : {}),
|
|
@@ -102,7 +105,10 @@ const runFromConfig = async (props) => {
|
|
|
102
105
|
return null;
|
|
103
106
|
return planFunctionsToUnits(re.functions, neonEnv, searchBase);
|
|
104
107
|
};
|
|
105
|
-
await runSupervisor(units, {
|
|
108
|
+
await runSupervisor(units, {
|
|
109
|
+
reload: { configPath, replan },
|
|
110
|
+
...(skipped ? { envNote: skipped.reason } : {}),
|
|
111
|
+
});
|
|
106
112
|
};
|
|
107
113
|
/**
|
|
108
114
|
* Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
|
|
@@ -174,6 +180,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
|
|
|
174
180
|
bundleDir: join(process.cwd(), 'node_modules', '.neon-dev', fn.slug),
|
|
175
181
|
childEnv,
|
|
176
182
|
label: fn.slug,
|
|
183
|
+
envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
|
|
177
184
|
// Signature of the function's *own* neon.ts config (NOT the dynamically-chosen search
|
|
178
185
|
// base) so reconcile can tell a real change from a no-op save. A search-mode function
|
|
179
186
|
// re-planned with a different base must hash identically, or it would be needlessly
|
|
@@ -221,7 +228,8 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
|
|
|
221
228
|
* stayed the same. A function whose config (env/port/portless/source) changed is restarted
|
|
222
229
|
* in place; siblings are untouched.
|
|
223
230
|
*/
|
|
224
|
-
const runSupervisor = async (units,
|
|
231
|
+
const runSupervisor = async (units, options = {}) => {
|
|
232
|
+
const { reload, envNote } = options;
|
|
225
233
|
if (hasPortlessUnit(units)) {
|
|
226
234
|
assertPortlessAvailable();
|
|
227
235
|
}
|
|
@@ -310,7 +318,7 @@ const runSupervisor = async (units, reload) => {
|
|
|
310
318
|
await Promise.all(running.map((r) => stopUnit(r)));
|
|
311
319
|
throw new Error('No function started. See the output above for details.');
|
|
312
320
|
}
|
|
313
|
-
printBanner(running);
|
|
321
|
+
printBanner(running, envNote);
|
|
314
322
|
// Config mode only: watch neon.ts and reconcile the live unit set when it changes.
|
|
315
323
|
// Reconciles are serialized: a burst of saves (editor write-then-format) must not run
|
|
316
324
|
// overlapping diffs against the mutating `running` array. A trailing run coalesces the
|
|
@@ -448,7 +456,10 @@ const reconcileOnce = async (running, replan, ops) => {
|
|
|
448
456
|
await Promise.all(added.map((r) => ops.startUnit(r)));
|
|
449
457
|
for (const r of added) {
|
|
450
458
|
if (r.status === 'ready') {
|
|
451
|
-
|
|
459
|
+
const env = formatEnvSummary(r.unit.envSummary);
|
|
460
|
+
logUnit(r.unit, chalk.green('ready') +
|
|
461
|
+
` ${urlFor(r.boundPort)}` +
|
|
462
|
+
(env ? chalk.dim(` ${env}`) : ''));
|
|
452
463
|
}
|
|
453
464
|
}
|
|
454
465
|
}
|
|
@@ -513,15 +524,13 @@ const spawnSyncCheck = (bin) => {
|
|
|
513
524
|
const writeBundle = async (source, bundleDir) => {
|
|
514
525
|
const files = await bundleEntry(source);
|
|
515
526
|
mkdirSync(bundleDir, { recursive: true });
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
// ESM. (A bare `out.mjs` would also work but breaks the `out.js.map` sourcemap link.)
|
|
520
|
-
writeFileSync(join(bundleDir, 'package.json'), '{"type":"module"}\n');
|
|
527
|
+
// bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
|
|
528
|
+
// it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
|
|
529
|
+
// points the sourcemap link at `index.mjs.map` for us.
|
|
521
530
|
for (const [name, contents] of Object.entries(files)) {
|
|
522
531
|
writeFileSync(join(bundleDir, name), contents);
|
|
523
532
|
}
|
|
524
|
-
return join(bundleDir, '
|
|
533
|
+
return join(bundleDir, 'index.mjs');
|
|
525
534
|
};
|
|
526
535
|
const urlFor = (port) => port === null ? chalk.red('not running') : `http://localhost:${port}`;
|
|
527
536
|
const waitForReady = (child) => new Promise((resolveReady) => {
|
|
@@ -567,7 +576,7 @@ const pipeChildOutput = (child, label) => {
|
|
|
567
576
|
forward('stdout');
|
|
568
577
|
forward('stderr');
|
|
569
578
|
};
|
|
570
|
-
const printBanner = (running) => {
|
|
579
|
+
const printBanner = (running, envNote) => {
|
|
571
580
|
log.info('');
|
|
572
581
|
log.info(chalk.green.bold(' Neon Functions dev server'));
|
|
573
582
|
log.info('');
|
|
@@ -575,9 +584,34 @@ const printBanner = (running) => {
|
|
|
575
584
|
const name = r.unit.label ?? 'function';
|
|
576
585
|
const url = urlFor(r.boundPort);
|
|
577
586
|
log.info(` ${chalk.dim(name.padEnd(20))} ${url}`);
|
|
587
|
+
const env = formatEnvSummary(r.unit.envSummary);
|
|
588
|
+
if (env)
|
|
589
|
+
log.info(` ${' '.repeat(20)} ${chalk.dim(env)}`);
|
|
590
|
+
}
|
|
591
|
+
if (envNote) {
|
|
592
|
+
log.info('');
|
|
593
|
+
log.info(` ${chalk.yellow('!')} ${chalk.dim(`Neon env: ${envNote}`)}`);
|
|
578
594
|
}
|
|
579
595
|
log.info('');
|
|
580
596
|
};
|
|
597
|
+
/**
|
|
598
|
+
* Render a unit's injected env into one transparent line for the banner, e.g.
|
|
599
|
+
* `env: DATABASE_URL, DATABASE_URL_UNPOOLED · neon.ts: RESEND_API_KEY`. Var **names** only
|
|
600
|
+
* (never values — they're secrets). Returns `''` when nothing is injected, so the caller can
|
|
601
|
+
* skip the line. Exported for unit testing.
|
|
602
|
+
*/
|
|
603
|
+
export const formatEnvSummary = (summary) => {
|
|
604
|
+
if (!summary)
|
|
605
|
+
return '';
|
|
606
|
+
const parts = [];
|
|
607
|
+
if (summary.neon.length > 0) {
|
|
608
|
+
parts.push(`env: ${[...summary.neon].sort().join(', ')}`);
|
|
609
|
+
}
|
|
610
|
+
if (summary.fn.length > 0) {
|
|
611
|
+
parts.push(`neon.ts: ${[...summary.fn].sort().join(', ')}`);
|
|
612
|
+
}
|
|
613
|
+
return parts.join(' · ');
|
|
614
|
+
};
|
|
581
615
|
const logUnit = (unit, message) => {
|
|
582
616
|
const prefix = unit.label ? chalk.dim(`[${unit.label}] `) : '';
|
|
583
617
|
log.info(`${prefix}${message}`);
|
package/commands/env.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
|
|
2
3
|
import { log } from '../log.js';
|
|
3
4
|
import { resolveNeonEnvVars } from '../dev/env.js';
|
|
@@ -5,6 +6,13 @@ import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
|
|
|
5
6
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
6
7
|
export const command = 'env';
|
|
7
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>`.';
|
|
8
16
|
export const builder = (argv) => argv
|
|
9
17
|
.usage('$0 env <sub-command> [options]')
|
|
10
18
|
.options({
|
|
@@ -23,7 +31,9 @@ export const builder = (argv) => argv
|
|
|
23
31
|
},
|
|
24
32
|
})
|
|
25
33
|
.example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
|
|
26
|
-
.example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), (args) =>
|
|
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
|
+
})
|
|
27
37
|
.demandCommand(1);
|
|
28
38
|
export const handler = (args) => args;
|
|
29
39
|
/** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
|
|
@@ -45,11 +55,55 @@ export const pull = async (props) => {
|
|
|
45
55
|
if (Object.keys(neonVars).length === 0) {
|
|
46
56
|
log.info('No Neon env variables to pull for this branch (no DATABASE_URL or ' +
|
|
47
57
|
'enabled Auth / Data API).');
|
|
48
|
-
return;
|
|
58
|
+
return { status: 'empty' };
|
|
49
59
|
}
|
|
50
60
|
const targetPath = resolveEnvFilePath(cwd, props.file);
|
|
51
61
|
const { written } = mergeEnvFile(targetPath, neonVars);
|
|
52
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
|
+
}
|
|
53
107
|
};
|
|
54
108
|
/**
|
|
55
109
|
* Keep only the recognized Neon variables from the resolved set, so a stray inherited
|
package/commands/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import * as dev from './dev.js';
|
|
|
21
21
|
import * as config from './config.js';
|
|
22
22
|
import * as deploy from './deploy.js';
|
|
23
23
|
import * as env from './env.js';
|
|
24
|
+
import * as bucket from './bucket.js';
|
|
24
25
|
export default [
|
|
25
26
|
auth,
|
|
26
27
|
users,
|
|
@@ -45,4 +46,5 @@ export default [
|
|
|
45
46
|
config,
|
|
46
47
|
deploy,
|
|
47
48
|
env,
|
|
49
|
+
bucket,
|
|
48
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,
|
|
@@ -382,12 +391,18 @@ const runAgent = async (props, inputs) => {
|
|
|
382
391
|
if (projectId) {
|
|
383
392
|
const branchId = await resolveDefaultBranchId(props, projectId);
|
|
384
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
|
+
}));
|
|
385
400
|
emitAgent({
|
|
386
401
|
status: 'linked',
|
|
387
402
|
context_file: props.contextFile,
|
|
388
403
|
context: { orgId, projectId, branchId },
|
|
389
404
|
project: { id: projectId },
|
|
390
|
-
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}`,
|
|
391
406
|
});
|
|
392
407
|
return;
|
|
393
408
|
}
|
|
@@ -416,6 +431,12 @@ const runAgent = async (props, inputs) => {
|
|
|
416
431
|
projectId: created.project.id,
|
|
417
432
|
branchId: created.branchId,
|
|
418
433
|
});
|
|
434
|
+
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
435
|
+
...props,
|
|
436
|
+
projectId: created.project.id,
|
|
437
|
+
branch: created.branchId,
|
|
438
|
+
envPull: props.envPull,
|
|
439
|
+
}));
|
|
419
440
|
emitAgent({
|
|
420
441
|
status: 'linked',
|
|
421
442
|
context_file: props.contextFile,
|
|
@@ -429,7 +450,7 @@ const runAgent = async (props, inputs) => {
|
|
|
429
450
|
name: created.project.name,
|
|
430
451
|
region_id: created.project.region_id,
|
|
431
452
|
},
|
|
432
|
-
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}`,
|
|
433
454
|
});
|
|
434
455
|
return;
|
|
435
456
|
}
|
|
@@ -591,6 +612,33 @@ const resolveDefaultBranchId = async (props, projectId) => {
|
|
|
591
612
|
}
|
|
592
613
|
return branch.id;
|
|
593
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
|
+
};
|
|
594
642
|
const fetchRegions = async (props) => {
|
|
595
643
|
try {
|
|
596
644
|
const { data } = await props.apiClient.getActiveRegions();
|
|
@@ -648,6 +696,20 @@ const printHumanSummary = (_props, summary) => {
|
|
|
648
696
|
lines.push('');
|
|
649
697
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
650
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
|
+
};
|
|
651
713
|
const onPromptState = (state) => {
|
|
652
714
|
if (state.aborted) {
|
|
653
715
|
process.stdout.write('\x1B[?25h');
|
package/dev/env.js
CHANGED
|
@@ -82,24 +82,40 @@ export const resolveNeonEnvVars = async (ctx) => {
|
|
|
82
82
|
};
|
|
83
83
|
/**
|
|
84
84
|
* `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* {
|
|
88
|
-
*
|
|
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.
|
|
89
93
|
*/
|
|
90
94
|
export const resolveDevEnv = async (ctx) => {
|
|
91
95
|
try {
|
|
92
|
-
return await resolveNeonEnvVars(ctx);
|
|
96
|
+
return { vars: await resolveNeonEnvVars(ctx) };
|
|
93
97
|
}
|
|
94
98
|
catch (err) {
|
|
95
99
|
if (err instanceof DevEnvMismatchError)
|
|
96
100
|
throw err;
|
|
97
101
|
if (err instanceof MissingBranchContextError) {
|
|
98
102
|
log.debug('dev: %s; skipping env injection', err.message);
|
|
99
|
-
return {
|
|
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
|
+
};
|
|
100
110
|
}
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
};
|
|
103
119
|
}
|
|
104
120
|
};
|
|
105
121
|
/**
|
package/package.json
CHANGED
package/pkg.js
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Load the CLI's package.json for version metadata. In the built CLI it sits right next to
|
|
6
|
+
* this module (the build copies it into `dist`); when running from source (tests, `tsx`) it
|
|
7
|
+
* does not, so we walk up to the nearest `package.json`. Both layouts resolve to the same
|
|
8
|
+
* file, keeping `pkg.version` correct everywhere without a test-only shim.
|
|
9
|
+
*/
|
|
10
|
+
const loadPkg = () => {
|
|
11
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
for (;;) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir) {
|
|
19
|
+
throw new Error('Could not locate package.json for version detection.');
|
|
20
|
+
}
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export default loadPkg();
|
package/storage_api.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Typed client helpers for the branch object-storage (bucket/object) API.
|
|
2
|
+
//
|
|
3
|
+
// These endpoints are part of the Neon object-storage surface (the "Buckets"
|
|
4
|
+
// tag in the public API). They are not yet exposed as typed methods on the
|
|
5
|
+
// published `@neondatabase/api-client` package, so the request/response types
|
|
6
|
+
// and the thin call helpers live here. They are implemented on top of the
|
|
7
|
+
// api-client's public `request()` method, which means they reuse the exact
|
|
8
|
+
// same authentication, base URL, headers and retry behaviour as every other
|
|
9
|
+
// neonctl command. When the generated client gains these methods, the call
|
|
10
|
+
// sites in `src/commands/bucket.ts` can switch over with no behavioural
|
|
11
|
+
// change.
|
|
12
|
+
const bucketsPath = (projectId, branchId) => `/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/buckets`;
|
|
13
|
+
const bucketPath = (projectId, branchId, bucketName) => `${bucketsPath(projectId, branchId)}/${encodeURIComponent(bucketName)}`;
|
|
14
|
+
/**
|
|
15
|
+
* Create a bucket on a branch.
|
|
16
|
+
*
|
|
17
|
+
* @request POST /projects/{project_id}/branches/{branch_id}/buckets
|
|
18
|
+
*/
|
|
19
|
+
export const createProjectBranchBucket = (apiClient, { projectId, branchId, name, accessLevel, }) => {
|
|
20
|
+
const body = { name };
|
|
21
|
+
// Omit access_level entirely so the server default (`private`) applies.
|
|
22
|
+
if (accessLevel !== undefined) {
|
|
23
|
+
body.access_level = accessLevel;
|
|
24
|
+
}
|
|
25
|
+
return apiClient.request({
|
|
26
|
+
path: bucketsPath(projectId, branchId),
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body,
|
|
29
|
+
format: 'json',
|
|
30
|
+
secure: true,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* List the buckets on a branch.
|
|
35
|
+
*
|
|
36
|
+
* @request GET /projects/{project_id}/branches/{branch_id}/buckets
|
|
37
|
+
*/
|
|
38
|
+
export const listProjectBranchBuckets = (apiClient, { projectId, branchId }) => apiClient.request({
|
|
39
|
+
path: bucketsPath(projectId, branchId),
|
|
40
|
+
method: 'GET',
|
|
41
|
+
format: 'json',
|
|
42
|
+
secure: true,
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Delete a bucket from a branch.
|
|
46
|
+
*
|
|
47
|
+
* @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}
|
|
48
|
+
*/
|
|
49
|
+
export const deleteProjectBranchBucket = (apiClient, { projectId, branchId, bucketName, }) => apiClient.request({
|
|
50
|
+
path: bucketPath(projectId, branchId, bucketName),
|
|
51
|
+
method: 'DELETE',
|
|
52
|
+
secure: true,
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* List objects (and collapsed folders) in a bucket on a branch.
|
|
56
|
+
*
|
|
57
|
+
* @request GET /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects
|
|
58
|
+
*/
|
|
59
|
+
export const listProjectBranchBucketObjects = (apiClient, { projectId, branchId, bucketName, ...query }) => apiClient.request({
|
|
60
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects`,
|
|
61
|
+
method: 'GET',
|
|
62
|
+
query,
|
|
63
|
+
format: 'json',
|
|
64
|
+
secure: true,
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Download an object's raw bytes from a bucket on a branch.
|
|
68
|
+
*
|
|
69
|
+
* The server returns the body as `application/octet-stream` with a
|
|
70
|
+
* `Content-Disposition: attachment` header; the helper requests the body as a
|
|
71
|
+
* stream (`responseType: 'stream'`), so `.data` is a Node `Readable` the caller
|
|
72
|
+
* can pipe straight to disk without buffering the whole object in memory. The
|
|
73
|
+
* response headers are returned alongside so the caller can derive a filename
|
|
74
|
+
* from `Content-Disposition`.
|
|
75
|
+
*
|
|
76
|
+
* The object key may contain `/`; it is percent-encoded into a single path
|
|
77
|
+
* segment so nested keys are routed to the `{object_key}` parameter.
|
|
78
|
+
*
|
|
79
|
+
* @request GET /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}/download
|
|
80
|
+
*/
|
|
81
|
+
export const getProjectBranchBucketObject = (apiClient, { projectId, branchId, bucketName, objectKey, }) => apiClient.request({
|
|
82
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}/download`,
|
|
83
|
+
method: 'GET',
|
|
84
|
+
format: 'stream',
|
|
85
|
+
secure: true,
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Delete an object from a bucket on a branch.
|
|
89
|
+
*
|
|
90
|
+
* The object key may contain `/`; it is percent-encoded into a single path
|
|
91
|
+
* segment so nested keys are routed to the `{object_key}` parameter.
|
|
92
|
+
*
|
|
93
|
+
* @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}
|
|
94
|
+
*/
|
|
95
|
+
export const deleteProjectBranchBucketObject = (apiClient, { projectId, branchId, bucketName, objectKey, }) => apiClient.request({
|
|
96
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}`,
|
|
97
|
+
method: 'DELETE',
|
|
98
|
+
secure: true,
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* Delete every object under a key prefix (folder) in a bucket on a branch.
|
|
102
|
+
*
|
|
103
|
+
* `prefix` must be non-empty and end with `/`; every object on this branch
|
|
104
|
+
* whose key starts with the prefix is soft-deleted in a single call.
|
|
105
|
+
*
|
|
106
|
+
* @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects-by-prefix
|
|
107
|
+
*/
|
|
108
|
+
export const deleteProjectBranchBucketObjectsByPrefix = (apiClient, { projectId, branchId, bucketName, prefix, }) => apiClient.request({
|
|
109
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects-by-prefix`,
|
|
110
|
+
method: 'DELETE',
|
|
111
|
+
query: { prefix },
|
|
112
|
+
format: 'json',
|
|
113
|
+
secure: true,
|
|
114
|
+
});
|