@selvajs/cli 4.8.0-beta.8 → 4.8.0-beta.9
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/package.json +2 -2
- package/src/checks/boot.js +149 -0
- package/src/checks/config.js +97 -0
- package/src/checks/result.js +27 -0
- package/src/commands/create.js +20 -56
- package/src/commands/doctor.js +47 -263
- package/src/commands/init.js +2 -20
- package/src/commands/keys.js +2 -15
- package/src/commands/migrate.js +128 -91
- package/src/commands/pm2.js +10 -4
- package/src/deployment-package.js +102 -0
- package/src/node-range.js +66 -0
- package/src/paths.js +44 -0
- package/src/prompts.js +0 -2
- package/bin/create.js +0 -7
package/src/commands/migrate.js
CHANGED
|
@@ -2,42 +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 { 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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();
|
|
41
45
|
requireDeploymentDir(dir);
|
|
42
46
|
|
|
43
47
|
p.intro(pc.bgCyan(pc.black(' selva migrate ')));
|
|
@@ -96,19 +100,22 @@ export async function runMigrate() {
|
|
|
96
100
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
97
101
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
98
102
|
|
|
99
|
-
const confirmed = await
|
|
100
|
-
message: 'Apply these changes, reinstall, and restart?',
|
|
101
|
-
initialValue: true
|
|
102
|
-
});
|
|
103
|
+
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
103
104
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
104
105
|
p.cancel('Cancelled.');
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
// Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.');
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
// Back up for rollback on npm-install failure.
|
|
@@ -136,22 +143,28 @@ export async function runMigrate() {
|
|
|
136
143
|
writeFileSync(envPath, envRename.text, 'utf8');
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
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 });
|
|
142
159
|
|
|
143
160
|
const s = p.spinner();
|
|
144
161
|
s.start('Installing new dependencies (this can take a minute)');
|
|
145
162
|
try {
|
|
146
|
-
|
|
147
|
-
// docs/Hotfix-CLI-Runtime.md.
|
|
148
|
-
execSync('npm install --prefer-online', {
|
|
149
|
-
cwd: dir,
|
|
150
|
-
stdio: 'pipe'
|
|
151
|
-
});
|
|
163
|
+
install(dir);
|
|
152
164
|
s.stop('Dependencies installed');
|
|
165
|
+
rmSync(nodeModulesBak, { recursive: true, force: true });
|
|
153
166
|
} catch (err) {
|
|
154
|
-
s.stop(pc.red('npm install failed — rolling back
|
|
167
|
+
s.stop(pc.red('npm install failed — rolling back'));
|
|
155
168
|
copyFileSync(bakPath, pkgPath);
|
|
156
169
|
if (hasStaleConfig && existsSync(configPath + '.bak')) {
|
|
157
170
|
copyFileSync(configPath + '.bak', configPath);
|
|
@@ -162,13 +175,25 @@ export async function runMigrate() {
|
|
|
162
175
|
if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
|
|
163
176
|
copyFileSync(envPath + '.bak', envPath);
|
|
164
177
|
}
|
|
165
|
-
|
|
166
|
-
|
|
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);
|
|
167
186
|
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
168
187
|
process.exit(1);
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
|
|
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
|
+
}
|
|
172
197
|
const backupHints = ['package.json.bak'];
|
|
173
198
|
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
174
199
|
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
@@ -187,23 +212,39 @@ export async function runMigrate() {
|
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
214
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}
|
|
198
241
|
|
|
199
|
-
|
|
242
|
+
function buildTargetPackageJson(current) {
|
|
243
|
+
return buildDeploymentPackageJson({
|
|
200
244
|
name: current.name ?? 'selva-deployment',
|
|
201
245
|
version: current.version ?? '0.1.0',
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
scripts: { ...CANONICAL_SCRIPTS },
|
|
205
|
-
dependencies: deps
|
|
206
|
-
};
|
|
246
|
+
engines: current.engines
|
|
247
|
+
});
|
|
207
248
|
}
|
|
208
249
|
|
|
209
250
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -235,39 +276,36 @@ function diffPackageJson(before, after) {
|
|
|
235
276
|
}
|
|
236
277
|
}
|
|
237
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
|
+
|
|
238
287
|
return lines;
|
|
239
288
|
}
|
|
240
289
|
|
|
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;
|
|
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(', ')}}` : '{}';
|
|
252
296
|
}
|
|
253
297
|
|
|
254
298
|
// Exported for `selva doctor` to check layout drift without duplication.
|
|
255
299
|
export function detectDrift(pkgJson, dir) {
|
|
256
300
|
const deps = pkgJson?.dependencies ?? {};
|
|
257
301
|
const reasons = [];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
reasons.push(
|
|
264
|
-
|
|
265
|
-
reasons.push('@selvajs/supabase-provider is now bundled into @selvajs/selva');
|
|
266
|
-
if (deps['@selvajs/header-auth-provider'])
|
|
267
|
-
reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
|
|
268
|
-
if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
|
|
269
|
-
if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
|
|
270
|
-
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
|
+
}
|
|
271
309
|
|
|
272
310
|
if (dir) {
|
|
273
311
|
const configPath = join(dir, 'selva.config.js');
|
|
@@ -290,5 +328,4 @@ export function detectDrift(pkgJson, dir) {
|
|
|
290
328
|
return reasons;
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
|
|
294
|
-
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(
|
|
@@ -92,8 +92,14 @@ function ensurePm2InSync(dir) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
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) } = {}) {
|
|
97
103
|
const result = spawnSync(bin, args, {
|
|
98
104
|
cwd: dir,
|
|
99
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 };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Node version-range matching, used by `selva doctor` to check an installed
|
|
2
|
+
// runtime and by the scaffolder to refuse a too-old shell before writing files.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Narrow `engines.node` range check — `>=X`, `^`, `~`, `||`, and bare/x-ranges.
|
|
6
|
+
* Returns null when unparseable so a misread never reports a false failure.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `satisfiesRange` in @selvajs/server/ops. Duplicated because the CLI is
|
|
9
|
+
* dependency-free by design (it scaffolds the deployment that installs the
|
|
10
|
+
* runtime, so it cannot import from it).
|
|
11
|
+
*/
|
|
12
|
+
export function satisfiesNodeRange(version, range) {
|
|
13
|
+
const core = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
|
|
14
|
+
if (!core) return null;
|
|
15
|
+
const v = [Number(core[1]), Number(core[2]), Number(core[3])];
|
|
16
|
+
|
|
17
|
+
let anyParsed = false;
|
|
18
|
+
for (const alt of String(range).split('||')) {
|
|
19
|
+
const clauses = alt.trim().split(/\s+/).filter(Boolean);
|
|
20
|
+
if (!clauses.length) continue;
|
|
21
|
+
|
|
22
|
+
let all = true;
|
|
23
|
+
let parsed = true;
|
|
24
|
+
for (const clause of clauses) {
|
|
25
|
+
const m = /^(>=|<=|>|<|\^|~|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(clause);
|
|
26
|
+
if (!m) {
|
|
27
|
+
parsed = false;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
const op = m[1] ?? '=';
|
|
31
|
+
const num = (s) => (s == null || s === 'x' || s === '*' ? 0 : Number(s));
|
|
32
|
+
const t = [Number(m[2]), num(m[3]), num(m[4])];
|
|
33
|
+
let cmp = 0;
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if (v[i] > t[i]) {
|
|
36
|
+
cmp = 1;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (v[i] < t[i]) {
|
|
40
|
+
cmp = -1;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let ok;
|
|
45
|
+
if (op === '>=') ok = cmp >= 0;
|
|
46
|
+
else if (op === '>') ok = cmp > 0;
|
|
47
|
+
else if (op === '<=') ok = cmp <= 0;
|
|
48
|
+
else if (op === '<') ok = cmp < 0;
|
|
49
|
+
else if (op === '^') ok = cmp >= 0 && v[0] === t[0];
|
|
50
|
+
else if (op === '~') ok = cmp >= 0 && v[0] === t[0] && v[1] === t[1];
|
|
51
|
+
else
|
|
52
|
+
ok =
|
|
53
|
+
v[0] === t[0] &&
|
|
54
|
+
(m[3] == null || m[3] === 'x' || m[3] === '*' || v[1] === t[1]) &&
|
|
55
|
+
(m[4] == null || m[4] === 'x' || m[4] === '*' || v[2] === t[2]);
|
|
56
|
+
if (!ok) {
|
|
57
|
+
all = false;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!parsed) continue;
|
|
62
|
+
anyParsed = true;
|
|
63
|
+
if (all) return true;
|
|
64
|
+
}
|
|
65
|
+
return anyParsed ? false : null;
|
|
66
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -21,6 +21,50 @@ export function requiredNodeRange() {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* This CLI's own version, written into `.selva-version` so a later `selva
|
|
26
|
+
* migrate` can tell which scaffold layout it is looking at.
|
|
27
|
+
*
|
|
28
|
+
* Read from package.json for the same reason as `requiredNodeRange`: a literal
|
|
29
|
+
* in code stops tracking the release the moment someone forgets to bump it, and
|
|
30
|
+
* the marker's whole job is to be accurate about which CLI wrote the directory.
|
|
31
|
+
*/
|
|
32
|
+
export function scaffoldVersion() {
|
|
33
|
+
try {
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
|
|
36
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
37
|
+
} catch {
|
|
38
|
+
return '0.0.0';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A scaffolding template shipped inside the installed runtime. The CLI cannot
|
|
44
|
+
* import from `@selvajs/selva` — it is what installs it — so it reads the files
|
|
45
|
+
* out of node_modules after install.
|
|
46
|
+
*/
|
|
47
|
+
export function runtimeTemplatePath(dir, name) {
|
|
48
|
+
return join(dir, 'node_modules', '@selvajs', 'selva', 'templates', name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `.env` template to merge operator values into: the runtime's `.env.example`
|
|
53
|
+
* when installed, otherwise the deployment's own `.env` so its comments and
|
|
54
|
+
* ordering survive a rewrite.
|
|
55
|
+
*
|
|
56
|
+
* Returns `''` when neither exists rather than throwing — `mergeEnv` appends
|
|
57
|
+
* every value to an empty template, so the operator gets a usable file instead
|
|
58
|
+
* of an ENOENT from a command that was only asked to change one key.
|
|
59
|
+
*/
|
|
60
|
+
export function readEnvTemplate(dir) {
|
|
61
|
+
const template = runtimeTemplatePath(dir, '.env.example');
|
|
62
|
+
if (existsSync(template)) return readFileSync(template, 'utf8');
|
|
63
|
+
const env = join(dir, '.env');
|
|
64
|
+
if (existsSync(env)) return readFileSync(env, 'utf8');
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
24
68
|
// A deployment directory must contain an .env and an ecosystem.config.cjs.
|
|
25
69
|
// Anything else (selva.config.js, node_modules) is "should be there" but the
|
|
26
70
|
// CLI doesn't require it — `selva init` is allowed to fix a partial install.
|
package/src/prompts.js
CHANGED