neonctl 2.24.2 → 2.25.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 +126 -41
- package/commands/auth.js +9 -0
- package/commands/bootstrap.js +603 -0
- package/commands/branches.js +6 -4
- package/commands/bucket.js +118 -5
- package/commands/checkout.js +25 -8
- package/commands/config.js +98 -10
- package/commands/deploy.js +2 -1
- package/commands/dev.js +11 -57
- package/commands/env.js +9 -2
- package/commands/functions.js +53 -5
- package/commands/index.js +2 -0
- package/commands/link.js +441 -108
- package/commands/projects.js +2 -2
- package/commands/set_context.js +5 -1
- package/config_format.js +8 -2
- package/context.js +33 -5
- package/dev/env.js +38 -0
- package/dev/functions.js +2 -4
- package/dev/runtime.js +2 -2
- package/index.js +1 -0
- package/package.json +5 -5
- package/storage_api.js +34 -0
- package/utils/bootstrap.js +243 -0
- package/utils/esbuild.js +11 -2
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import prompts from 'prompts';
|
|
6
|
+
import which from 'which';
|
|
7
|
+
import { isCi } from '../env.js';
|
|
8
|
+
import { log } from '../log.js';
|
|
9
|
+
import { FALLBACK_TEMPLATES, fetchFileBytes, fetchSymlinkTarget, fetchTemplates, findTemplate, resolveTemplate, templateIds, } from '../utils/bootstrap.js';
|
|
10
|
+
// The directory positional is optional: omitting it in an interactive terminal
|
|
11
|
+
// prompts for one. In a non-interactive context a missing directory is an error.
|
|
12
|
+
export const command = 'bootstrap [directory]';
|
|
13
|
+
export const describe = 'Scaffold a new project from a Neon starter template';
|
|
14
|
+
export const builder = (argv) => argv
|
|
15
|
+
.usage('$0 bootstrap [directory] [options]')
|
|
16
|
+
.positional('directory', {
|
|
17
|
+
describe: 'Directory to scaffold into. Use "." for the current directory. Omit to be prompted.',
|
|
18
|
+
type: 'string',
|
|
19
|
+
})
|
|
20
|
+
.options({
|
|
21
|
+
template: {
|
|
22
|
+
describe: 'Template to use (skips the interactive picker). Run with --list-templates to see available templates.',
|
|
23
|
+
type: 'string',
|
|
24
|
+
},
|
|
25
|
+
'list-templates': {
|
|
26
|
+
alias: ['list', 'ls'],
|
|
27
|
+
describe: 'List available templates and exit.',
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
force: {
|
|
32
|
+
describe: 'Scaffold into the target directory even if it is not empty (colliding files are overwritten).',
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
default: false,
|
|
35
|
+
},
|
|
36
|
+
agent: {
|
|
37
|
+
describe: 'Emit a JSON state-machine response designed for AI agents instead of prompting. The output is a single JSON object with a discriminated `status` field describing the next step.',
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
default: false,
|
|
40
|
+
},
|
|
41
|
+
default: {
|
|
42
|
+
alias: 'y',
|
|
43
|
+
describe: 'Quick start: scaffold the default template (or --template) and run the usual setup (install dependencies, git init) without prompting. Linking is left to you since it needs a project choice.',
|
|
44
|
+
type: 'boolean',
|
|
45
|
+
default: false,
|
|
46
|
+
},
|
|
47
|
+
install: {
|
|
48
|
+
describe: 'Install dependencies after scaffolding. In interactive mode this is offered as a prompt; use --no-install to skip without being asked.',
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
default: true,
|
|
51
|
+
},
|
|
52
|
+
git: {
|
|
53
|
+
describe: 'Initialize a git repository after scaffolding. In interactive mode this is offered as a prompt; use --no-git to skip without being asked.',
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
default: true,
|
|
56
|
+
},
|
|
57
|
+
link: {
|
|
58
|
+
describe: 'Run `neon link` in the scaffolded directory after installing. In interactive mode this is offered as a prompt; use --no-link to skip without being asked.',
|
|
59
|
+
type: 'boolean',
|
|
60
|
+
default: true,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
.example('$0 bootstrap my-app', 'Create ./my-app from an interactively chosen template')
|
|
64
|
+
.example('$0 bootstrap . --template hono', 'Scaffold the Hono template into the current directory')
|
|
65
|
+
.example('$0 bootstrap my-app --default', 'Quick start: scaffold the default template and run setup without prompting')
|
|
66
|
+
.example('$0 bootstrap my-app --template hono --agent', 'Scaffold without prompting and emit the JSON state machine for AI agents')
|
|
67
|
+
.strict();
|
|
68
|
+
export const handler = async (props) => {
|
|
69
|
+
if (props.listTemplates) {
|
|
70
|
+
const templates = await fetchTemplates();
|
|
71
|
+
for (const t of templates) {
|
|
72
|
+
const services = t.services && t.services.length > 0
|
|
73
|
+
? ` [${t.services.join(' · ')}]`
|
|
74
|
+
: '';
|
|
75
|
+
process.stdout.write(`${t.id} — ${t.description}${services}\n`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (props.agent) {
|
|
80
|
+
await runAgentSafely(props);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const templates = await resolveTemplateList(props);
|
|
84
|
+
// --default is a non-interactive quick start: it fills in the template and
|
|
85
|
+
// directory and runs setup without asking, so it must not fall into the
|
|
86
|
+
// prompt path even on a TTY.
|
|
87
|
+
const interactive = !props.default && Boolean(process.stdout.isTTY) && !isCi();
|
|
88
|
+
const template = await resolveSelectedTemplate(props, interactive, templates);
|
|
89
|
+
const targetDir = await resolveTargetDir(props, interactive, template);
|
|
90
|
+
ensureTargetUsable(targetDir, props.force);
|
|
91
|
+
await scaffold(template, targetDir);
|
|
92
|
+
printScaffolded(template, targetDir);
|
|
93
|
+
await runPostScaffoldSteps(props, targetDir, interactive);
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The template list to choose from. When --template is given we try the
|
|
97
|
+
* built-in fallback list first to avoid a network round-trip, only fetching the
|
|
98
|
+
* remote manifest if the id isn't one of the defaults.
|
|
99
|
+
*/
|
|
100
|
+
const resolveTemplateList = async (props) => props.template && findTemplate(FALLBACK_TEMPLATES, props.template)
|
|
101
|
+
? FALLBACK_TEMPLATES
|
|
102
|
+
: fetchTemplates();
|
|
103
|
+
/**
|
|
104
|
+
* The picker label for a template: the title prefixed with the Neon services it
|
|
105
|
+
* uses as a dim badge, e.g. "[Postgres · Functions] Hono API …". The badge is
|
|
106
|
+
* styled with chalk.dim only (never a foreground color) so it survives the
|
|
107
|
+
* cyan/underline `prompts` paints over the focused row — dim resets with the
|
|
108
|
+
* intensity SGR, leaving the row's color and underline intact. The one-line
|
|
109
|
+
* description renders under the title on focus (handled by `prompts`).
|
|
110
|
+
*/
|
|
111
|
+
const formatTemplateTitle = (template) => {
|
|
112
|
+
if (!template.services || template.services.length === 0) {
|
|
113
|
+
return template.title;
|
|
114
|
+
}
|
|
115
|
+
return `${chalk.dim(`[${template.services.join(' · ')}]`)} ${template.title}`;
|
|
116
|
+
};
|
|
117
|
+
const resolveSelectedTemplate = async (props, interactive, templates) => {
|
|
118
|
+
if (props.template) {
|
|
119
|
+
const template = findTemplate(templates, props.template);
|
|
120
|
+
if (!template) {
|
|
121
|
+
throw new Error(`Unknown template "${props.template}". Available templates: ${templateIds(templates)}.`);
|
|
122
|
+
}
|
|
123
|
+
return template;
|
|
124
|
+
}
|
|
125
|
+
// --default with no --template falls back to the first (default) template so
|
|
126
|
+
// a bare `neon bootstrap my-app --default` works end to end.
|
|
127
|
+
if (props.default) {
|
|
128
|
+
const fallback = templates[0];
|
|
129
|
+
if (!fallback) {
|
|
130
|
+
throw new Error('No templates available to scaffold from.');
|
|
131
|
+
}
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
if (!interactive) {
|
|
135
|
+
throw new Error(`No template selected. Re-run in an interactive terminal to pick one, or pass --template <id>. Available templates: ${templateIds(templates)}.`);
|
|
136
|
+
}
|
|
137
|
+
const { id } = await prompts({
|
|
138
|
+
onState: onPromptState,
|
|
139
|
+
type: 'select',
|
|
140
|
+
name: 'id',
|
|
141
|
+
message: 'Which template would you like to use?',
|
|
142
|
+
choices: templates.map((template) => ({
|
|
143
|
+
title: formatTemplateTitle(template),
|
|
144
|
+
description: template.description,
|
|
145
|
+
value: template.id,
|
|
146
|
+
})),
|
|
147
|
+
initial: 0,
|
|
148
|
+
});
|
|
149
|
+
const template = findTemplate(templates, id);
|
|
150
|
+
if (!template) {
|
|
151
|
+
throw new Error('No template selected.');
|
|
152
|
+
}
|
|
153
|
+
return template;
|
|
154
|
+
};
|
|
155
|
+
const resolveTargetDir = async (props, interactive, template) => {
|
|
156
|
+
let dir = props.directory;
|
|
157
|
+
if (dir === undefined) {
|
|
158
|
+
// --default supplies a directory (the template's name) so the quick start
|
|
159
|
+
// needs nothing but a template.
|
|
160
|
+
if (props.default) {
|
|
161
|
+
return resolve(process.cwd(), defaultDirName(template));
|
|
162
|
+
}
|
|
163
|
+
if (!interactive) {
|
|
164
|
+
throw new Error('No target directory given. Pass one, e.g. `neon bootstrap my-app` (or "." for the current directory).');
|
|
165
|
+
}
|
|
166
|
+
const { value } = await prompts({
|
|
167
|
+
onState: onPromptState,
|
|
168
|
+
type: 'text',
|
|
169
|
+
name: 'value',
|
|
170
|
+
message: 'Where should we scaffold your project?',
|
|
171
|
+
initial: defaultDirName(template),
|
|
172
|
+
validate: (input) => input && input.trim().length > 0
|
|
173
|
+
? true
|
|
174
|
+
: 'Enter a directory (use "." for the current directory).',
|
|
175
|
+
});
|
|
176
|
+
dir = String(value).trim();
|
|
177
|
+
}
|
|
178
|
+
return resolve(process.cwd(), dir === '.' ? '' : dir);
|
|
179
|
+
};
|
|
180
|
+
const defaultDirName = (template) => template.source.subdir.split('/').pop() || template.id;
|
|
181
|
+
/**
|
|
182
|
+
* A bad user-supplied input that an agent (or human) can correct: an unknown
|
|
183
|
+
* template id or a non-empty target directory. Carries an `agentCode` so
|
|
184
|
+
* `--agent` mode reports a precise `status: error` code instead of a generic
|
|
185
|
+
* INTERNAL_ERROR, while the human path just surfaces the clear `message`.
|
|
186
|
+
*/
|
|
187
|
+
class BootstrapInputError extends Error {
|
|
188
|
+
constructor(message, agentCode) {
|
|
189
|
+
super(message);
|
|
190
|
+
this.name = 'BootstrapInputError';
|
|
191
|
+
this.agentCode = agentCode;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const ensureTargetUsable = (dir, force) => {
|
|
195
|
+
if (!existsSync(dir)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!statSync(dir).isDirectory()) {
|
|
199
|
+
throw new BootstrapInputError(`Target ${dir} already exists and is not a directory.`, 'TARGET_NOT_DIRECTORY');
|
|
200
|
+
}
|
|
201
|
+
// A lone `.git` is ignored so you can scaffold into a freshly `git init`ed
|
|
202
|
+
// (otherwise empty) directory without reaching for --force.
|
|
203
|
+
const contents = readdirSync(dir).filter((name) => name !== '.git');
|
|
204
|
+
if (contents.length > 0 && !force) {
|
|
205
|
+
throw new BootstrapInputError(`Target directory ${dir} is not empty. Use --force to scaffold into it anyway (colliding files will be overwritten), or choose an empty directory.`, 'TARGET_NOT_EMPTY');
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const scaffold = async (template, targetDir) => {
|
|
209
|
+
log.info('Fetching template "%s" from GitHub…', template.id);
|
|
210
|
+
const { commitSha, entries } = await resolveTemplate(template);
|
|
211
|
+
mkdirSync(targetDir, { recursive: true });
|
|
212
|
+
log.info('Scaffolding %d files into %s…', entries.length, targetDir);
|
|
213
|
+
await mapWithConcurrency(entries, 8, async (entry) => {
|
|
214
|
+
const dest = join(targetDir, entry.path);
|
|
215
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
216
|
+
if (entry.kind === 'symlink') {
|
|
217
|
+
const target = await fetchSymlinkTarget(template, commitSha, entry.repoPath);
|
|
218
|
+
writeSymlink(dest, target);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const bytes = await fetchFileBytes(template, commitSha, entry.repoPath);
|
|
222
|
+
writeFileSync(dest, bytes);
|
|
223
|
+
if (entry.executable) {
|
|
224
|
+
chmodSync(dest, 0o755);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return entries.length;
|
|
229
|
+
};
|
|
230
|
+
const writeSymlink = (dest, target) => {
|
|
231
|
+
if (isSymlink(dest)) {
|
|
232
|
+
rmSync(dest, { force: true });
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
symlinkSync(target, dest);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
// Windows refuses symlinks without elevated rights / developer mode. The
|
|
239
|
+
// template still works for most tooling if we drop a regular file holding
|
|
240
|
+
// the link target, so we degrade gracefully instead of failing the copy.
|
|
241
|
+
if (errnoCode(err) === 'EPERM' || process.platform === 'win32') {
|
|
242
|
+
log.warning('Could not create symlink %s -> %s; wrote it as a regular file instead.', dest, target);
|
|
243
|
+
writeFileSync(dest, target);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
// ----------------------------------------------------------------------------
|
|
250
|
+
// Post-scaffold steps (install dependencies, git init, link to a Neon project)
|
|
251
|
+
// ----------------------------------------------------------------------------
|
|
252
|
+
/**
|
|
253
|
+
* After a human scaffold, offer the things you almost always do next: install
|
|
254
|
+
* dependencies, initialize a git repo, and link the directory to a Neon
|
|
255
|
+
* project. In an interactive terminal each is a y/n prompt (skippable up front
|
|
256
|
+
* with --no-install / --no-git / --no-link); `--default` runs install + git
|
|
257
|
+
* without asking; otherwise we just print the manual steps so nothing runs
|
|
258
|
+
* behind the user's back. Agent mode never reaches here — it returns these as
|
|
259
|
+
* structured `next_steps` instead (see {@link runAgent}).
|
|
260
|
+
*/
|
|
261
|
+
const runPostScaffoldSteps = async (props, targetDir, interactive) => {
|
|
262
|
+
const detected = detectPackageManager();
|
|
263
|
+
if (props.default) {
|
|
264
|
+
await runDefaultSteps(props, targetDir, detected ?? 'npm');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!interactive) {
|
|
268
|
+
printNextSteps(targetDir, detected ?? 'npm', {
|
|
269
|
+
installed: false,
|
|
270
|
+
suggestLink: true,
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// The package manager used for the install (and shown in the closing hint).
|
|
275
|
+
// When we couldn't infer it from the invocation we ask, so a globally
|
|
276
|
+
// installed `neon` doesn't silently force npm on a bun/pnpm user.
|
|
277
|
+
let pm = detected ?? 'npm';
|
|
278
|
+
let installed = false;
|
|
279
|
+
if (props.install && (await confirm(installPrompt(detected)))) {
|
|
280
|
+
pm = detected ?? (await selectPackageManager());
|
|
281
|
+
installed = await runCommand(pm, ['install'], targetDir);
|
|
282
|
+
}
|
|
283
|
+
if (props.git &&
|
|
284
|
+
!isGitRepo(targetDir) &&
|
|
285
|
+
(await confirm('Initialize a git repository?'))) {
|
|
286
|
+
await initGitRepo(targetDir);
|
|
287
|
+
}
|
|
288
|
+
// `neon link` pulls env vars, which loads this project's neon.ts — and that
|
|
289
|
+
// evaluation needs the dependencies installed. So when deps weren't installed
|
|
290
|
+
// and the scaffold ships a neon.ts, skip the link prompt (it would just fail)
|
|
291
|
+
// and tell the user how to finish by hand.
|
|
292
|
+
if (props.link) {
|
|
293
|
+
if (!installed && hasNeonConfig(targetDir)) {
|
|
294
|
+
log.info("Skipping the Neon link step: `neon link` reads this project's neon.ts " +
|
|
295
|
+
`to pull env vars, which needs its dependencies. Run \`${pm} install\`, ` +
|
|
296
|
+
'then `neon link`.');
|
|
297
|
+
}
|
|
298
|
+
else if (await confirm('Link this project to a Neon project now? (runs neon link)')) {
|
|
299
|
+
await runNeonLink(props, targetDir);
|
|
300
|
+
// link prints its own summary (and pulls env), so end with just the run hint.
|
|
301
|
+
printNextSteps(targetDir, pm, { installed, suggestLink: false });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
printNextSteps(targetDir, pm, { installed, suggestLink: true });
|
|
306
|
+
};
|
|
307
|
+
const installPrompt = (detected) => detected ? `Install dependencies with ${detected}?` : 'Install dependencies?';
|
|
308
|
+
/**
|
|
309
|
+
* `--default` quick start: run install + git init without prompting, honoring
|
|
310
|
+
* --no-install / --no-git. Linking is intentionally skipped — it needs an
|
|
311
|
+
* org/project choice we can't make non-interactively — so we point at it in the
|
|
312
|
+
* closing hint instead.
|
|
313
|
+
*/
|
|
314
|
+
const runDefaultSteps = async (props, targetDir, pm) => {
|
|
315
|
+
log.info('Quick start (--default): running setup without prompting.');
|
|
316
|
+
let installed = false;
|
|
317
|
+
if (props.install) {
|
|
318
|
+
installed = await runCommand(pm, ['install'], targetDir);
|
|
319
|
+
}
|
|
320
|
+
if (props.git && !isGitRepo(targetDir)) {
|
|
321
|
+
await initGitRepo(targetDir);
|
|
322
|
+
}
|
|
323
|
+
printNextSteps(targetDir, pm, { installed, suggestLink: true });
|
|
324
|
+
};
|
|
325
|
+
const isGitRepo = (dir) => existsSync(join(dir, '.git'));
|
|
326
|
+
// Config filenames the runtime loads (mirrors @neondatabase/config). A scaffold
|
|
327
|
+
// that ships one makes `neon link`'s env pull evaluate it — which needs deps.
|
|
328
|
+
const NEON_CONFIG_FILENAMES = ['neon.ts', 'neon.mts', 'neon.js', 'neon.mjs'];
|
|
329
|
+
const hasNeonConfig = (dir) => NEON_CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
|
|
330
|
+
/**
|
|
331
|
+
* Initialize a git repository in the scaffolded directory. Just `git init` — we
|
|
332
|
+
* deliberately don't auto-commit, both to avoid failing on a machine with no
|
|
333
|
+
* git identity configured and to leave the first commit to the user.
|
|
334
|
+
*/
|
|
335
|
+
const initGitRepo = async (dir) => {
|
|
336
|
+
await runCommand('git', ['init'], dir);
|
|
337
|
+
};
|
|
338
|
+
const confirm = async (message) => {
|
|
339
|
+
const { value } = await prompts({
|
|
340
|
+
onState: onPromptState,
|
|
341
|
+
type: 'confirm',
|
|
342
|
+
name: 'value',
|
|
343
|
+
message,
|
|
344
|
+
initial: true,
|
|
345
|
+
});
|
|
346
|
+
return value === true;
|
|
347
|
+
};
|
|
348
|
+
// npm first so it's the default/preselected choice; the rest follow in rough
|
|
349
|
+
// popularity order.
|
|
350
|
+
const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
351
|
+
/**
|
|
352
|
+
* The package manager the CLI was invoked through, read from the
|
|
353
|
+
* `npm_config_user_agent` npm sets for `npm exec`/`npx`, `pnpm dlx`, `yarn
|
|
354
|
+
* dlx`, and `bunx` (so `pnpm dlx neonctl bootstrap` installs with pnpm).
|
|
355
|
+
* Returns undefined when there's nothing to infer from — e.g. a
|
|
356
|
+
* globally-installed `neon`/`neonctl` — so the caller can ask instead of
|
|
357
|
+
* silently assuming npm.
|
|
358
|
+
*/
|
|
359
|
+
const detectPackageManager = () => {
|
|
360
|
+
const ua = process.env.npm_config_user_agent ?? '';
|
|
361
|
+
if (ua.startsWith('pnpm'))
|
|
362
|
+
return 'pnpm';
|
|
363
|
+
if (ua.startsWith('yarn'))
|
|
364
|
+
return 'yarn';
|
|
365
|
+
if (ua.startsWith('bun'))
|
|
366
|
+
return 'bun';
|
|
367
|
+
if (ua.startsWith('npm'))
|
|
368
|
+
return 'npm';
|
|
369
|
+
return undefined;
|
|
370
|
+
};
|
|
371
|
+
/** The package managers actually on PATH, in {@link PACKAGE_MANAGERS} order. */
|
|
372
|
+
const installedPackageManagers = () => PACKAGE_MANAGERS.filter((pm) => which.sync(pm, { nothrow: true }) !== null);
|
|
373
|
+
/**
|
|
374
|
+
* Ask which package manager to install with when we couldn't infer one from the
|
|
375
|
+
* invocation. Offers the managers actually installed (npm preselected); with
|
|
376
|
+
* one or none installed there's nothing to choose, so it returns that one (or
|
|
377
|
+
* npm) without prompting. A cancelled prompt falls back to npm.
|
|
378
|
+
*/
|
|
379
|
+
const selectPackageManager = async () => {
|
|
380
|
+
const installed = installedPackageManagers();
|
|
381
|
+
if (installed.length <= 1) {
|
|
382
|
+
return installed[0] ?? 'npm';
|
|
383
|
+
}
|
|
384
|
+
const { pm } = await prompts({
|
|
385
|
+
onState: onPromptState,
|
|
386
|
+
type: 'select',
|
|
387
|
+
name: 'pm',
|
|
388
|
+
message: 'Which package manager should we use?',
|
|
389
|
+
choices: installed.map((manager) => ({
|
|
390
|
+
title: manager,
|
|
391
|
+
value: manager,
|
|
392
|
+
})),
|
|
393
|
+
initial: Math.max(0, installed.indexOf('npm')),
|
|
394
|
+
});
|
|
395
|
+
return pm ?? 'npm';
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Run a command inheriting our stdio so the user sees install / link output
|
|
399
|
+
* live and can answer any prompts the child raises. Resolves to whether it
|
|
400
|
+
* exited cleanly; a non-zero exit is reported but never aborts bootstrap — the
|
|
401
|
+
* scaffold already succeeded, so we let the user retry the step by hand.
|
|
402
|
+
*/
|
|
403
|
+
const runCommand = (cmd, args, cwd) => new Promise((resolvePromise) => {
|
|
404
|
+
// npm/pnpm/yarn ship as .cmd shims on Windows, which need a shell to run.
|
|
405
|
+
const child = spawn(cmd, args, {
|
|
406
|
+
cwd,
|
|
407
|
+
stdio: 'inherit',
|
|
408
|
+
shell: process.platform === 'win32',
|
|
409
|
+
});
|
|
410
|
+
child.on('error', (err) => {
|
|
411
|
+
log.warning('Could not run `%s %s`: %s', cmd, args.join(' '), err instanceof Error ? err.message : String(err));
|
|
412
|
+
resolvePromise(false);
|
|
413
|
+
});
|
|
414
|
+
child.on('close', (code) => {
|
|
415
|
+
if (code !== 0) {
|
|
416
|
+
log.warning('`%s %s` exited with code %d.', cmd, args.join(' '), code);
|
|
417
|
+
}
|
|
418
|
+
resolvePromise(code === 0);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
/**
|
|
422
|
+
* Re-invoke this same CLI as `neon link` inside the scaffolded directory, so the
|
|
423
|
+
* new project's `.neon` context (and pulled `.env`) land in the right place and
|
|
424
|
+
* link's own interactive picker drives org/project/branch selection. Re-execing
|
|
425
|
+
* (rather than calling the handler in-process) keeps link running with `cwd` set
|
|
426
|
+
* to the target dir, which is where its env pull writes.
|
|
427
|
+
*/
|
|
428
|
+
const runNeonLink = async (props, targetDir) => {
|
|
429
|
+
const args = [process.argv[1], 'link'];
|
|
430
|
+
if (props.apiKey) {
|
|
431
|
+
args.push('--api-key', props.apiKey);
|
|
432
|
+
}
|
|
433
|
+
args.push('--api-host', props.apiHost, '--output', props.output);
|
|
434
|
+
await runCommand(process.execPath, args, targetDir);
|
|
435
|
+
};
|
|
436
|
+
const printScaffolded = (template, targetDir) => {
|
|
437
|
+
log.info('');
|
|
438
|
+
log.info('Done. Scaffolded "%s" into %s.', template.title, isCurrentDir(targetDir) ? 'the current directory' : displayDir(targetDir));
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* The closing "Next steps" hint. Skips `cd` for the current directory, omits
|
|
442
|
+
* the install line once deps are in, and only nudges `neon link` when linking
|
|
443
|
+
* wasn't already offered/run — so the user never sees a step they just did.
|
|
444
|
+
*/
|
|
445
|
+
const printNextSteps = (targetDir, pm, opts) => {
|
|
446
|
+
log.info('');
|
|
447
|
+
log.info('Next steps:');
|
|
448
|
+
if (!isCurrentDir(targetDir)) {
|
|
449
|
+
log.info(' cd %s', displayDir(targetDir));
|
|
450
|
+
}
|
|
451
|
+
if (!opts.installed) {
|
|
452
|
+
log.info(' %s install', pm);
|
|
453
|
+
}
|
|
454
|
+
if (opts.suggestLink) {
|
|
455
|
+
log.info(' neon link');
|
|
456
|
+
}
|
|
457
|
+
log.info(' See the README to run it.');
|
|
458
|
+
log.info('');
|
|
459
|
+
};
|
|
460
|
+
const runAgentSafely = async (props) => {
|
|
461
|
+
try {
|
|
462
|
+
await runAgent(props);
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
emitAgent(toAgentError(err));
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* The `--agent` flow: resolve what the flags determine and emit one JSON object
|
|
471
|
+
* describing either the next input needed (`needs_template` / `needs_directory`)
|
|
472
|
+
* or the terminal result (`scaffolded`). Unlike interactive mode it never
|
|
473
|
+
* prompts and never runs install/git/link itself — those come back as structured
|
|
474
|
+
* `next_steps` so the agent can confirm with the user and run them (the link
|
|
475
|
+
* step chains into `neon link --agent`).
|
|
476
|
+
*/
|
|
477
|
+
const runAgent = async (props) => {
|
|
478
|
+
if (!props.template) {
|
|
479
|
+
const templates = await fetchTemplates();
|
|
480
|
+
emitAgent({
|
|
481
|
+
status: 'needs_template',
|
|
482
|
+
instruction: `Ask the user which template to scaffold, then re-run the next_command_template with the chosen --template value${props.directory ? '' : ' and a target directory'}.`,
|
|
483
|
+
options: templates.map((template) => ({
|
|
484
|
+
id: template.id,
|
|
485
|
+
title: template.title,
|
|
486
|
+
description: template.description,
|
|
487
|
+
...(template.services ? { services: template.services } : {}),
|
|
488
|
+
})),
|
|
489
|
+
next_command_template: `neon bootstrap --agent ${props.directory ? shellArg(props.directory) : '<directory>'} --template <template_id>`,
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const templates = await resolveTemplateList(props);
|
|
494
|
+
const template = findTemplate(templates, props.template);
|
|
495
|
+
if (!template) {
|
|
496
|
+
throw new BootstrapInputError(`Unknown template "${props.template}". Available templates: ${templateIds(templates)}.`, 'UNKNOWN_TEMPLATE');
|
|
497
|
+
}
|
|
498
|
+
if (props.directory === undefined) {
|
|
499
|
+
emitAgent({
|
|
500
|
+
status: 'needs_directory',
|
|
501
|
+
instruction: 'Ask the user which directory to scaffold into (use "." for the current directory), then re-run the next_command_template with it.',
|
|
502
|
+
next_command_template: `neon bootstrap --agent <directory> --template ${shellArg(template.id)}`,
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const targetDir = resolve(process.cwd(), props.directory === '.' ? '' : props.directory);
|
|
507
|
+
ensureTargetUsable(targetDir, props.force);
|
|
508
|
+
const filesWritten = await scaffold(template, targetDir);
|
|
509
|
+
const dir = displayDir(targetDir);
|
|
510
|
+
const runIn = isCurrentDir(targetDir) ? '' : `cd ${shellArg(dir)} && `;
|
|
511
|
+
emitAgent({
|
|
512
|
+
status: 'scaffolded',
|
|
513
|
+
directory: targetDir,
|
|
514
|
+
template: { id: template.id, title: template.title },
|
|
515
|
+
files_written: filesWritten,
|
|
516
|
+
next_steps: [
|
|
517
|
+
{
|
|
518
|
+
action: 'install_dependencies',
|
|
519
|
+
instruction: 'Ask the user whether to install dependencies, then run this in the project directory.',
|
|
520
|
+
command: `${runIn}npm install`,
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
action: 'initialize_git',
|
|
524
|
+
instruction: 'Ask the user whether to initialize a git repository in the project directory.',
|
|
525
|
+
command: `${runIn}git init`,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
action: 'link_neon_project',
|
|
529
|
+
instruction: 'Ask the user whether to link the project to a Neon project now. This runs the link state machine — follow its JSON output for the next step.',
|
|
530
|
+
command: `${runIn}neon link --agent`,
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
message: `Scaffolded "${template.title}" (${filesWritten} files) into ${dir}. Offer the next_steps to the user: install dependencies, initialize git, then link a Neon project.`,
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
const emitAgent = (response) => {
|
|
537
|
+
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
538
|
+
};
|
|
539
|
+
const toAgentError = (err) => {
|
|
540
|
+
if (err instanceof BootstrapInputError) {
|
|
541
|
+
return { status: 'error', code: err.agentCode, message: err.message };
|
|
542
|
+
}
|
|
543
|
+
if (err instanceof Error) {
|
|
544
|
+
return { status: 'error', code: 'INTERNAL_ERROR', message: err.message };
|
|
545
|
+
}
|
|
546
|
+
return { status: 'error', code: 'INTERNAL_ERROR', message: String(err) };
|
|
547
|
+
};
|
|
548
|
+
// ----------------------------------------------------------------------------
|
|
549
|
+
// Path display helpers
|
|
550
|
+
// ----------------------------------------------------------------------------
|
|
551
|
+
const isCurrentDir = (targetDir) => relative(process.cwd(), targetDir) === '';
|
|
552
|
+
/**
|
|
553
|
+
* The path to show the user: the bare relative path for the common
|
|
554
|
+
* `bootstrap my-app` case, the absolute path when the target sits outside the
|
|
555
|
+
* cwd (a deep `../../..` is noise), and "." for the current directory.
|
|
556
|
+
*/
|
|
557
|
+
const displayDir = (targetDir) => {
|
|
558
|
+
const rel = relative(process.cwd(), targetDir);
|
|
559
|
+
if (rel === '') {
|
|
560
|
+
return '.';
|
|
561
|
+
}
|
|
562
|
+
return rel.startsWith('..') ? targetDir : rel;
|
|
563
|
+
};
|
|
564
|
+
const mapWithConcurrency = async (items, limit, fn) => {
|
|
565
|
+
const queue = [...items];
|
|
566
|
+
const worker = async () => {
|
|
567
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
568
|
+
await fn(next);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
|
|
572
|
+
await Promise.all(workers);
|
|
573
|
+
};
|
|
574
|
+
const isSymlink = (path) => {
|
|
575
|
+
try {
|
|
576
|
+
return lstatSync(path).isSymbolicLink();
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
const errnoCode = (err) => {
|
|
583
|
+
if (typeof err === 'object' &&
|
|
584
|
+
err !== null &&
|
|
585
|
+
'code' in err &&
|
|
586
|
+
typeof err.code === 'string') {
|
|
587
|
+
return err.code;
|
|
588
|
+
}
|
|
589
|
+
return undefined;
|
|
590
|
+
};
|
|
591
|
+
const shellArg = (value) => {
|
|
592
|
+
if (/^[A-Za-z0-9._:/-]+$/.test(value)) {
|
|
593
|
+
return value;
|
|
594
|
+
}
|
|
595
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
596
|
+
};
|
|
597
|
+
const onPromptState = (state) => {
|
|
598
|
+
if (state.aborted) {
|
|
599
|
+
process.stdout.write('\x1B[?25h');
|
|
600
|
+
process.stdout.write('\n');
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
};
|
package/commands/branches.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EndpointType } from '@neondatabase/api-client';
|
|
2
|
-
import { readContextFile } from '../context.js';
|
|
2
|
+
import { contextBranch, readContextFile } from '../context.js';
|
|
3
3
|
import { writer } from '../writer.js';
|
|
4
4
|
import { branchCreateRequest } from '../parameters.gen.js';
|
|
5
5
|
import { retryOnLock } from '../api.js';
|
|
@@ -221,8 +221,9 @@ const list = async (props) => {
|
|
|
221
221
|
projectId: props.projectId,
|
|
222
222
|
});
|
|
223
223
|
// The branch pinned in the local context (.neon), so we can flag it as `[current]` — the
|
|
224
|
-
// one commands target by default and that `neonctl env pull` would read.
|
|
225
|
-
|
|
224
|
+
// one commands target by default and that `neonctl env pull` would read. The context
|
|
225
|
+
// stores the branch by name (preferred) or id, so match against either.
|
|
226
|
+
const currentBranch = contextBranch(readContextFile(props.contextFile));
|
|
226
227
|
writer(props).end(branches, {
|
|
227
228
|
fields: BRANCH_FIELDS,
|
|
228
229
|
renderColumns: {
|
|
@@ -241,7 +242,8 @@ const list = async (props) => {
|
|
|
241
242
|
if (isAnon) {
|
|
242
243
|
labels.push('[anon]');
|
|
243
244
|
}
|
|
244
|
-
if (
|
|
245
|
+
if (currentBranch !== undefined &&
|
|
246
|
+
(br.id === currentBranch || br.name === currentBranch)) {
|
|
245
247
|
labels.push('[current]');
|
|
246
248
|
}
|
|
247
249
|
labels.push(br.name);
|