@selvajs/cli 4.7.4-beta.1 → 4.8.0-beta.11
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 +10 -4
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +89 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +289 -0
- package/src/cli.js +2 -2
- package/src/commands/create.js +43 -69
- package/src/commands/doctor.js +151 -170
- package/src/commands/init.js +9 -32
- package/src/commands/keys.js +3 -17
- package/src/commands/migrate.js +225 -105
- package/src/commands/pm2.js +45 -9
- package/src/deployment-package.js +143 -0
- package/src/env.js +57 -3
- package/src/node-range.js +66 -0
- package/src/paths.js +68 -5
- package/src/prompts.js +4 -16
- package/bin/create.js +0 -7
package/src/commands/migrate.js
CHANGED
|
@@ -1,48 +1,43 @@
|
|
|
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 {
|
|
6
|
-
existsSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
copyFileSync,
|
|
10
|
-
rmSync
|
|
11
|
-
} from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
|
|
12
6
|
import { join } from 'node:path';
|
|
13
|
-
import {
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
14
8
|
import * as p from '@clack/prompts';
|
|
15
9
|
import pc from 'picocolors';
|
|
16
10
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
11
|
+
import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
|
|
12
|
+
import {
|
|
13
|
+
buildDeploymentPackageJson,
|
|
14
|
+
CANONICAL_FIELDS,
|
|
15
|
+
DEPENDENCIES,
|
|
16
|
+
LEGACY_DEPENDENCIES,
|
|
17
|
+
OVERRIDES
|
|
18
|
+
} from '../deployment-package.js';
|
|
19
|
+
import { APP_NAME, runPm2 } from './pm2.js';
|
|
20
|
+
|
|
21
|
+
// Where migrate parks the old node_modules while npm installs the new one.
|
|
22
|
+
// Left behind only if the process is killed mid-migration; `selva doctor`
|
|
23
|
+
// flags a surviving stash as an interrupted migration.
|
|
24
|
+
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
25
|
+
|
|
26
|
+
// `deps` are seams for tests: the rollback path destroys and restores a real
|
|
27
|
+
// dependency tree, so it has to run against a real directory — but not against
|
|
28
|
+
// a real npm or pm2 daemon.
|
|
29
|
+
export async function runMigrate(
|
|
30
|
+
_argv,
|
|
31
|
+
{
|
|
32
|
+
dir: injectedDir,
|
|
33
|
+
// --prefer-online: npm can otherwise resolve a cached packument that
|
|
34
|
+
// predates the release being migrated to.
|
|
35
|
+
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
36
|
+
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
37
|
+
pm2 = runPm2
|
|
38
|
+
} = {}
|
|
39
|
+
) {
|
|
40
|
+
const dir = injectedDir ?? resolveDeploymentDir();
|
|
46
41
|
requireDeploymentDir(dir);
|
|
47
42
|
|
|
48
43
|
p.intro(pc.bgCyan(pc.black(' selva migrate ')));
|
|
@@ -57,8 +52,6 @@ export async function runMigrate() {
|
|
|
57
52
|
const target = buildTargetPackageJson(before);
|
|
58
53
|
const pkgDiff = diffPackageJson(before, target);
|
|
59
54
|
|
|
60
|
-
// Side-files: selva.config.js (now unused) and ecosystem.config.cjs
|
|
61
|
-
// (must point at @selvajs/selva, not @selvajs/runtime).
|
|
62
55
|
const configPath = join(dir, 'selva.config.js');
|
|
63
56
|
const hasStaleConfig = existsSync(configPath);
|
|
64
57
|
|
|
@@ -66,9 +59,31 @@ export async function runMigrate() {
|
|
|
66
59
|
const ecoHasStaleRuntime =
|
|
67
60
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
68
61
|
|
|
62
|
+
// The server drops its read-old-name shim after one minor version, so
|
|
63
|
+
// rewriting now is what keeps a tuned value tuned instead of silently
|
|
64
|
+
// reverting to defaults later.
|
|
65
|
+
const envPath = join(dir, '.env');
|
|
66
|
+
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
67
|
+
const envRename = envBefore
|
|
68
|
+
? renameEnvKeys(envBefore, RENAMED_ENV_VARS)
|
|
69
|
+
: { text: '', changes: [] };
|
|
70
|
+
|
|
69
71
|
const sideFileChanges = [];
|
|
70
|
-
if (hasStaleConfig)
|
|
71
|
-
|
|
72
|
+
if (hasStaleConfig)
|
|
73
|
+
sideFileChanges.push(
|
|
74
|
+
`${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
|
|
75
|
+
);
|
|
76
|
+
if (ecoHasStaleRuntime)
|
|
77
|
+
sideFileChanges.push(
|
|
78
|
+
`${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
|
|
79
|
+
);
|
|
80
|
+
for (const [oldName, newName, action] of envRename.changes) {
|
|
81
|
+
sideFileChanges.push(
|
|
82
|
+
action === 'dropped'
|
|
83
|
+
? `${pc.red('-')} .env ${pc.dim(`${oldName} (deprecated; ${newName} is already set)`)}`
|
|
84
|
+
: `${pc.yellow('~')} .env ${pc.dim(`${oldName} → ${newName}`)}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
72
87
|
|
|
73
88
|
if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
|
|
74
89
|
p.outro(pc.green('Already on the current layout — nothing to migrate.'));
|
|
@@ -79,22 +94,25 @@ export async function runMigrate() {
|
|
|
79
94
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
80
95
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
81
96
|
|
|
82
|
-
const confirmed = await
|
|
83
|
-
message: 'Apply these changes, reinstall, and restart?',
|
|
84
|
-
initialValue: true
|
|
85
|
-
});
|
|
97
|
+
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
86
98
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
87
99
|
p.cancel('Cancelled.');
|
|
88
100
|
return;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
// Stop pm2 before npm rewrites
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
// Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
|
|
104
|
+
// under a running server serves a broken page (same reason `update` stops first).
|
|
105
|
+
// A legacy deployment may predate the local-pm2 layout, so a missing binary
|
|
106
|
+
// isn't fatal here: there's nothing running for us to stop.
|
|
107
|
+
try {
|
|
108
|
+
const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
109
|
+
if (stopStatus !== 0) {
|
|
110
|
+
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
95
114
|
}
|
|
96
115
|
|
|
97
|
-
// Back up for rollback on npm-install failure.
|
|
98
116
|
const bakPath = pkgPath + '.bak';
|
|
99
117
|
copyFileSync(pkgPath, bakPath);
|
|
100
118
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
@@ -106,7 +124,7 @@ export async function runMigrate() {
|
|
|
106
124
|
|
|
107
125
|
if (ecoHasStaleRuntime) {
|
|
108
126
|
copyFileSync(ecoPath, ecoPath + '.bak');
|
|
109
|
-
//
|
|
127
|
+
// Text substitution, not regeneration, so any operator customizations to the file survive.
|
|
110
128
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
111
129
|
/@selvajs\/runtime/g,
|
|
112
130
|
'@selvajs/selva'
|
|
@@ -114,22 +132,33 @@ export async function runMigrate() {
|
|
|
114
132
|
writeFileSync(ecoPath, ecoContent, 'utf8');
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
if (envRename.changes.length > 0) {
|
|
136
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
137
|
+
writeFileSync(envPath, envRename.text, 'utf8');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A legacy lockfile pins the old package set across a major bump, so this needs
|
|
141
|
+
// a clean install. But node_modules is also where pm2 lives, and rollback has to
|
|
142
|
+
// restart the app — deleting it outright would leave a failed migration with no
|
|
143
|
+
// way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
|
|
144
|
+
// until the install succeeds.
|
|
145
|
+
const nodeModules = join(dir, 'node_modules');
|
|
146
|
+
const nodeModulesBak = join(dir, NODE_MODULES_STASH);
|
|
147
|
+
const lockPath = join(dir, 'package-lock.json');
|
|
148
|
+
|
|
149
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
150
|
+
if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
|
|
151
|
+
const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
|
|
152
|
+
rmSync(lockPath, { force: true });
|
|
120
153
|
|
|
121
154
|
const s = p.spinner();
|
|
122
155
|
s.start('Installing new dependencies (this can take a minute)');
|
|
123
156
|
try {
|
|
124
|
-
|
|
125
|
-
// docs/Hotfix-CLI-Runtime.md.
|
|
126
|
-
execSync('npm install --prefer-online', {
|
|
127
|
-
cwd: dir,
|
|
128
|
-
stdio: 'pipe'
|
|
129
|
-
});
|
|
157
|
+
install(dir);
|
|
130
158
|
s.stop('Dependencies installed');
|
|
159
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
131
160
|
} catch (err) {
|
|
132
|
-
s.stop(pc.red('npm install failed — rolling back
|
|
161
|
+
s.stop(pc.red('npm install failed — rolling back'));
|
|
133
162
|
copyFileSync(bakPath, pkgPath);
|
|
134
163
|
if (hasStaleConfig && existsSync(configPath + '.bak')) {
|
|
135
164
|
copyFileSync(configPath + '.bak', configPath);
|
|
@@ -137,16 +166,46 @@ export async function runMigrate() {
|
|
|
137
166
|
if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
|
|
138
167
|
copyFileSync(ecoPath + '.bak', ecoPath);
|
|
139
168
|
}
|
|
140
|
-
|
|
141
|
-
|
|
169
|
+
if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
|
|
170
|
+
copyFileSync(envPath + '.bak', envPath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A half-installed node_modules from the failed attempt is worse than the old
|
|
174
|
+
// one, so clear it before restoring the stash.
|
|
175
|
+
rmSync(nodeModules, { recursive: true, force: true });
|
|
176
|
+
if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
|
|
177
|
+
if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
|
|
178
|
+
|
|
179
|
+
restartAfterRollback(dir, pm2);
|
|
142
180
|
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
143
181
|
process.exit(1);
|
|
144
182
|
}
|
|
145
183
|
|
|
146
|
-
|
|
184
|
+
// Migration itself already succeeded at this point, so a failed pm2 start is reported
|
|
185
|
+
// but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
|
|
186
|
+
// running process isn't. Contrast restartAfterRollback, which does exit 1: that path
|
|
187
|
+
// runs after a rollback, where a failed restart leaves nothing serving traffic at all.
|
|
188
|
+
let status;
|
|
189
|
+
try {
|
|
190
|
+
status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
status = 1;
|
|
193
|
+
p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
|
|
194
|
+
}
|
|
195
|
+
// A pin change means the just-installed pm2 CLI and the still-running
|
|
196
|
+
// daemon are now different versions. That's the repairable skew direction
|
|
197
|
+
// (daemon older), but it stays skewed until the operator acts — say so
|
|
198
|
+
// here, once, instead of letting doctor nag forever. Deliberately NOT
|
|
199
|
+
// automated: `pm2 update` recycles the daemon, and doing that unattended
|
|
200
|
+
// inside a migration converts a mid-flight failure into an outage (see
|
|
201
|
+
// plans/fixes/host-prerequisites-and-pm2-audit.md).
|
|
202
|
+
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
203
|
+
if (pm2Notice) p.log.warn(pm2Notice);
|
|
204
|
+
|
|
147
205
|
const backupHints = ['package.json.bak'];
|
|
148
206
|
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
149
207
|
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
208
|
+
if (envRename.changes.length > 0) backupHints.push('.env.bak');
|
|
150
209
|
|
|
151
210
|
if (status === 0) {
|
|
152
211
|
p.outro(
|
|
@@ -161,23 +220,34 @@ export async function runMigrate() {
|
|
|
161
220
|
}
|
|
162
221
|
}
|
|
163
222
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'
|
|
170
|
-
|
|
171
|
-
|
|
223
|
+
// Never throws: the migration is already aborting, and an exception here would
|
|
224
|
+
// replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
225
|
+
// the operator's problem to act on, so it gets logged, not swallowed.
|
|
226
|
+
function restartAfterRollback(dir, pm2 = runPm2) {
|
|
227
|
+
try {
|
|
228
|
+
const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
229
|
+
if (status === 0) {
|
|
230
|
+
p.log.success(`Rolled back and restarted ${APP_NAME}.`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
p.log.error(
|
|
234
|
+
`Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
|
|
235
|
+
`check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
|
|
236
|
+
);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
p.log.error(
|
|
239
|
+
`Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
|
|
240
|
+
`The app is likely down — run \`npm install\` then \`selva start\`.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
172
244
|
|
|
173
|
-
|
|
245
|
+
function buildTargetPackageJson(current) {
|
|
246
|
+
return buildDeploymentPackageJson({
|
|
174
247
|
name: current.name ?? 'selva-deployment',
|
|
175
248
|
version: current.version ?? '0.1.0',
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
scripts: { ...CANONICAL_SCRIPTS },
|
|
179
|
-
dependencies: deps
|
|
180
|
-
};
|
|
249
|
+
engines: current.engines
|
|
250
|
+
});
|
|
181
251
|
}
|
|
182
252
|
|
|
183
253
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -196,6 +266,19 @@ function diffPackageJson(before, after) {
|
|
|
196
266
|
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
197
267
|
}
|
|
198
268
|
|
|
269
|
+
// Without this section, an overrides-only change reads as "already on the
|
|
270
|
+
// current layout" and the security shim never reaches existing deployments.
|
|
271
|
+
const beforeOverrides = before.overrides ?? {};
|
|
272
|
+
const afterOverrides = after.overrides ?? {};
|
|
273
|
+
const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
|
|
274
|
+
for (const name of [...allOverrides].sort()) {
|
|
275
|
+
const a = beforeOverrides[name];
|
|
276
|
+
const b = afterOverrides[name];
|
|
277
|
+
if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
|
|
278
|
+
else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
|
|
279
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
199
282
|
const beforeScripts = before.scripts ?? {};
|
|
200
283
|
const afterScripts = after.scripts ?? {};
|
|
201
284
|
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
@@ -209,38 +292,53 @@ function diffPackageJson(before, after) {
|
|
|
209
292
|
}
|
|
210
293
|
}
|
|
211
294
|
|
|
295
|
+
// Anything outside the canonical set is discarded by the rewrite — show it here
|
|
296
|
+
// too, or the operator confirms a change (e.g. losing devDependencies) they
|
|
297
|
+
// never saw.
|
|
298
|
+
for (const key of Object.keys(before).sort()) {
|
|
299
|
+
if (CANONICAL_FIELDS.has(key)) continue;
|
|
300
|
+
lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
212
303
|
return lines;
|
|
213
304
|
}
|
|
214
305
|
|
|
215
|
-
//
|
|
216
|
-
function
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
stdio: inherit ? 'inherit' : 'pipe',
|
|
222
|
-
shell: process.platform === 'win32'
|
|
223
|
-
});
|
|
224
|
-
if (result.error) return 1;
|
|
225
|
-
return result.status ?? 0;
|
|
306
|
+
// One-line preview of a dropped field, enough to recognise what is being lost.
|
|
307
|
+
function summarize(value) {
|
|
308
|
+
if (value === null || typeof value !== 'object') return String(value);
|
|
309
|
+
const keys = Object.keys(value);
|
|
310
|
+
if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
|
|
311
|
+
return keys.length ? `{${keys.join(', ')}}` : '{}';
|
|
226
312
|
}
|
|
227
313
|
|
|
228
314
|
// Exported for `selva doctor` to check layout drift without duplication.
|
|
229
315
|
export function detectDrift(pkgJson, dir) {
|
|
230
316
|
const deps = pkgJson?.dependencies ?? {};
|
|
231
317
|
const reasons = [];
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
318
|
+
|
|
319
|
+
for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
|
|
320
|
+
if (deps[name]) reasons.push(`${name} is ${why}`);
|
|
321
|
+
}
|
|
322
|
+
for (const name of Object.keys(DEPENDENCIES)) {
|
|
323
|
+
if (!deps[name]) reasons.push(`${name} is missing`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// pm2 is the one dependency pinned exactly, so a version difference is drift
|
|
327
|
+
// rather than an operator preference. Presence alone was checked above and
|
|
328
|
+
// isn't enough: a deployment scaffolded against an older CLI keeps its old
|
|
329
|
+
// pm2 forever, which is how the pin reached a two-major gap unnoticed.
|
|
330
|
+
if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
|
|
331
|
+
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Overrides are security shims for vulnerable transitive deps — a
|
|
335
|
+
// deployment without them installs the vulnerable version on its next
|
|
336
|
+
// `npm install`, silently.
|
|
337
|
+
for (const [name, version] of Object.entries(OVERRIDES)) {
|
|
338
|
+
if (pkgJson?.overrides?.[name] !== version) {
|
|
339
|
+
reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
244
342
|
|
|
245
343
|
if (dir) {
|
|
246
344
|
const configPath = join(dir, 'selva.config.js');
|
|
@@ -251,10 +349,32 @@ export function detectDrift(pkgJson, dir) {
|
|
|
251
349
|
if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
|
|
252
350
|
reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
|
|
253
351
|
}
|
|
352
|
+
const envPath = join(dir, '.env');
|
|
353
|
+
if (existsSync(envPath)) {
|
|
354
|
+
const { changes } = renameEnvKeys(readFileSync(envPath, 'utf8'), RENAMED_ENV_VARS);
|
|
355
|
+
for (const [oldName, newName] of changes) {
|
|
356
|
+
reasons.push(`.env still uses ${oldName} (renamed to ${newName})`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
254
359
|
}
|
|
255
360
|
|
|
256
361
|
return reasons;
|
|
257
362
|
}
|
|
258
363
|
|
|
259
|
-
//
|
|
260
|
-
|
|
364
|
+
// Null when the pin didn't change. Exported for its test — the wording is the
|
|
365
|
+
// operator's only pointer to finish the upgrade.
|
|
366
|
+
export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
367
|
+
if (!beforeVersion || beforeVersion === afterVersion) return null;
|
|
368
|
+
return [
|
|
369
|
+
`pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
|
|
370
|
+
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
371
|
+
``,
|
|
372
|
+
` npx pm2 update`,
|
|
373
|
+
` npx pm2 save`,
|
|
374
|
+
``,
|
|
375
|
+
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
376
|
+
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
377
|
+
].join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export { buildTargetPackageJson, diffPackageJson };
|
package/src/commands/pm2.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Thin wrappers around PM2 commands:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Thin wrappers around PM2 commands: resolve to the deployment-local pm2, resync
|
|
2
|
+
// the daemon before state changes, and pass --update-env on restart so edits to
|
|
3
|
+
// .env actually take effect.
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
@@ -9,10 +9,9 @@ import * as p from '@clack/prompts';
|
|
|
9
9
|
import pc from 'picocolors';
|
|
10
10
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
11
|
|
|
12
|
-
const APP_NAME = 'selva-compute';
|
|
12
|
+
export const APP_NAME = 'selva-compute';
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
function pm2Bin(dir) {
|
|
14
|
+
export function pm2Bin(dir) {
|
|
16
15
|
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
17
16
|
if (!existsSync(local)) {
|
|
18
17
|
throw new Error(
|
|
@@ -25,7 +24,32 @@ function pm2Bin(dir) {
|
|
|
25
24
|
return local;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
//
|
|
27
|
+
// Parses the two versions pm2 prints alongside its out-of-date warning:
|
|
28
|
+
// In memory PM2 version: 6.0.14
|
|
29
|
+
// Local PM2 version: 5.4.3
|
|
30
|
+
// Returns nulls when the shape doesn't match, so callers fall back to resyncing.
|
|
31
|
+
export function parsePm2Skew(output) {
|
|
32
|
+
// eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
|
|
33
|
+
const clean = output.replace(/\[[0-9;]*m/g, '');
|
|
34
|
+
const daemon = /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
|
|
35
|
+
const local = /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
|
|
36
|
+
return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// True when the running daemon is newer than the deployment-local pm2 — i.e. a
|
|
40
|
+
// foreign global pm2 owns it. `pm2 update` can't fix that direction: it would
|
|
41
|
+
// downgrade the daemon and hand it a dump from a newer version, dropping the
|
|
42
|
+
// process table so selva-compute never gets re-registered.
|
|
43
|
+
export function daemonOutranksCli(daemon, local) {
|
|
44
|
+
if (!daemon || !local) return false;
|
|
45
|
+
const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
if (a[i] > b[i]) return true;
|
|
48
|
+
if (a[i] < b[i]) return false;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
function ensurePm2InSync(dir) {
|
|
30
54
|
const bin = pm2Bin(dir);
|
|
31
55
|
const probe = spawnSync(bin, ['ping'], {
|
|
@@ -36,6 +60,16 @@ function ensurePm2InSync(dir) {
|
|
|
36
60
|
const output = (probe.stdout ?? '') + (probe.stderr ?? '');
|
|
37
61
|
if (!/out-of-date/i.test(output)) return;
|
|
38
62
|
|
|
63
|
+
const { daemon, local } = parsePm2Skew(output);
|
|
64
|
+
if (daemonOutranksCli(daemon, local)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`The running PM2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}), ` +
|
|
67
|
+
`so a global pm2 owns it. Running \`pm2 update\` would downgrade the daemon and drop ` +
|
|
68
|
+
`its process table, leaving ${APP_NAME} unregistered. Resolve the conflict first: ` +
|
|
69
|
+
`\`which -a pm2\`, \`pm2 -v\`, \`pm2 ping\`.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
39
73
|
p.log.warn(
|
|
40
74
|
'PM2 in-memory daemon is a different version than the deployment-local pm2 — ' +
|
|
41
75
|
'running `pm2 update` to resync (this briefly restarts managed processes).'
|
|
@@ -53,8 +87,10 @@ function ensurePm2InSync(dir) {
|
|
|
53
87
|
}
|
|
54
88
|
}
|
|
55
89
|
|
|
56
|
-
|
|
57
|
-
|
|
90
|
+
// `bin` lets a caller pass an already-resolved pm2 path instead of resolving
|
|
91
|
+
// fresh from `dir` — needed when node_modules won't exist at call time (e.g.
|
|
92
|
+
// `selva migrate` wipes it mid-flight and passes the binary it stashed first).
|
|
93
|
+
export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
|
|
58
94
|
const result = spawnSync(bin, args, {
|
|
59
95
|
cwd: dir,
|
|
60
96
|
stdio: inherit ? 'inherit' : 'pipe',
|