@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/init.js
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
// `selva init` —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// • Reads current .env values; uses them as prompt defaults.
|
|
5
|
-
// • Never regenerates SELVA_HMAC_KEY / SELVA_AT_REST_KEY if they're set.
|
|
6
|
-
// Rotating those invalidates sessions and at-rest encryption — that's
|
|
7
|
-
// `selva keys rotate`'s job, not init's.
|
|
8
|
-
// • Doesn't touch package.json or run npm install.
|
|
1
|
+
// `selva init` — reconfigures an existing deployment. Unlike `create`, it reads
|
|
2
|
+
// the current .env as prompt defaults and never touches package.json or runs
|
|
3
|
+
// npm install.
|
|
9
4
|
|
|
10
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
5
|
import { join } from 'node:path';
|
|
12
6
|
import * as p from '@clack/prompts';
|
|
13
7
|
import pc from 'picocolors';
|
|
14
8
|
import { collectConfig } from '../prompts.js';
|
|
15
9
|
import { readEnvFile, writeEnvFile } from '../env.js';
|
|
16
10
|
import { generateKey } from '../secrets.js';
|
|
17
|
-
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
|
+
import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
18
12
|
|
|
19
13
|
export async function runInit() {
|
|
20
14
|
const dir = resolveDeploymentDir();
|
|
@@ -24,10 +18,10 @@ export async function runInit() {
|
|
|
24
18
|
const current = readEnvFile(envPath);
|
|
25
19
|
const values = await collectConfig({ defaults: current, mode: 'init' });
|
|
26
20
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
21
|
+
// Never regenerate an existing key: rotating SELVA_HMAC_KEY or
|
|
22
|
+
// SELVA_AT_REST_KEY invalidates sessions and at-rest encryption — that's
|
|
23
|
+
// `selva keys rotate`'s job, not init's. Missing keys (a scaffold that
|
|
24
|
+
// bailed mid-flight) are the one case it's safe to generate here.
|
|
31
25
|
if (
|
|
32
26
|
current.SELVA_HMAC_KEY &&
|
|
33
27
|
current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
|
|
@@ -48,24 +42,11 @@ export async function runInit() {
|
|
|
48
42
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
'node_modules',
|
|
57
|
-
'@selvajs',
|
|
58
|
-
'selva',
|
|
59
|
-
'templates',
|
|
60
|
-
'.env.example'
|
|
61
|
-
);
|
|
62
|
-
const template = existsSync(templatePath)
|
|
63
|
-
? readFileSync(templatePath, 'utf8')
|
|
64
|
-
: existsSync(envPath)
|
|
65
|
-
? readFileSync(envPath, 'utf8')
|
|
66
|
-
: '';
|
|
67
|
-
|
|
68
|
-
writeEnvFile(envPath, template, values);
|
|
45
|
+
// A values-only rewrite drops anything it isn't handed, and the prompts cover
|
|
46
|
+
// only the vars they ask about — every tuned knob the operator set by hand
|
|
47
|
+
// (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
|
|
48
|
+
// the existing file forward and let the prompt answers win on top.
|
|
49
|
+
writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
|
|
69
50
|
|
|
70
51
|
p.outro(
|
|
71
52
|
[
|
package/src/commands/keys.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Rotate SELVA_HMAC_KEY or SELVA_AT_REST_KEY; requires explicit confirm (blast radius in TARGETS).
|
|
2
2
|
|
|
3
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import * as p from '@clack/prompts';
|
|
6
5
|
import pc from 'picocolors';
|
|
7
6
|
import { readEnvFile, writeEnvFile } from '../env.js';
|
|
8
7
|
import { generateKey } from '../secrets.js';
|
|
9
|
-
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
8
|
+
import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
10
9
|
|
|
11
10
|
const TARGETS = {
|
|
12
11
|
hmac: {
|
|
@@ -57,19 +56,7 @@ export async function runKeysRotate(argv) {
|
|
|
57
56
|
const fresh = generateKey();
|
|
58
57
|
current[envVar] = fresh;
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
dir,
|
|
62
|
-
'node_modules',
|
|
63
|
-
'@selvajs',
|
|
64
|
-
'selva',
|
|
65
|
-
'templates',
|
|
66
|
-
'.env.example'
|
|
67
|
-
);
|
|
68
|
-
const template = existsSync(templatePath)
|
|
69
|
-
? readFileSync(templatePath, 'utf8')
|
|
70
|
-
: readFileSync(envPath, 'utf8');
|
|
71
|
-
|
|
72
|
-
writeEnvFile(envPath, template, current);
|
|
59
|
+
writeEnvFile(envPath, readEnvTemplate(dir), current);
|
|
73
60
|
|
|
74
61
|
p.outro(
|
|
75
62
|
[
|
package/src/commands/migrate.js
CHANGED
|
@@ -1,43 +1,116 @@
|
|
|
1
|
-
//
|
|
2
|
-
// provider packages),
|
|
1
|
+
// Migrates a deployment to the current layout: rewrites package.json (drops legacy
|
|
2
|
+
// provider packages), removes stale selva.config.js, updates ecosystem.config.cjs.
|
|
3
3
|
// Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
|
|
4
4
|
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import pc from 'picocolors';
|
|
10
10
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
11
|
import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
|
|
12
|
+
import {
|
|
13
|
+
buildDeploymentPackageJson,
|
|
14
|
+
CANONICAL_FIELDS,
|
|
15
|
+
DEPENDENCIES,
|
|
16
|
+
isFloatingPin,
|
|
17
|
+
LEGACY_DEPENDENCIES,
|
|
18
|
+
npmDistTagVersion,
|
|
19
|
+
OVERRIDES,
|
|
20
|
+
resolveSelvaPins,
|
|
21
|
+
SELVA_PACKAGES
|
|
22
|
+
} from '../deployment-package.js';
|
|
23
|
+
import { APP_NAME, runPm2 } from './pm2.js';
|
|
24
|
+
|
|
25
|
+
// Where migrate parks the old node_modules while npm installs the new one.
|
|
26
|
+
// Left behind only if the process is killed mid-migration; `selva doctor`
|
|
27
|
+
// flags a surviving stash as an interrupted migration.
|
|
28
|
+
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
29
|
+
|
|
30
|
+
// Matches the backups migrate writes: `.env.2026-08-10T05-43-12.bak`. Anchored
|
|
31
|
+
// at both ends so an operator's own `notes.bak` is never picked up as ours.
|
|
32
|
+
export const BACKUP_PATTERN = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.bak$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Colons are illegal in Windows filenames, so this is ISO with `:` swapped for
|
|
36
|
+
* `-` and the fractional seconds dropped: 2026-08-10T05-43-12.
|
|
37
|
+
*/
|
|
38
|
+
export function backupStamp(now = new Date()) {
|
|
39
|
+
return now
|
|
40
|
+
.toISOString()
|
|
41
|
+
.replace(/\.\d+Z$/, '')
|
|
42
|
+
.replace(/:/g, '-');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A migration used to write `package.json.bak` at a fixed path, so the second
|
|
47
|
+
* substantive migration overwrote the first's copy — and since each run backs
|
|
48
|
+
* up the *current* file, that left the operator's original config recoverable
|
|
49
|
+
* from nowhere (#184). One stamp per run keeps a whole migration's backups
|
|
50
|
+
* together and makes them sortable.
|
|
51
|
+
*
|
|
52
|
+
* Not auto-pruned here: deleting an operator's only copy of their old config is
|
|
53
|
+
* worse than a few small files accumulating. `selva doctor` reports aged ones
|
|
54
|
+
* with a `--fix`, which keeps the decision theirs.
|
|
55
|
+
*/
|
|
56
|
+
export function backupPathFor(filePath, stamp) {
|
|
57
|
+
return `${filePath}.${stamp}.bak`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Old enough that the migration which wrote it is long settled.
|
|
61
|
+
export const BACKUP_AGE_DAYS = 30;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Split migration backups into what to keep and what `doctor --fix` may delete.
|
|
65
|
+
*
|
|
66
|
+
* The newest run's backups are always kept, however old they are — on a
|
|
67
|
+
* deployment that migrates once a year, age alone would delete the only copy of
|
|
68
|
+
* the pre-migration config, which is the whole point of keeping them. So the
|
|
69
|
+
* rule is: keep the newest stamp, offer to delete stamps older than
|
|
70
|
+
* `BACKUP_AGE_DAYS`.
|
|
71
|
+
*
|
|
72
|
+
* `names` are basenames; the stamp is read from the filename rather than mtime,
|
|
73
|
+
* which a copy or restore would reset.
|
|
74
|
+
*/
|
|
75
|
+
export function classifyBackups(names, now = new Date(), maxAgeDays = BACKUP_AGE_DAYS) {
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const m = BACKUP_PATTERN.exec(name);
|
|
79
|
+
if (m) found.push({ name, stamp: m[1] });
|
|
80
|
+
}
|
|
81
|
+
if (found.length === 0) return { keep: [], aged: [], newest: null };
|
|
82
|
+
|
|
83
|
+
// Stamps are fixed-width and zero-padded, so lexical order is chronological.
|
|
84
|
+
const newest = found.reduce((a, b) => (b.stamp > a.stamp ? b : a)).stamp;
|
|
85
|
+
const cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
const keep = [];
|
|
88
|
+
const aged = [];
|
|
89
|
+
for (const entry of found) {
|
|
90
|
+
// `2026-08-10T05-43-12` → parseable by restoring the colons.
|
|
91
|
+
const at = Date.parse(entry.stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3') + 'Z');
|
|
92
|
+
if (entry.stamp === newest || !Number.isFinite(at) || at >= cutoff) keep.push(entry.name);
|
|
93
|
+
else aged.push(entry.name);
|
|
94
|
+
}
|
|
95
|
+
return { keep, aged, newest };
|
|
96
|
+
}
|
|
12
97
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const CANONICAL_SCRIPTS = {
|
|
31
|
-
start: 'selva start',
|
|
32
|
-
stop: 'selva stop',
|
|
33
|
-
restart: 'selva restart',
|
|
34
|
-
logs: 'selva logs',
|
|
35
|
-
doctor: 'selva doctor',
|
|
36
|
-
update: 'selva update'
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export async function runMigrate() {
|
|
40
|
-
const dir = resolveDeploymentDir();
|
|
98
|
+
// `deps` are seams for tests: the rollback path destroys and restores a real
|
|
99
|
+
// dependency tree, so it has to run against a real directory — but not against
|
|
100
|
+
// a real npm or pm2 daemon.
|
|
101
|
+
export async function runMigrate(
|
|
102
|
+
_argv,
|
|
103
|
+
{
|
|
104
|
+
dir: injectedDir,
|
|
105
|
+
// --prefer-online: npm can otherwise resolve a cached packument that
|
|
106
|
+
// predates the release being migrated to.
|
|
107
|
+
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
108
|
+
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
109
|
+
pm2 = runPm2,
|
|
110
|
+
resolveVersion = npmDistTagVersion
|
|
111
|
+
} = {}
|
|
112
|
+
) {
|
|
113
|
+
const dir = injectedDir ?? resolveDeploymentDir();
|
|
41
114
|
requireDeploymentDir(dir);
|
|
42
115
|
|
|
43
116
|
p.intro(pc.bgCyan(pc.black(' selva migrate ')));
|
|
@@ -49,11 +122,9 @@ export async function runMigrate() {
|
|
|
49
122
|
}
|
|
50
123
|
|
|
51
124
|
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
52
|
-
const target = buildTargetPackageJson(before);
|
|
125
|
+
const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
|
|
53
126
|
const pkgDiff = diffPackageJson(before, target);
|
|
54
127
|
|
|
55
|
-
// Side-files: selva.config.js (now unused) and ecosystem.config.cjs
|
|
56
|
-
// (must point at @selvajs/selva, not @selvajs/runtime).
|
|
57
128
|
const configPath = join(dir, 'selva.config.js');
|
|
58
129
|
const hasStaleConfig = existsSync(configPath);
|
|
59
130
|
|
|
@@ -61,9 +132,9 @@ export async function runMigrate() {
|
|
|
61
132
|
const ecoHasStaleRuntime =
|
|
62
133
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
63
134
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
135
|
+
// The server drops its read-old-name shim after one minor version, so
|
|
136
|
+
// rewriting now is what keeps a tuned value tuned instead of silently
|
|
137
|
+
// reverting to defaults later.
|
|
67
138
|
const envPath = join(dir, '.env');
|
|
68
139
|
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
69
140
|
const envRename = envBefore
|
|
@@ -96,34 +167,61 @@ export async function runMigrate() {
|
|
|
96
167
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
97
168
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
98
169
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
170
|
+
// The diff shows the pin that will be written; these explain why it is that
|
|
171
|
+
// pin. A preserved beta and a pin that couldn't be resolved both look like
|
|
172
|
+
// "no change" in the diff, and the second one is a stale pin, not a stable one.
|
|
173
|
+
for (const note of pinNotes) {
|
|
174
|
+
if (note.kind === 'prerelease') {
|
|
175
|
+
p.log.info(
|
|
176
|
+
`${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
|
|
177
|
+
`means newest stable and would move this deployment backwards.`
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
p.log.warn(
|
|
181
|
+
`Could not resolve the current version of ${note.name} (${note.reason}). ` +
|
|
182
|
+
`Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
103
188
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
104
189
|
p.cancel('Cancelled.');
|
|
105
190
|
return;
|
|
106
191
|
}
|
|
107
192
|
|
|
108
|
-
// Stop pm2 before npm rewrites
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
193
|
+
// Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
|
|
194
|
+
// under a running server serves a broken page (same reason `update` stops first).
|
|
195
|
+
// A legacy deployment may predate the local-pm2 layout, so a missing binary
|
|
196
|
+
// isn't fatal here: there's nothing running for us to stop.
|
|
197
|
+
try {
|
|
198
|
+
const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
199
|
+
if (stopStatus !== 0) {
|
|
200
|
+
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
112
204
|
}
|
|
113
205
|
|
|
114
|
-
//
|
|
115
|
-
|
|
206
|
+
// One stamp for the whole run, so a migration's backups sort together and a
|
|
207
|
+
// later migration cannot overwrite this one's (#184).
|
|
208
|
+
const stamp = backupStamp();
|
|
209
|
+
const bakPath = backupPathFor(pkgPath, stamp);
|
|
210
|
+
const configBak = backupPathFor(configPath, stamp);
|
|
211
|
+
const ecoBak = backupPathFor(ecoPath, stamp);
|
|
212
|
+
const envBak = backupPathFor(envPath, stamp);
|
|
213
|
+
|
|
116
214
|
copyFileSync(pkgPath, bakPath);
|
|
117
215
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
118
216
|
|
|
119
217
|
if (hasStaleConfig) {
|
|
120
|
-
copyFileSync(configPath,
|
|
218
|
+
copyFileSync(configPath, configBak);
|
|
121
219
|
rmSync(configPath, { force: true });
|
|
122
220
|
}
|
|
123
221
|
|
|
124
222
|
if (ecoHasStaleRuntime) {
|
|
125
|
-
copyFileSync(ecoPath,
|
|
126
|
-
//
|
|
223
|
+
copyFileSync(ecoPath, ecoBak);
|
|
224
|
+
// Text substitution, not regeneration, so any operator customizations to the file survive.
|
|
127
225
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
128
226
|
/@selvajs\/runtime/g,
|
|
129
227
|
'@selvajs/selva'
|
|
@@ -132,47 +230,79 @@ export async function runMigrate() {
|
|
|
132
230
|
}
|
|
133
231
|
|
|
134
232
|
if (envRename.changes.length > 0) {
|
|
135
|
-
copyFileSync(envPath,
|
|
233
|
+
copyFileSync(envPath, envBak);
|
|
136
234
|
writeFileSync(envPath, envRename.text, 'utf8');
|
|
137
235
|
}
|
|
138
236
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
237
|
+
// A legacy lockfile pins the old package set across a major bump, so this needs
|
|
238
|
+
// a clean install. But node_modules is also where pm2 lives, and rollback has to
|
|
239
|
+
// restart the app — deleting it outright would leave a failed migration with no
|
|
240
|
+
// way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
|
|
241
|
+
// until the install succeeds.
|
|
242
|
+
const nodeModules = join(dir, 'node_modules');
|
|
243
|
+
const nodeModulesBak = join(dir, NODE_MODULES_STASH);
|
|
244
|
+
const lockPath = join(dir, 'package-lock.json');
|
|
245
|
+
|
|
246
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
247
|
+
if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
|
|
248
|
+
const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
|
|
249
|
+
rmSync(lockPath, { force: true });
|
|
142
250
|
|
|
143
251
|
const s = p.spinner();
|
|
144
252
|
s.start('Installing new dependencies (this can take a minute)');
|
|
145
253
|
try {
|
|
146
|
-
|
|
147
|
-
// docs/Hotfix-CLI-Runtime.md.
|
|
148
|
-
execSync('npm install --prefer-online', {
|
|
149
|
-
cwd: dir,
|
|
150
|
-
stdio: 'pipe'
|
|
151
|
-
});
|
|
254
|
+
install(dir);
|
|
152
255
|
s.stop('Dependencies installed');
|
|
256
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
153
257
|
} catch (err) {
|
|
154
|
-
s.stop(pc.red('npm install failed — rolling back
|
|
258
|
+
s.stop(pc.red('npm install failed — rolling back'));
|
|
155
259
|
copyFileSync(bakPath, pkgPath);
|
|
156
|
-
if (hasStaleConfig && existsSync(
|
|
157
|
-
copyFileSync(
|
|
260
|
+
if (hasStaleConfig && existsSync(configBak)) {
|
|
261
|
+
copyFileSync(configBak, configPath);
|
|
158
262
|
}
|
|
159
|
-
if (ecoHasStaleRuntime && existsSync(
|
|
160
|
-
copyFileSync(
|
|
263
|
+
if (ecoHasStaleRuntime && existsSync(ecoBak)) {
|
|
264
|
+
copyFileSync(ecoBak, ecoPath);
|
|
161
265
|
}
|
|
162
|
-
if (envRename.changes.length > 0 && existsSync(
|
|
163
|
-
copyFileSync(
|
|
266
|
+
if (envRename.changes.length > 0 && existsSync(envBak)) {
|
|
267
|
+
copyFileSync(envBak, envPath);
|
|
164
268
|
}
|
|
165
|
-
|
|
166
|
-
|
|
269
|
+
|
|
270
|
+
// A half-installed node_modules from the failed attempt is worse than the old
|
|
271
|
+
// one, so clear it before restoring the stash.
|
|
272
|
+
rmSync(nodeModules, { recursive: true, force: true });
|
|
273
|
+
if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
|
|
274
|
+
if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
|
|
275
|
+
|
|
276
|
+
restartAfterRollback(dir, pm2);
|
|
167
277
|
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
168
278
|
process.exit(1);
|
|
169
279
|
}
|
|
170
280
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
281
|
+
// Migration itself already succeeded at this point, so a failed pm2 start is reported
|
|
282
|
+
// but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
|
|
283
|
+
// running process isn't. Contrast restartAfterRollback, which does exit 1: that path
|
|
284
|
+
// runs after a rollback, where a failed restart leaves nothing serving traffic at all.
|
|
285
|
+
let status;
|
|
286
|
+
try {
|
|
287
|
+
status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
status = 1;
|
|
290
|
+
p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
|
|
291
|
+
}
|
|
292
|
+
// A pin change means the just-installed pm2 CLI and the still-running
|
|
293
|
+
// daemon are now different versions. That's the repairable skew direction
|
|
294
|
+
// (daemon older), but it stays skewed until the operator acts — say so
|
|
295
|
+
// here, once, instead of letting doctor nag forever. Deliberately NOT
|
|
296
|
+
// automated: `pm2 update` recycles the daemon, and doing that unattended
|
|
297
|
+
// inside a migration converts a mid-flight failure into an outage (see
|
|
298
|
+
// plans/fixes/host-prerequisites-and-pm2-audit.md).
|
|
299
|
+
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
300
|
+
if (pm2Notice) p.log.warn(pm2Notice);
|
|
301
|
+
|
|
302
|
+
const backupHints = [`package.json.${stamp}.bak`];
|
|
303
|
+
if (hasStaleConfig) backupHints.push(`selva.config.js.${stamp}.bak`);
|
|
304
|
+
if (ecoHasStaleRuntime) backupHints.push(`ecosystem.config.cjs.${stamp}.bak`);
|
|
305
|
+
if (envRename.changes.length > 0) backupHints.push(`.env.${stamp}.bak`);
|
|
176
306
|
|
|
177
307
|
if (status === 0) {
|
|
178
308
|
p.outro(
|
|
@@ -187,23 +317,37 @@ export async function runMigrate() {
|
|
|
187
317
|
}
|
|
188
318
|
}
|
|
189
319
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
'
|
|
196
|
-
|
|
197
|
-
|
|
320
|
+
// Never throws: the migration is already aborting, and an exception here would
|
|
321
|
+
// replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
322
|
+
// the operator's problem to act on, so it gets logged, not swallowed.
|
|
323
|
+
function restartAfterRollback(dir, pm2 = runPm2) {
|
|
324
|
+
try {
|
|
325
|
+
const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
326
|
+
if (status === 0) {
|
|
327
|
+
p.log.success(`Rolled back and restarted ${APP_NAME}.`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
p.log.error(
|
|
331
|
+
`Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
|
|
332
|
+
`check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
|
|
333
|
+
);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
p.log.error(
|
|
336
|
+
`Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
|
|
337
|
+
`The app is likely down — run \`npm install\` then \`selva start\`.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
198
341
|
|
|
199
|
-
|
|
342
|
+
function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
|
|
343
|
+
const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
|
|
344
|
+
const pkg = buildDeploymentPackageJson({
|
|
200
345
|
name: current.name ?? 'selva-deployment',
|
|
201
346
|
version: current.version ?? '0.1.0',
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
};
|
|
347
|
+
engines: current.engines,
|
|
348
|
+
dependencies: pins
|
|
349
|
+
});
|
|
350
|
+
return { pkg, notes };
|
|
207
351
|
}
|
|
208
352
|
|
|
209
353
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -222,6 +366,19 @@ function diffPackageJson(before, after) {
|
|
|
222
366
|
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
223
367
|
}
|
|
224
368
|
|
|
369
|
+
// Without this section, an overrides-only change reads as "already on the
|
|
370
|
+
// current layout" and the security shim never reaches existing deployments.
|
|
371
|
+
const beforeOverrides = before.overrides ?? {};
|
|
372
|
+
const afterOverrides = after.overrides ?? {};
|
|
373
|
+
const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
|
|
374
|
+
for (const name of [...allOverrides].sort()) {
|
|
375
|
+
const a = beforeOverrides[name];
|
|
376
|
+
const b = afterOverrides[name];
|
|
377
|
+
if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
|
|
378
|
+
else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
|
|
379
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
225
382
|
const beforeScripts = before.scripts ?? {};
|
|
226
383
|
const afterScripts = after.scripts ?? {};
|
|
227
384
|
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
@@ -235,39 +392,67 @@ function diffPackageJson(before, after) {
|
|
|
235
392
|
}
|
|
236
393
|
}
|
|
237
394
|
|
|
395
|
+
// Anything outside the canonical set is discarded by the rewrite — show it here
|
|
396
|
+
// too, or the operator confirms a change (e.g. losing devDependencies) they
|
|
397
|
+
// never saw.
|
|
398
|
+
for (const key of Object.keys(before).sort()) {
|
|
399
|
+
if (CANONICAL_FIELDS.has(key)) continue;
|
|
400
|
+
lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
238
403
|
return lines;
|
|
239
404
|
}
|
|
240
405
|
|
|
241
|
-
//
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
stdio: inherit ? 'inherit' : 'pipe',
|
|
248
|
-
shell: process.platform === 'win32'
|
|
249
|
-
});
|
|
250
|
-
if (result.error) return 1;
|
|
251
|
-
return result.status ?? 0;
|
|
406
|
+
// One-line preview of a dropped field, enough to recognise what is being lost.
|
|
407
|
+
function summarize(value) {
|
|
408
|
+
if (value === null || typeof value !== 'object') return String(value);
|
|
409
|
+
const keys = Object.keys(value);
|
|
410
|
+
if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
|
|
411
|
+
return keys.length ? `{${keys.join(', ')}}` : '{}';
|
|
252
412
|
}
|
|
253
413
|
|
|
254
414
|
// Exported for `selva doctor` to check layout drift without duplication.
|
|
255
415
|
export function detectDrift(pkgJson, dir) {
|
|
256
416
|
const deps = pkgJson?.dependencies ?? {};
|
|
257
417
|
const reasons = [];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
reasons.push(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
418
|
+
|
|
419
|
+
for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
|
|
420
|
+
if (deps[name]) reasons.push(`${name} is ${why}`);
|
|
421
|
+
}
|
|
422
|
+
for (const name of Object.keys(DEPENDENCIES)) {
|
|
423
|
+
if (!deps[name]) reasons.push(`${name} is missing`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// pm2 is the one dependency pinned exactly, so a version difference is drift
|
|
427
|
+
// rather than an operator preference. Presence alone was checked above and
|
|
428
|
+
// isn't enough: a deployment scaffolded against an older CLI keeps its old
|
|
429
|
+
// pm2 forever, which is how the pin reached a two-major gap unnoticed.
|
|
430
|
+
if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
|
|
431
|
+
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A stored dist-tag re-resolves on every `npm install`, so the deployment
|
|
435
|
+
// follows the tag instead of a version an operator chose. Worse on a
|
|
436
|
+
// prerelease line, where `latest` points at the older stable release and the
|
|
437
|
+
// "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
|
|
438
|
+
// tags, so existing deployments carry one and nothing else reports it.
|
|
439
|
+
for (const name of SELVA_PACKAGES) {
|
|
440
|
+
if (deps[name] && isFloatingPin(deps[name])) {
|
|
441
|
+
reasons.push(
|
|
442
|
+
`${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
|
|
443
|
+
`(re-resolves on every install; migrate writes a concrete version)`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Overrides are security shims for vulnerable transitive deps — a
|
|
449
|
+
// deployment without them installs the vulnerable version on its next
|
|
450
|
+
// `npm install`, silently.
|
|
451
|
+
for (const [name, version] of Object.entries(OVERRIDES)) {
|
|
452
|
+
if (pkgJson?.overrides?.[name] !== version) {
|
|
453
|
+
reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
271
456
|
|
|
272
457
|
if (dir) {
|
|
273
458
|
const configPath = join(dir, 'selva.config.js');
|
|
@@ -290,5 +475,26 @@ export function detectDrift(pkgJson, dir) {
|
|
|
290
475
|
return reasons;
|
|
291
476
|
}
|
|
292
477
|
|
|
293
|
-
//
|
|
294
|
-
|
|
478
|
+
// Null when the pin didn't change. Exported for its test — the wording is the
|
|
479
|
+
// operator's only pointer to finish the upgrade.
|
|
480
|
+
export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
481
|
+
if (!beforeVersion || beforeVersion === afterVersion) return null;
|
|
482
|
+
return [
|
|
483
|
+
`pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
|
|
484
|
+
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
485
|
+
``,
|
|
486
|
+
` npx pm2 update`,
|
|
487
|
+
` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
|
|
488
|
+
` npx pm2 save`,
|
|
489
|
+
``,
|
|
490
|
+
`Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
|
|
491
|
+
`process table before restoring it, so saving a failed restore overwrites`,
|
|
492
|
+
`~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
|
|
493
|
+
`recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
|
|
494
|
+
``,
|
|
495
|
+
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
496
|
+
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
497
|
+
].join('\n');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export { buildTargetPackageJson, diffPackageJson };
|