@selvajs/cli 4.8.0-beta.8 → 4.8.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 +7 -12
- package/package.json +6 -3
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +189 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +372 -0
- package/src/cli.js +70 -2
- package/src/commands/create.js +71 -76
- package/src/commands/doctor.js +163 -332
- package/src/commands/init.js +13 -32
- package/src/commands/keys.js +2 -15
- package/src/commands/migrate.js +321 -115
- package/src/commands/pm2.js +15 -18
- package/src/deployment-package.js +254 -0
- package/src/env.js +197 -25
- package/src/node-range.js +66 -0
- package/src/paths.js +46 -3
- package/src/prompts.js +4 -16
- package/bin/create.js +0 -7
package/src/commands/create.js
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
//
|
|
2
|
-
// ecosystem.config.cjs
|
|
3
|
-
// templates are the source of truth; providers are env-driven.
|
|
1
|
+
// Scaffolds a fresh deployment: package.json, node_modules, .env (merged from
|
|
2
|
+
// template + prompts), and ecosystem.config.cjs.
|
|
4
3
|
|
|
5
4
|
import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
|
6
|
-
import { resolve, join, basename } from 'node:path';
|
|
5
|
+
import { resolve, join, basename, dirname } from 'node:path';
|
|
7
6
|
import { spawn } from 'node:child_process';
|
|
8
7
|
import * as p from '@clack/prompts';
|
|
9
8
|
import pc from 'picocolors';
|
|
10
9
|
import { collectConfig, collectConfigFromEnv } from '../prompts.js';
|
|
11
10
|
import { generateKey } from '../secrets.js';
|
|
12
11
|
import { writeEnvFile } from '../env.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
import {
|
|
13
|
+
isEmptyOrMissing,
|
|
14
|
+
requiredNodeRange,
|
|
15
|
+
runtimeTemplatePath,
|
|
16
|
+
scaffoldVersion
|
|
17
|
+
} from '../paths.js';
|
|
18
|
+
import {
|
|
19
|
+
buildDeploymentPackageJson,
|
|
20
|
+
npmDistTagVersion,
|
|
21
|
+
resolveSelvaPins
|
|
22
|
+
} from '../deployment-package.js';
|
|
23
|
+
import { satisfiesNodeRange } from '../node-range.js';
|
|
17
24
|
|
|
18
25
|
export async function runCreate(argv) {
|
|
19
26
|
const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
|
|
20
27
|
|
|
21
|
-
// Scaffolding under too old a Node
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// operator is still choosing their environment. The floor comes from this
|
|
26
|
-
// CLI's own engines.node, so a bump can't leave a stale copy behind.
|
|
28
|
+
// Scaffolding under too old a Node installs and passes the health check,
|
|
29
|
+
// then fails only under real traffic — npm treats an engine mismatch as a
|
|
30
|
+
// warning, not an error, unless engine-strict is set, which no deployment
|
|
31
|
+
// sets. Refuse now, while the operator is still choosing their environment.
|
|
27
32
|
const required = requiredNodeRange();
|
|
28
33
|
if (required && satisfiesNodeRange(process.versions.node, required) === false) {
|
|
29
34
|
console.error(
|
|
@@ -46,20 +51,29 @@ export async function runCreate(argv) {
|
|
|
46
51
|
}
|
|
47
52
|
mkdirSync(targetDir, { recursive: true });
|
|
48
53
|
|
|
49
|
-
// --yes or CI=1 skips prompts (unattended bootstrap).
|
|
50
54
|
const nonInteractive = yes || envBool(process.env.CI);
|
|
51
55
|
const values = nonInteractive
|
|
52
56
|
? collectConfigFromEnv(process.env)
|
|
53
57
|
: await collectConfig({ defaults: {}, mode: 'create' });
|
|
54
58
|
|
|
55
|
-
// Generate fresh secrets (stable across restarts; init refuses to regen).
|
|
56
59
|
values.SELVA_HMAC_KEY = generateKey();
|
|
57
60
|
values.SELVA_AT_REST_KEY = generateKey();
|
|
58
61
|
|
|
59
62
|
const deployName = basename(targetDir);
|
|
60
63
|
|
|
61
|
-
//
|
|
62
|
-
|
|
64
|
+
// Resolve the dist-tags to concrete versions before writing. A stored
|
|
65
|
+
// `"latest"` re-resolves on every later `npm install`, so the deployment
|
|
66
|
+
// would follow the tag instead of the version it was scaffolded against —
|
|
67
|
+
// and `selva doctor` reports a floating pin as drift.
|
|
68
|
+
const { pins } = resolveSelvaPins({}, npmDistTagVersion);
|
|
69
|
+
|
|
70
|
+
// package.json first: npm install needs it, and the runtime templates below
|
|
71
|
+
// only exist once @selvajs/selva is installed into node_modules.
|
|
72
|
+
const pkgJson = JSON.stringify(
|
|
73
|
+
buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
|
|
74
|
+
null,
|
|
75
|
+
2
|
|
76
|
+
);
|
|
63
77
|
writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
|
|
64
78
|
|
|
65
79
|
if (!skipInstall) {
|
|
@@ -70,24 +84,23 @@ export async function runCreate(argv) {
|
|
|
70
84
|
);
|
|
71
85
|
}
|
|
72
86
|
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
if (skipInstall || !existsSync(
|
|
87
|
+
const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
|
|
88
|
+
const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
|
|
89
|
+
if (skipInstall || !existsSync(envTemplatePath)) {
|
|
76
90
|
p.log.warn(
|
|
77
|
-
`Couldn't read runtime templates from ${
|
|
91
|
+
`Couldn't read runtime templates from ${dirname(envTemplatePath)}. ` +
|
|
78
92
|
`Run \`npm install\`, then \`selva init\` to finish setup.`
|
|
79
93
|
);
|
|
80
|
-
writeFileSync(join(targetDir, '.selva-version'),
|
|
94
|
+
writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
|
|
81
95
|
p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
|
|
82
96
|
return;
|
|
83
97
|
}
|
|
84
98
|
|
|
85
|
-
|
|
86
|
-
writeEnvFile(join(targetDir, '.env'), envTemplate, values);
|
|
99
|
+
writeEnvFile(join(targetDir, '.env'), readFileSync(envTemplatePath, 'utf8'), values);
|
|
87
100
|
|
|
88
|
-
cpSync(
|
|
101
|
+
cpSync(ecosystemTemplatePath, join(targetDir, 'ecosystem.config.cjs'));
|
|
89
102
|
|
|
90
|
-
writeFileSync(join(targetDir, '.selva-version'),
|
|
103
|
+
writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
|
|
91
104
|
writeGitignore(targetDir);
|
|
92
105
|
|
|
93
106
|
p.outro(
|
|
@@ -106,21 +119,17 @@ export async function runCreate(argv) {
|
|
|
106
119
|
);
|
|
107
120
|
}
|
|
108
121
|
|
|
109
|
-
// Stream npm output with live progress; on failure, show tail for debugging.
|
|
110
|
-
// Cache-bust hint: surface broken versions like @selvajs/selva@0.10.2.
|
|
111
122
|
function runNpmInstall(cwd) {
|
|
112
123
|
return new Promise((resolveP, rejectP) => {
|
|
113
124
|
const s = p.spinner();
|
|
114
125
|
s.start('Installing dependencies (this can take a minute)');
|
|
115
126
|
|
|
116
|
-
// Keep last 80 lines for failure output.
|
|
117
127
|
const tail = [];
|
|
118
128
|
const remember = (line) => {
|
|
119
129
|
tail.push(line);
|
|
120
130
|
if (tail.length > 80) tail.shift();
|
|
121
131
|
};
|
|
122
132
|
|
|
123
|
-
// Extract progress milestones (reify: lines, package count).
|
|
124
133
|
const updateProgress = (line) => {
|
|
125
134
|
const trimmed = line.trim();
|
|
126
135
|
if (!trimmed) return;
|
|
@@ -136,7 +145,7 @@ function runNpmInstall(cwd) {
|
|
|
136
145
|
if (/^npm (WARN|error|notice)/.test(trimmed)) return;
|
|
137
146
|
};
|
|
138
147
|
|
|
139
|
-
//
|
|
148
|
+
// --loglevel=info is what makes npm print the reify: lines updateProgress matches on.
|
|
140
149
|
const child = spawn('npm', ['install', '--loglevel=info'], {
|
|
141
150
|
cwd,
|
|
142
151
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -180,8 +189,8 @@ function runNpmInstall(cwd) {
|
|
|
180
189
|
}
|
|
181
190
|
s.stop(pc.red(`npm install failed (exit ${code})`));
|
|
182
191
|
|
|
183
|
-
//
|
|
184
|
-
//
|
|
192
|
+
// Without this the operator has to dig through ~/.npm/_logs/*-debug-0.log
|
|
193
|
+
// to find out what actually went wrong.
|
|
185
194
|
console.error('');
|
|
186
195
|
console.error(pc.dim('── last lines of npm output ──'));
|
|
187
196
|
for (const line of tail) console.error(line);
|
|
@@ -192,7 +201,7 @@ function runNpmInstall(cwd) {
|
|
|
192
201
|
console.error(
|
|
193
202
|
pc.yellow(
|
|
194
203
|
'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
|
|
195
|
-
|
|
204
|
+
'workspace:* / catalog: specs) and has been unpublished. Your local npm\n' +
|
|
196
205
|
'cache is stale. Clear it and retry:\n\n' +
|
|
197
206
|
' npm cache clean --force\n' +
|
|
198
207
|
' rm -rf node_modules package-lock.json\n' +
|
|
@@ -206,11 +215,37 @@ function runNpmInstall(cwd) {
|
|
|
206
215
|
});
|
|
207
216
|
}
|
|
208
217
|
|
|
218
|
+
// `npx @selvajs/cli` resolves to the `cli` bin — the scaffolder — while every
|
|
219
|
+
// operate command lives on the sibling `selva` bin. So `npx @selvajs/cli doctor
|
|
220
|
+
// --fix` lands here, where "doctor" is just a directory name and `--fix` an
|
|
221
|
+
// unknown flag. Name the bin split rather than letting the operator conclude
|
|
222
|
+
// their CLI is too old for a flag it has shipped for releases.
|
|
223
|
+
const OPERATE_COMMANDS = new Set([
|
|
224
|
+
'doctor',
|
|
225
|
+
'start',
|
|
226
|
+
'stop',
|
|
227
|
+
'restart',
|
|
228
|
+
'logs',
|
|
229
|
+
'update',
|
|
230
|
+
'migrate',
|
|
231
|
+
'keys',
|
|
232
|
+
'init'
|
|
233
|
+
]);
|
|
234
|
+
|
|
209
235
|
function parseArgs(argv) {
|
|
210
236
|
let dir;
|
|
211
237
|
let force = false;
|
|
212
238
|
let skipInstall = false;
|
|
213
239
|
let yes = false;
|
|
240
|
+
|
|
241
|
+
if (OPERATE_COMMANDS.has(argv[0])) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`\`${argv[0]}\` is an operate command, but \`npx @selvajs/cli\` runs the scaffolder.\n` +
|
|
244
|
+
` Run it through the deployment's own CLI instead:\n` +
|
|
245
|
+
` npx selva ${argv.join(' ')}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
214
249
|
for (const arg of argv) {
|
|
215
250
|
if (arg === '--force') force = true;
|
|
216
251
|
else if (arg === '--skip-install') skipInstall = true;
|
|
@@ -224,9 +259,7 @@ function parseArgs(argv) {
|
|
|
224
259
|
}
|
|
225
260
|
}
|
|
226
261
|
if (!dir) {
|
|
227
|
-
throw new Error(
|
|
228
|
-
'Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]'
|
|
229
|
-
);
|
|
262
|
+
throw new Error('Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]');
|
|
230
263
|
}
|
|
231
264
|
return { dir, force, skipInstall, yes };
|
|
232
265
|
}
|
|
@@ -236,44 +269,6 @@ function envBool(v) {
|
|
|
236
269
|
return ['1', 'true', 'yes'].includes(String(v).toLowerCase());
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
// Depends on @selvajs/selva (prebuilt) + @selvajs/cli (operator tool).
|
|
240
|
-
// @selvajs/cli links the `selva` bin; without it, only global CLI works.
|
|
241
|
-
function buildPackageJson(name /*, values */) {
|
|
242
|
-
const deps = {
|
|
243
|
-
'@selvajs/cli': 'latest',
|
|
244
|
-
'@selvajs/selva': 'latest',
|
|
245
|
-
// Deployment-local pm2 (pinned exact to prevent daemon version skew).
|
|
246
|
-
pm2: '5.4.3'
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const pkg = {
|
|
250
|
-
name: sanitizePackageName(name),
|
|
251
|
-
version: '0.1.0',
|
|
252
|
-
private: true,
|
|
253
|
-
type: 'module',
|
|
254
|
-
scripts: {
|
|
255
|
-
start: 'selva start',
|
|
256
|
-
stop: 'selva stop',
|
|
257
|
-
restart: 'selva restart',
|
|
258
|
-
logs: 'selva logs',
|
|
259
|
-
doctor: 'selva doctor',
|
|
260
|
-
update: 'selva update'
|
|
261
|
-
},
|
|
262
|
-
dependencies: deps
|
|
263
|
-
};
|
|
264
|
-
return JSON.stringify(pkg, null, 2);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function sanitizePackageName(name) {
|
|
268
|
-
// npm package names: lowercase, no spaces, limited punctuation.
|
|
269
|
-
return (
|
|
270
|
-
name
|
|
271
|
-
.toLowerCase()
|
|
272
|
-
.replace(/[^a-z0-9._-]+/g, '-')
|
|
273
|
-
.replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
272
|
function writeGitignore(dir) {
|
|
278
273
|
const path = join(dir, '.gitignore');
|
|
279
274
|
if (existsSync(path)) return;
|