@selvajs/cli 4.7.4-beta.0 → 4.8.0-beta.10
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 +2 -2
- package/package.json +7 -4
- package/src/checks/boot.js +149 -0
- package/src/checks/config.js +97 -0
- package/src/checks/result.js +27 -0
- package/src/cli.js +1 -1
- package/src/commands/create.js +38 -55
- package/src/commands/doctor.js +143 -145
- package/src/commands/init.js +2 -20
- package/src/commands/keys.js +3 -17
- package/src/commands/migrate.js +169 -98
- package/src/commands/pm2.js +50 -5
- package/src/deployment-package.js +102 -0
- package/src/env.js +65 -0
- package/src/node-range.js +66 -0
- package/src/paths.js +66 -2
- package/src/prompts.js +0 -2
- package/bin/create.js +0 -7
package/src/commands/migrate.js
CHANGED
|
@@ -2,47 +2,46 @@
|
|
|
2
2
|
// provider packages), remove stale selva.config.js, update 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
|
+
} from '../deployment-package.js';
|
|
18
|
+
import { APP_NAME, runPm2 } from './pm2.js';
|
|
19
|
+
|
|
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 — `detectDrift`
|
|
23
|
+
* reports it, because a stray copy of the dependency tree is both confusing and
|
|
24
|
+
* large enough to matter on a small VM.
|
|
25
|
+
*/
|
|
26
|
+
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [deps] Seams for tests. The rollback path destroys and
|
|
30
|
+
* restores a real dependency tree, so it has to be exercised against a real
|
|
31
|
+
* directory — but not against a real npm or a real pm2 daemon.
|
|
32
|
+
*/
|
|
33
|
+
export async function runMigrate(
|
|
34
|
+
_argv,
|
|
35
|
+
{
|
|
36
|
+
dir: injectedDir,
|
|
37
|
+
// --prefer-online bypasses the stale-packument trap: npm can otherwise
|
|
38
|
+
// resolve a cached packument that predates the release being migrated to.
|
|
39
|
+
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
40
|
+
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
41
|
+
pm2 = runPm2
|
|
42
|
+
} = {}
|
|
43
|
+
) {
|
|
44
|
+
const dir = injectedDir ?? resolveDeploymentDir();
|
|
46
45
|
requireDeploymentDir(dir);
|
|
47
46
|
|
|
48
47
|
p.intro(pc.bgCyan(pc.black(' selva migrate ')));
|
|
@@ -66,9 +65,31 @@ export async function runMigrate() {
|
|
|
66
65
|
const ecoHasStaleRuntime =
|
|
67
66
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
68
67
|
|
|
68
|
+
// Deprecated env keys. The server reads the old names for one more minor
|
|
69
|
+
// version, so this is a warning today and a silent fallback to defaults once
|
|
70
|
+
// that shim goes — rewriting now is what keeps a tuned value tuned.
|
|
71
|
+
const envPath = join(dir, '.env');
|
|
72
|
+
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
73
|
+
const envRename = envBefore
|
|
74
|
+
? renameEnvKeys(envBefore, RENAMED_ENV_VARS)
|
|
75
|
+
: { text: '', changes: [] };
|
|
76
|
+
|
|
69
77
|
const sideFileChanges = [];
|
|
70
|
-
if (hasStaleConfig)
|
|
71
|
-
|
|
78
|
+
if (hasStaleConfig)
|
|
79
|
+
sideFileChanges.push(
|
|
80
|
+
`${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
|
|
81
|
+
);
|
|
82
|
+
if (ecoHasStaleRuntime)
|
|
83
|
+
sideFileChanges.push(
|
|
84
|
+
`${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
|
|
85
|
+
);
|
|
86
|
+
for (const [oldName, newName, action] of envRename.changes) {
|
|
87
|
+
sideFileChanges.push(
|
|
88
|
+
action === 'dropped'
|
|
89
|
+
? `${pc.red('-')} .env ${pc.dim(`${oldName} (deprecated; ${newName} is already set)`)}`
|
|
90
|
+
: `${pc.yellow('~')} .env ${pc.dim(`${oldName} → ${newName}`)}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
72
93
|
|
|
73
94
|
if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
|
|
74
95
|
p.outro(pc.green('Already on the current layout — nothing to migrate.'));
|
|
@@ -79,19 +100,22 @@ export async function runMigrate() {
|
|
|
79
100
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
80
101
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
81
102
|
|
|
82
|
-
const confirmed = await
|
|
83
|
-
message: 'Apply these changes, reinstall, and restart?',
|
|
84
|
-
initialValue: true
|
|
85
|
-
});
|
|
103
|
+
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
86
104
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
87
105
|
p.cancel('Cancelled.');
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
// Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
// A legacy deployment may predate the local-pm2 layout, so a missing binary
|
|
111
|
+
// is not fatal here — there is nothing running for us to stop.
|
|
112
|
+
try {
|
|
113
|
+
const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
114
|
+
if (stopStatus !== 0) {
|
|
115
|
+
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
// Back up for rollback on npm-install failure.
|
|
@@ -114,22 +138,33 @@ export async function runMigrate() {
|
|
|
114
138
|
writeFileSync(ecoPath, ecoContent, 'utf8');
|
|
115
139
|
}
|
|
116
140
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
if (envRename.changes.length > 0) {
|
|
142
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
143
|
+
writeFileSync(envPath, envRename.text, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A clean install is required — a legacy lockfile pins the old package set
|
|
147
|
+
// across a major bump. But node_modules is also where pm2 lives, and the
|
|
148
|
+
// rollback below has to restart the app, so deleting it outright leaves a
|
|
149
|
+
// failed migration with no way back up. Rename instead: atomic, keeps the
|
|
150
|
+
// .bin symlinks intact, and restorable until the install succeeds.
|
|
151
|
+
const nodeModules = join(dir, 'node_modules');
|
|
152
|
+
const nodeModulesBak = join(dir, NODE_MODULES_STASH);
|
|
153
|
+
const lockPath = join(dir, 'package-lock.json');
|
|
154
|
+
|
|
155
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
156
|
+
if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
|
|
157
|
+
const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
|
|
158
|
+
rmSync(lockPath, { force: true });
|
|
120
159
|
|
|
121
160
|
const s = p.spinner();
|
|
122
161
|
s.start('Installing new dependencies (this can take a minute)');
|
|
123
162
|
try {
|
|
124
|
-
|
|
125
|
-
// docs/Hotfix-CLI-Runtime.md.
|
|
126
|
-
execSync('npm install --prefer-online', {
|
|
127
|
-
cwd: dir,
|
|
128
|
-
stdio: 'pipe'
|
|
129
|
-
});
|
|
163
|
+
install(dir);
|
|
130
164
|
s.stop('Dependencies installed');
|
|
165
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
131
166
|
} catch (err) {
|
|
132
|
-
s.stop(pc.red('npm install failed — rolling back
|
|
167
|
+
s.stop(pc.red('npm install failed — rolling back'));
|
|
133
168
|
copyFileSync(bakPath, pkgPath);
|
|
134
169
|
if (hasStaleConfig && existsSync(configPath + '.bak')) {
|
|
135
170
|
copyFileSync(configPath + '.bak', configPath);
|
|
@@ -137,16 +172,32 @@ export async function runMigrate() {
|
|
|
137
172
|
if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
|
|
138
173
|
copyFileSync(ecoPath + '.bak', ecoPath);
|
|
139
174
|
}
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
|
|
176
|
+
copyFileSync(envPath + '.bak', envPath);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Restore the dependency tree the app was running on. A half-installed
|
|
180
|
+
// node_modules from the failed attempt is worse than the old one.
|
|
181
|
+
rmSync(nodeModules, { recursive: true, force: true });
|
|
182
|
+
if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
|
|
183
|
+
if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
|
|
184
|
+
|
|
185
|
+
restartAfterRollback(dir, pm2);
|
|
142
186
|
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
143
187
|
process.exit(1);
|
|
144
188
|
}
|
|
145
189
|
|
|
146
|
-
|
|
190
|
+
let status;
|
|
191
|
+
try {
|
|
192
|
+
status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
status = 1;
|
|
195
|
+
p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
|
|
196
|
+
}
|
|
147
197
|
const backupHints = ['package.json.bak'];
|
|
148
198
|
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
149
199
|
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
200
|
+
if (envRename.changes.length > 0) backupHints.push('.env.bak');
|
|
150
201
|
|
|
151
202
|
if (status === 0) {
|
|
152
203
|
p.outro(
|
|
@@ -161,23 +212,39 @@ export async function runMigrate() {
|
|
|
161
212
|
}
|
|
162
213
|
}
|
|
163
214
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Bring the app back up after a failed migration.
|
|
217
|
+
*
|
|
218
|
+
* Never throws: the migration is already aborting, and an exception here would
|
|
219
|
+
* replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
220
|
+
* the operator's problem to act on, so it must be stated, not swallowed — the
|
|
221
|
+
* old code returned a bare 1 and printed nothing.
|
|
222
|
+
*/
|
|
223
|
+
function restartAfterRollback(dir, pm2 = runPm2) {
|
|
224
|
+
try {
|
|
225
|
+
const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
226
|
+
if (status === 0) {
|
|
227
|
+
p.log.success(`Rolled back and restarted ${APP_NAME}.`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
p.log.error(
|
|
231
|
+
`Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
|
|
232
|
+
`check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
|
|
233
|
+
);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
p.log.error(
|
|
236
|
+
`Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
|
|
237
|
+
`The app is likely down — run \`npm install\` then \`selva start\`.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
172
241
|
|
|
173
|
-
|
|
242
|
+
function buildTargetPackageJson(current) {
|
|
243
|
+
return buildDeploymentPackageJson({
|
|
174
244
|
name: current.name ?? 'selva-deployment',
|
|
175
245
|
version: current.version ?? '0.1.0',
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
scripts: { ...CANONICAL_SCRIPTS },
|
|
179
|
-
dependencies: deps
|
|
180
|
-
};
|
|
246
|
+
engines: current.engines
|
|
247
|
+
});
|
|
181
248
|
}
|
|
182
249
|
|
|
183
250
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -209,38 +276,36 @@ function diffPackageJson(before, after) {
|
|
|
209
276
|
}
|
|
210
277
|
}
|
|
211
278
|
|
|
279
|
+
// Anything outside the canonical set is discarded by the rewrite.
|
|
280
|
+
// devDependencies and description used to vanish without appearing here, so
|
|
281
|
+
// the operator confirmed a change they were never shown.
|
|
282
|
+
for (const key of Object.keys(before).sort()) {
|
|
283
|
+
if (CANONICAL_FIELDS.has(key)) continue;
|
|
284
|
+
lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
212
287
|
return lines;
|
|
213
288
|
}
|
|
214
289
|
|
|
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;
|
|
290
|
+
// One-line preview of a dropped field, enough to recognise what is being lost.
|
|
291
|
+
function summarize(value) {
|
|
292
|
+
if (value === null || typeof value !== 'object') return String(value);
|
|
293
|
+
const keys = Object.keys(value);
|
|
294
|
+
if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
|
|
295
|
+
return keys.length ? `{${keys.join(', ')}}` : '{}';
|
|
226
296
|
}
|
|
227
297
|
|
|
228
298
|
// Exported for `selva doctor` to check layout drift without duplication.
|
|
229
299
|
export function detectDrift(pkgJson, dir) {
|
|
230
300
|
const deps = pkgJson?.dependencies ?? {};
|
|
231
301
|
const reasons = [];
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (deps['@selvajs/header-auth-provider'])
|
|
240
|
-
reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
|
|
241
|
-
if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
|
|
242
|
-
if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
|
|
243
|
-
if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
|
|
302
|
+
|
|
303
|
+
for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
|
|
304
|
+
if (deps[name]) reasons.push(`${name} is ${why}`);
|
|
305
|
+
}
|
|
306
|
+
for (const name of Object.keys(DEPENDENCIES)) {
|
|
307
|
+
if (!deps[name]) reasons.push(`${name} is missing`);
|
|
308
|
+
}
|
|
244
309
|
|
|
245
310
|
if (dir) {
|
|
246
311
|
const configPath = join(dir, 'selva.config.js');
|
|
@@ -251,10 +316,16 @@ export function detectDrift(pkgJson, dir) {
|
|
|
251
316
|
if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
|
|
252
317
|
reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
|
|
253
318
|
}
|
|
319
|
+
const envPath = join(dir, '.env');
|
|
320
|
+
if (existsSync(envPath)) {
|
|
321
|
+
const { changes } = renameEnvKeys(readFileSync(envPath, 'utf8'), RENAMED_ENV_VARS);
|
|
322
|
+
for (const [oldName, newName] of changes) {
|
|
323
|
+
reasons.push(`.env still uses ${oldName} (renamed to ${newName})`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
254
326
|
}
|
|
255
327
|
|
|
256
328
|
return reasons;
|
|
257
329
|
}
|
|
258
330
|
|
|
259
|
-
|
|
260
|
-
export { buildTargetPackageJson, SELVA_DEPS };
|
|
331
|
+
export { buildTargetPackageJson, diffPackageJson };
|
package/src/commands/pm2.js
CHANGED
|
@@ -9,10 +9,10 @@ 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
14
|
// Resolve to deployment-local pm2 (no global fallback; prevents version skew).
|
|
15
|
-
function pm2Bin(dir) {
|
|
15
|
+
export function pm2Bin(dir) {
|
|
16
16
|
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
17
17
|
if (!existsSync(local)) {
|
|
18
18
|
throw new Error(
|
|
@@ -25,7 +25,36 @@ function pm2Bin(dir) {
|
|
|
25
25
|
return local;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// Parse the two versions pm2 prints alongside its out-of-date warning:
|
|
29
|
+
// In memory PM2 version: 6.0.14
|
|
30
|
+
// Local PM2 version: 5.4.3
|
|
31
|
+
// Returns nulls when the shape changes — callers then fall back to resyncing,
|
|
32
|
+
// which is the pre-existing behaviour.
|
|
33
|
+
export function parsePm2Skew(output) {
|
|
34
|
+
// eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
|
|
35
|
+
const clean = output.replace(/\[[0-9;]*m/g, '');
|
|
36
|
+
const daemon = /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
|
|
37
|
+
const local = /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
|
|
38
|
+
return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when the running daemon is newer than the deployment-local pm2 — i.e. a
|
|
43
|
+
* foreign global pm2 owns it. `pm2 update` cannot fix that direction: it
|
|
44
|
+
* downgrades the daemon and hands it a dump from a newer version, which drops
|
|
45
|
+
* the process table so selva-compute is never re-registered (issue #118).
|
|
46
|
+
*/
|
|
47
|
+
export function daemonOutranksCli(daemon, local) {
|
|
48
|
+
if (!daemon || !local) return false;
|
|
49
|
+
const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
|
|
50
|
+
for (let i = 0; i < 3; i++) {
|
|
51
|
+
if (a[i] > b[i]) return true;
|
|
52
|
+
if (a[i] < b[i]) return false;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for daemon/CLI version mismatch; run `pm2 update` only when it can help.
|
|
29
58
|
function ensurePm2InSync(dir) {
|
|
30
59
|
const bin = pm2Bin(dir);
|
|
31
60
|
const probe = spawnSync(bin, ['ping'], {
|
|
@@ -36,6 +65,16 @@ function ensurePm2InSync(dir) {
|
|
|
36
65
|
const output = (probe.stdout ?? '') + (probe.stderr ?? '');
|
|
37
66
|
if (!/out-of-date/i.test(output)) return;
|
|
38
67
|
|
|
68
|
+
const { daemon, local } = parsePm2Skew(output);
|
|
69
|
+
if (daemonOutranksCli(daemon, local)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`The running PM2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}), ` +
|
|
72
|
+
`so a global pm2 owns it. Running \`pm2 update\` would downgrade the daemon and drop ` +
|
|
73
|
+
`its process table, leaving ${APP_NAME} unregistered. Resolve the conflict first: ` +
|
|
74
|
+
`\`which -a pm2\`, \`pm2 -v\`, \`pm2 ping\`.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
39
78
|
p.log.warn(
|
|
40
79
|
'PM2 in-memory daemon is a different version than the deployment-local pm2 — ' +
|
|
41
80
|
'running `pm2 update` to resync (this briefly restarts managed processes).'
|
|
@@ -53,8 +92,14 @@ function ensurePm2InSync(dir) {
|
|
|
53
92
|
}
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Run the deployment-local pm2.
|
|
97
|
+
*
|
|
98
|
+
* `bin` overrides where pm2 is resolved from — `selva migrate` wipes
|
|
99
|
+
* node_modules mid-flight and passes the binary it stashed beforehand, because
|
|
100
|
+
* resolving from the deployment at that moment would find nothing.
|
|
101
|
+
*/
|
|
102
|
+
export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
|
|
58
103
|
const result = spawnSync(bin, args, {
|
|
59
104
|
cwd: dir,
|
|
60
105
|
stdio: inherit ? 'inherit' : 'pipe',
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The package.json a deployment directory gets. `create` writes it at scaffold
|
|
2
|
+
// time and `migrate` rewrites an existing one onto it, so it lives here rather
|
|
3
|
+
// than in either command — two copies drifted once already, and the pm2 pin is
|
|
4
|
+
// exactly the field where a silent difference costs the most.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* pm2 is pinned exactly, not caret-ranged.
|
|
8
|
+
*
|
|
9
|
+
* pm2's CLI and its long-running daemon must be the same version. A range lets
|
|
10
|
+
* two deployments installed a week apart resolve differently, and the newer one
|
|
11
|
+
* silently adopts the daemon the older one started — the skew `ensurePm2InSync`
|
|
12
|
+
* has to detect and, in the daemon-is-newer direction, cannot repair (#118).
|
|
13
|
+
*/
|
|
14
|
+
const PM2_VERSION = '5.4.3';
|
|
15
|
+
|
|
16
|
+
const SCRIPTS = {
|
|
17
|
+
start: 'selva start',
|
|
18
|
+
stop: 'selva stop',
|
|
19
|
+
restart: 'selva restart',
|
|
20
|
+
logs: 'selva logs',
|
|
21
|
+
doctor: 'selva doctor',
|
|
22
|
+
update: 'selva update'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
|
|
27
|
+
* the CLI is a dependency so `npm run start` resolves the `selva` bin locally
|
|
28
|
+
* instead of relying on a global install.
|
|
29
|
+
*/
|
|
30
|
+
const DEPENDENCIES = {
|
|
31
|
+
'@selvajs/cli': 'latest',
|
|
32
|
+
'@selvajs/selva': 'latest',
|
|
33
|
+
pm2: PM2_VERSION
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Legacy `@selvajs/*` packages that a deployment may still list. Each was
|
|
38
|
+
* either renamed or folded into `@selvajs/selva`; `migrate` drops them and
|
|
39
|
+
* `doctor` reports them as layout drift.
|
|
40
|
+
*/
|
|
41
|
+
export const LEGACY_DEPENDENCIES = {
|
|
42
|
+
'@selvajs/runtime': 'the old runtime package',
|
|
43
|
+
'@selvajs/create': 'the old CLI package',
|
|
44
|
+
'@selvajs/platform': 'now bundled into @selvajs/selva',
|
|
45
|
+
'@selvajs/local-provider': 'now bundled into @selvajs/selva',
|
|
46
|
+
'@selvajs/supabase-provider': 'now bundled into @selvajs/selva',
|
|
47
|
+
'@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the canonical deployment package.json.
|
|
52
|
+
*
|
|
53
|
+
* Dependencies are replaced wholesale rather than merged, so an operator's own
|
|
54
|
+
* additions are dropped. That is deliberate — a deployment directory is
|
|
55
|
+
* generated output, and anyone needing custom dependencies should depend on
|
|
56
|
+
* `@selvajs/selva` from their own project instead of editing this file.
|
|
57
|
+
*
|
|
58
|
+
* `engines` is the one exception, carried over when present: npm only enforces
|
|
59
|
+
* it under `engine-strict`, so an operator who pinned a Node floor did it
|
|
60
|
+
* deliberately, and dropping it removes a guard whose absence shows up only
|
|
61
|
+
* under real traffic (issue #176). Everything else a migration discards is
|
|
62
|
+
* listed by `diffPackageJson` before the operator confirms.
|
|
63
|
+
*/
|
|
64
|
+
export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
|
|
65
|
+
const pkg = {
|
|
66
|
+
name: sanitizePackageName(name),
|
|
67
|
+
version,
|
|
68
|
+
private: true,
|
|
69
|
+
type: 'module',
|
|
70
|
+
scripts: { ...SCRIPTS },
|
|
71
|
+
dependencies: { ...DEPENDENCIES }
|
|
72
|
+
};
|
|
73
|
+
if (engines && typeof engines === 'object') pkg.engines = { ...engines };
|
|
74
|
+
return pkg;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Top-level keys the canonical package.json defines. Anything outside this set
|
|
79
|
+
* that an operator added is dropped by `migrate` — `diffPackageJson` reports
|
|
80
|
+
* them so the loss is declared rather than discovered later.
|
|
81
|
+
*/
|
|
82
|
+
export const CANONICAL_FIELDS = new Set([
|
|
83
|
+
'name',
|
|
84
|
+
'version',
|
|
85
|
+
'private',
|
|
86
|
+
'type',
|
|
87
|
+
'scripts',
|
|
88
|
+
'dependencies',
|
|
89
|
+
'engines'
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
export function sanitizePackageName(name) {
|
|
93
|
+
// npm package names: lowercase, no spaces, limited punctuation.
|
|
94
|
+
return (
|
|
95
|
+
String(name ?? '')
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
98
|
+
.replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { DEPENDENCIES, SCRIPTS, PM2_VERSION };
|
package/src/env.js
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Old env name → its replacement, for keys renamed without changing meaning.
|
|
7
|
+
* The server still reads each old name for one minor version and warns at boot
|
|
8
|
+
* (`readRenamed` in `@selvajs/server` `resolveComputeLimits`); `selva doctor`
|
|
9
|
+
* reports them and `selva migrate` rewrites them. Keep in sync with that list,
|
|
10
|
+
* and drop an entry when the server stops reading it.
|
|
11
|
+
*/
|
|
12
|
+
export const RENAMED_ENV_VARS = {
|
|
13
|
+
COMPUTE_DEFINITION_BYTE_CACHE_MB: 'COMPUTE_DEFINITION_CACHE_MB',
|
|
14
|
+
COMPUTE_RESPONSE_CACHE_MB: 'COMPUTE_SOLVE_CACHE_MB',
|
|
15
|
+
DEFINITION_CACHE_TTL_MS: 'REMOTE_DEFINITION_CACHE_TTL_MS',
|
|
16
|
+
MAX_SOLVE_DURATION_MS: 'COMPUTE_SOLVE_DEADLINE_MS'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Deprecated vars whose replacement changes the VALUE too, so they can't be
|
|
21
|
+
* rewritten by renaming a key. `selva doctor` reports these; migrate leaves
|
|
22
|
+
* them alone rather than guessing.
|
|
23
|
+
*/
|
|
24
|
+
export const REPLACED_ENV_VARS = {
|
|
25
|
+
SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
|
|
26
|
+
};
|
|
27
|
+
|
|
5
28
|
// Parse a .env file into { key: value }. Preserves nothing else.
|
|
6
29
|
export function parseEnv(text) {
|
|
7
30
|
const out = {};
|
|
@@ -78,6 +101,48 @@ export function writeEnvFile(path, template, values) {
|
|
|
78
101
|
writeFileSync(path, mergeEnv(template, values), 'utf8');
|
|
79
102
|
}
|
|
80
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Rewrite deprecated keys to their current names, in place. `renames` maps old
|
|
106
|
+
* name → new name. Only the key is touched — the value, comments, ordering and
|
|
107
|
+
* spacing survive, so an operator's tuned file stays recognisably theirs.
|
|
108
|
+
*
|
|
109
|
+
* A key already present under its new name is left alone and its old line is
|
|
110
|
+
* dropped: the server resolves the same way (new name wins), so keeping both
|
|
111
|
+
* would preserve a line that does nothing.
|
|
112
|
+
*
|
|
113
|
+
* Returns `{ text, changes }` where `changes` is `[oldName, newName, 'renamed'
|
|
114
|
+
* | 'dropped']` per line acted on; an empty array means the file was already
|
|
115
|
+
* current and nothing should be written.
|
|
116
|
+
*/
|
|
117
|
+
export function renameEnvKeys(text, renames) {
|
|
118
|
+
const present = new Set(Object.keys(parseEnv(text)));
|
|
119
|
+
const changes = [];
|
|
120
|
+
const out = [];
|
|
121
|
+
|
|
122
|
+
for (const line of text.split(/\r?\n/)) {
|
|
123
|
+
const stripped = line.trim();
|
|
124
|
+
const eq = stripped.indexOf('=');
|
|
125
|
+
if (!stripped || stripped.startsWith('#') || eq === -1) {
|
|
126
|
+
out.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const key = stripped.slice(0, eq).trim();
|
|
130
|
+
const newName = renames[key];
|
|
131
|
+
if (!newName) {
|
|
132
|
+
out.push(line);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (present.has(newName)) {
|
|
136
|
+
changes.push([key, newName, 'dropped']);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
out.push(line.replace(key, newName));
|
|
140
|
+
changes.push([key, newName, 'renamed']);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { text: out.join('\n'), changes };
|
|
144
|
+
}
|
|
145
|
+
|
|
81
146
|
function quoteIfNeeded(value) {
|
|
82
147
|
const s = String(value);
|
|
83
148
|
if (s === '') return '""';
|