@selvajs/cli 2.0.0 → 2.0.2
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 +1 -1
- package/package.json +4 -4
- package/src/cli.js +2 -0
- package/src/commands/create.js +9 -20
- package/src/commands/doctor.js +104 -10
- package/src/commands/migrate.js +304 -0
- package/src/commands/pm2.js +4 -11
- package/src/env.js +7 -1
- package/bin/create.js +0 -7
- package/bin/selva.js +0 -7
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ CLI for white-label Selva deployments.
|
|
|
8
8
|
npx @selvajs/cli my-deployment
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Interactive scaffolder. Prompts for provider, tenancy, flags, brand name, admin email. Generates `SELVA_HMAC_KEY` + `SELVA_AT_REST_KEY`. Writes `.env`, `
|
|
11
|
+
Interactive scaffolder. Prompts for provider, tenancy, flags, brand name, admin email. Generates `SELVA_HMAC_KEY` + `SELVA_AT_REST_KEY`. Writes `.env`, `ecosystem.config.cjs`, `package.json`. Runs `npm install`.
|
|
12
12
|
|
|
13
13
|
## Operate an existing deployment
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
|
-
"
|
|
11
|
+
"cli": "./bin/cli.js",
|
|
12
12
|
"selva": "./bin/selva.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"picocolors": "^1.1.1"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"start": "node ./bin/
|
|
26
|
+
"start": "node ./bin/cli.js",
|
|
27
27
|
"selva": "node ./bin/selva.js",
|
|
28
|
-
"test": "node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/keys.js']) { await import(m); }\""
|
|
28
|
+
"test": "node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/migrate.js','./src/commands/keys.js']) { await import(m); }\""
|
|
29
29
|
}
|
|
30
30
|
}
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ const COMMANDS = {
|
|
|
14
14
|
restart: () => import('./commands/pm2.js').then((m) => m.runRestart),
|
|
15
15
|
logs: () => import('./commands/pm2.js').then((m) => m.runLogs),
|
|
16
16
|
update: () => import('./commands/pm2.js').then((m) => m.runUpdate),
|
|
17
|
+
migrate: () => import('./commands/migrate.js').then((m) => m.runMigrate),
|
|
17
18
|
keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -70,6 +71,7 @@ function printHelp() {
|
|
|
70
71
|
' restart pm2 restart selva-compute --update-env',
|
|
71
72
|
' logs pm2 logs selva-compute',
|
|
72
73
|
' update npm update @selvajs/selva + restart',
|
|
74
|
+
' migrate Bring package.json onto the current layout',
|
|
73
75
|
' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
|
|
74
76
|
'',
|
|
75
77
|
pc.dim('To scaffold a new deployment: ') + pc.cyan('npx @selvajs/cli <dir>')
|
package/src/commands/create.js
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// What this writes into <dir>:
|
|
4
4
|
// .env merged from runtime's .env.example + prompt values
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// package.json depends on @selvajs/selva + providers
|
|
5
|
+
// ecosystem.config.cjs copied verbatim from runtime templates
|
|
6
|
+
// package.json depends on @selvajs/selva + @selvajs/cli + pm2
|
|
8
7
|
// .selva-version marker for future CLI migrations
|
|
9
8
|
// node_modules/ after `npm install`
|
|
10
9
|
//
|
|
11
10
|
// The runtime templates are the source of truth — we don't carry our own
|
|
12
11
|
// copies in @selvajs/cli. We install @selvajs/selva first, then copy
|
|
13
|
-
// from node_modules/@selvajs/selva/templates/.
|
|
12
|
+
// from node_modules/@selvajs/selva/templates/. Provider selection is
|
|
13
|
+
// env-driven (SELVA_AUTH_PROVIDER etc.) — no selva.config.js is generated.
|
|
14
14
|
|
|
15
15
|
import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
|
16
16
|
import { resolve, join, basename } from 'node:path';
|
|
@@ -81,7 +81,6 @@ export async function runCreate(argv) {
|
|
|
81
81
|
const envTemplate = readFileSync(join(runtimeTemplates, '.env.example'), 'utf8');
|
|
82
82
|
writeEnvFile(join(targetDir, '.env'), envTemplate, values);
|
|
83
83
|
|
|
84
|
-
cpSync(join(runtimeTemplates, 'selva.config.example.js'), join(targetDir, 'selva.config.js'));
|
|
85
84
|
cpSync(join(runtimeTemplates, 'ecosystem.config.cjs'), join(targetDir, 'ecosystem.config.cjs'));
|
|
86
85
|
|
|
87
86
|
writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
|
|
@@ -257,16 +256,16 @@ function envBool(v) {
|
|
|
257
256
|
}
|
|
258
257
|
|
|
259
258
|
// The deployment's package.json depends on @selvajs/selva (prebuilt
|
|
260
|
-
// SvelteKit app
|
|
261
|
-
//
|
|
259
|
+
// SvelteKit app with all providers bundled in) plus @selvajs/cli (the
|
|
260
|
+
// operator-side tool). Provider selection is env-driven — no provider
|
|
261
|
+
// packages need to be listed here.
|
|
262
262
|
//
|
|
263
|
-
// We
|
|
263
|
+
// We list @selvajs/cli itself as a dep so the `selva` bin gets linked
|
|
264
264
|
// into node_modules/.bin/. Without this the operator's only way to run
|
|
265
265
|
// `selva doctor` / `selva start` is a global install of the CLI.
|
|
266
|
-
function buildPackageJson(name
|
|
266
|
+
function buildPackageJson(name /*, values */) {
|
|
267
267
|
const deps = {
|
|
268
268
|
'@selvajs/cli': 'latest',
|
|
269
|
-
'@selvajs/platform': 'latest',
|
|
270
269
|
'@selvajs/selva': 'latest',
|
|
271
270
|
// pm2 lives in the deployment's own node_modules so `selva start` can
|
|
272
271
|
// resolve it via node_modules/.bin/pm2 without a global install. The
|
|
@@ -274,16 +273,6 @@ function buildPackageJson(name, values) {
|
|
|
274
273
|
pm2: '^5.4.0'
|
|
275
274
|
};
|
|
276
275
|
|
|
277
|
-
const providers = new Set([
|
|
278
|
-
values.SELVA_AUTH_PROVIDER,
|
|
279
|
-
values.SELVA_DATA_PROVIDER,
|
|
280
|
-
values.SELVA_STORAGE_PROVIDER
|
|
281
|
-
]);
|
|
282
|
-
|
|
283
|
-
if (providers.has('local')) deps['@selvajs/local-provider'] = 'latest';
|
|
284
|
-
if (providers.has('supabase')) deps['@selvajs/supabase-provider'] = 'latest';
|
|
285
|
-
if (providers.has('header')) deps['@selvajs/header-auth-provider'] = 'latest';
|
|
286
|
-
|
|
287
276
|
// Use `selva` (resolved via node_modules/.bin) in the npm scripts. Running
|
|
288
277
|
// them as `npm run start` / `npm run doctor` works without remembering the
|
|
289
278
|
// ./node_modules/.bin/ prefix.
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
// `selva doctor` — validate a deployment without starting it.
|
|
2
2
|
//
|
|
3
3
|
// Checks:
|
|
4
|
-
// • .env
|
|
4
|
+
// • .env and ecosystem.config.cjs exist
|
|
5
|
+
// • Layout drift (legacy provider packages, stale selva.config.js, etc.)
|
|
5
6
|
// • Secrets are present and look like 32-byte hex
|
|
6
7
|
// • DATA_PATH writable (when local provider is in use)
|
|
7
8
|
// • Supabase URL reachable (when supabase provider is in use)
|
|
8
|
-
// • @selvajs/selva
|
|
9
|
+
// • @selvajs/selva installed
|
|
9
10
|
// • Origin set when behind a reverse proxy looks set
|
|
10
11
|
//
|
|
11
12
|
// Exits 0 (green) or 1 (any red); yellow checks don't fail the run.
|
|
12
13
|
|
|
13
|
-
import { existsSync, accessSync, constants, statSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, accessSync, constants, statSync } from 'node:fs';
|
|
14
15
|
import { join, resolve } from 'node:path';
|
|
15
16
|
import * as p from '@clack/prompts';
|
|
16
17
|
import pc from 'picocolors';
|
|
17
18
|
import { readEnvFile } from '../env.js';
|
|
18
19
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
20
|
+
import { detectDrift } from './migrate.js';
|
|
19
21
|
|
|
20
22
|
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
21
23
|
const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
|
|
@@ -31,16 +33,21 @@ export async function runDoctor() {
|
|
|
31
33
|
|
|
32
34
|
// ── Files ──────────────────────────────────────────────────────────
|
|
33
35
|
checks.push(checkFile(join(dir, '.env'), '.env present'));
|
|
34
|
-
checks.push(checkFile(join(dir, 'selva.config.js'), 'selva.config.js present'));
|
|
35
36
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
36
37
|
|
|
38
|
+
// ── Layout drift ───────────────────────────────────────────────────
|
|
39
|
+
// Catches deployments still on the @selvajs/runtime layout (or other
|
|
40
|
+
// historical states) and points the operator at `selva migrate` instead
|
|
41
|
+
// of just letting the missing-package checks below scream.
|
|
42
|
+
checks.push(checkLayoutDrift(dir));
|
|
43
|
+
|
|
37
44
|
// ── Secrets ────────────────────────────────────────────────────────
|
|
38
45
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
39
46
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
40
47
|
|
|
41
48
|
// ── Provider wiring ────────────────────────────────────────────────
|
|
42
49
|
// `header` is only valid for the auth slot — data/storage stay
|
|
43
|
-
// local|supabase. Mirror what
|
|
50
|
+
// local|supabase. Mirror what providers.server.ts enforces.
|
|
44
51
|
const providers = {
|
|
45
52
|
auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
|
|
46
53
|
data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
|
|
@@ -83,10 +90,9 @@ export async function runDoctor() {
|
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
// ── Installed packages ─────────────────────────────────────────────
|
|
93
|
+
// Provider implementations are bundled into @selvajs/selva — only the
|
|
94
|
+
// runtime package needs to be on disk.
|
|
86
95
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
87
|
-
if (used.has('local')) checks.push(checkPackage(dir, '@selvajs/local-provider'));
|
|
88
|
-
if (used.has('supabase')) checks.push(checkPackage(dir, '@selvajs/supabase-provider'));
|
|
89
|
-
if (used.has('header')) checks.push(checkPackage(dir, '@selvajs/header-auth-provider'));
|
|
90
96
|
|
|
91
97
|
// ── Origin (best-effort) ───────────────────────────────────────────
|
|
92
98
|
if (env.ORIGIN) {
|
|
@@ -192,10 +198,38 @@ function checkPackage(dir, name) {
|
|
|
192
198
|
return green(`${name} installed`);
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
function checkLayoutDrift(dir) {
|
|
202
|
+
const pkgPath = join(dir, 'package.json');
|
|
203
|
+
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|
|
204
|
+
let pkg;
|
|
205
|
+
try {
|
|
206
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
207
|
+
} catch {
|
|
208
|
+
return red('package.json is not valid JSON');
|
|
209
|
+
}
|
|
210
|
+
const reasons = detectDrift(pkg, dir);
|
|
211
|
+
if (reasons.length === 0) return green('deployment layout is current');
|
|
212
|
+
return red(
|
|
213
|
+
`deployment layout is outdated — run \`selva migrate\`:\n ` +
|
|
214
|
+
reasons.map((r) => '· ' + r).join('\n ')
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Defaults must match HeaderAuthProvider.DEFAULT_HEADERS. Duplicated here so
|
|
219
|
+
// doctor doesn't have to load the runtime. If the provider's defaults ever
|
|
220
|
+
// change, update both places — there's a smoke test in providers/header-auth
|
|
221
|
+
// that pins them, so a divergence would surface in CI.
|
|
222
|
+
const DEFAULT_HEADER_NAMES = {
|
|
223
|
+
upn: 'SELVA-UserPrincipalName',
|
|
224
|
+
email: 'SELVA-Email',
|
|
225
|
+
displayName: 'SELVA-DisplayName'
|
|
226
|
+
};
|
|
227
|
+
|
|
195
228
|
// Header-auth-specific sanity checks. None of these catch the truly dangerous
|
|
196
229
|
// misconfigurations (header spoofing, missing proxy auth) — those are
|
|
197
230
|
// runtime invariants we can't verify from here. We DO check the things we can:
|
|
198
|
-
// allowlist file presence, HOST binding, ORIGIN, and
|
|
231
|
+
// allowlist file presence, HOST binding, ORIGIN, bootstrap admin, and the
|
|
232
|
+
// resolved header names so they can be diffed against the proxy config.
|
|
199
233
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
200
234
|
const out = [];
|
|
201
235
|
|
|
@@ -204,7 +238,8 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
204
238
|
if (!allowlistDir) {
|
|
205
239
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
206
240
|
} else {
|
|
207
|
-
const
|
|
241
|
+
const allowlistAbsDir = resolve(dir, allowlistDir);
|
|
242
|
+
const allowlistPath = join(allowlistAbsDir, 'header-allowlist.json');
|
|
208
243
|
if (existsSync(allowlistPath)) {
|
|
209
244
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
210
245
|
} else {
|
|
@@ -216,6 +251,14 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
216
251
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
217
252
|
)
|
|
218
253
|
);
|
|
254
|
+
|
|
255
|
+
// If we expect lazy creation, the dir (or its parent) needs to be
|
|
256
|
+
// writable. Only check when HEADER_AUTH_DATA_DIR is set explicitly
|
|
257
|
+
// — when falling back to DATA_PATH the `local` provider's own
|
|
258
|
+
// checkDataPath has already covered the same ground.
|
|
259
|
+
if (env.HEADER_AUTH_DATA_DIR) {
|
|
260
|
+
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
261
|
+
}
|
|
219
262
|
}
|
|
220
263
|
}
|
|
221
264
|
|
|
@@ -264,5 +307,56 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
264
307
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
265
308
|
}
|
|
266
309
|
|
|
310
|
+
// 6. Resolved header names. The most common header-auth boot symptom is
|
|
311
|
+
// `user:null` because the proxy sets one set of names and the provider
|
|
312
|
+
// reads another. Print what the provider WILL read so the operator can
|
|
313
|
+
// diff it against the Caddyfile / oauth2-proxy config. We don't fail on
|
|
314
|
+
// custom names — operators legitimately override these for non-Caddy
|
|
315
|
+
// proxies — but yellow-flag the case where one is overridden and the
|
|
316
|
+
// others aren't, since a partial override is almost always a typo.
|
|
317
|
+
const resolved = {
|
|
318
|
+
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
319
|
+
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
|
320
|
+
displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
|
|
321
|
+
};
|
|
322
|
+
const overrides = [
|
|
323
|
+
Boolean(env.HEADER_AUTH_UPN_HEADER),
|
|
324
|
+
Boolean(env.HEADER_AUTH_EMAIL_HEADER),
|
|
325
|
+
Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
326
|
+
].filter(Boolean).length;
|
|
327
|
+
const headerList =
|
|
328
|
+
`UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
329
|
+
if (overrides === 0) {
|
|
330
|
+
out.push(green(`header names (bundled defaults): ${headerList}`));
|
|
331
|
+
} else if (overrides === 3) {
|
|
332
|
+
out.push(green(`header names (all overridden): ${headerList}`));
|
|
333
|
+
} else {
|
|
334
|
+
out.push(
|
|
335
|
+
yellow(
|
|
336
|
+
`header names partially overridden (${overrides}/3 set): ${headerList} — ` +
|
|
337
|
+
`a partial override is usually a typo. Set all three or none.`
|
|
338
|
+
)
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
267
342
|
return out;
|
|
268
343
|
}
|
|
344
|
+
|
|
345
|
+
function checkDirWritable(absDir, label) {
|
|
346
|
+
try {
|
|
347
|
+
if (existsSync(absDir)) {
|
|
348
|
+
const stat = statSync(absDir);
|
|
349
|
+
if (!stat.isDirectory()) return red(`${label} exists but isn't a directory`);
|
|
350
|
+
accessSync(absDir, constants.W_OK);
|
|
351
|
+
return green(`${label} writable`);
|
|
352
|
+
}
|
|
353
|
+
const parent = resolve(absDir, '..');
|
|
354
|
+
if (!existsSync(parent)) {
|
|
355
|
+
return yellow(`${label} doesn't exist yet (parent missing: ${parent})`);
|
|
356
|
+
}
|
|
357
|
+
accessSync(parent, constants.W_OK);
|
|
358
|
+
return yellow(`${label} doesn't exist yet — will be created on first run`);
|
|
359
|
+
} catch {
|
|
360
|
+
return red(`${label} not writable`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// `selva migrate` — bring an existing deployment onto the current layout.
|
|
2
|
+
//
|
|
3
|
+
// Three historical migrations exist for Selva deployments:
|
|
4
|
+
// 1. `@selvajs/create` → `@selvajs/cli` (CLI bootstrap; can't be automated
|
|
5
|
+
// since the operator has no `selva` binary yet — they run two npm
|
|
6
|
+
// commands by hand from the Hotfix doc).
|
|
7
|
+
// 2. `@selvajs/runtime` → `@selvajs/selva` (runtime bundling: UI, schemas,
|
|
8
|
+
// ui-kit and the providers' workspace deps are now built into
|
|
9
|
+
// @selvajs/selva).
|
|
10
|
+
// 3. selva.config.js → env-driven providers (the picker logic moved into
|
|
11
|
+
// the runtime; deployments no longer need a config file. Provider
|
|
12
|
+
// packages are bundled into @selvajs/selva.)
|
|
13
|
+
//
|
|
14
|
+
// Migrate automates 2 and 3 together: it rewrites package.json, drops the
|
|
15
|
+
// now-bundled provider packages, removes any stale selva.config.js, and
|
|
16
|
+
// rewrites ecosystem.config.cjs if it still points at @selvajs/runtime.
|
|
17
|
+
//
|
|
18
|
+
// Future package-layout shifts go here too — the command should remain
|
|
19
|
+
// idempotent. On an already-current deployment it prints "nothing to
|
|
20
|
+
// migrate" and exits 0.
|
|
21
|
+
//
|
|
22
|
+
// Mirrors `selva update`'s lifecycle: stop pm2, mutate node_modules, start
|
|
23
|
+
// pm2 again with --update-env. Rollback restores package.json.bak on
|
|
24
|
+
// npm-install failure so the operator isn't left with a broken deployment.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
copyFileSync,
|
|
31
|
+
rmSync
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
35
|
+
import * as p from '@clack/prompts';
|
|
36
|
+
import pc from 'picocolors';
|
|
37
|
+
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
38
|
+
|
|
39
|
+
const APP_NAME = 'selva-compute';
|
|
40
|
+
|
|
41
|
+
// Packages we own. Everything in this set is rewritten wholesale by migrate;
|
|
42
|
+
// anything outside it (operator's own deps) gets dropped, which is the
|
|
43
|
+
// explicit design choice — operators with custom deps should fork the
|
|
44
|
+
// template rather than hand-patch the deployment package.json.
|
|
45
|
+
const SELVA_DEPS = new Set([
|
|
46
|
+
'@selvajs/cli',
|
|
47
|
+
'@selvajs/selva',
|
|
48
|
+
'@selvajs/runtime', // legacy — removed during migrate
|
|
49
|
+
'@selvajs/platform', // legacy — bundled into @selvajs/selva
|
|
50
|
+
'@selvajs/local-provider', // legacy — bundled into @selvajs/selva
|
|
51
|
+
'@selvajs/supabase-provider', // legacy — bundled into @selvajs/selva
|
|
52
|
+
'@selvajs/header-auth-provider', // legacy — bundled into @selvajs/selva
|
|
53
|
+
'@selvajs/create' // legacy CLI — removed during migrate
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const CANONICAL_SCRIPTS = {
|
|
57
|
+
start: 'selva start',
|
|
58
|
+
stop: 'selva stop',
|
|
59
|
+
restart: 'selva restart',
|
|
60
|
+
logs: 'selva logs',
|
|
61
|
+
doctor: 'selva doctor',
|
|
62
|
+
update: 'selva update'
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export async function runMigrate() {
|
|
66
|
+
const dir = resolveDeploymentDir();
|
|
67
|
+
requireDeploymentDir(dir);
|
|
68
|
+
|
|
69
|
+
p.intro(pc.bgCyan(pc.black(' selva migrate ')));
|
|
70
|
+
|
|
71
|
+
const pkgPath = join(dir, 'package.json');
|
|
72
|
+
if (!existsSync(pkgPath)) {
|
|
73
|
+
p.outro(pc.red('No package.json in this directory — nothing to migrate.'));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
78
|
+
const target = buildTargetPackageJson(before);
|
|
79
|
+
const pkgDiff = diffPackageJson(before, target);
|
|
80
|
+
|
|
81
|
+
// Side-files: selva.config.js (now unused) and ecosystem.config.cjs
|
|
82
|
+
// (must point at @selvajs/selva, not @selvajs/runtime).
|
|
83
|
+
const configPath = join(dir, 'selva.config.js');
|
|
84
|
+
const hasStaleConfig = existsSync(configPath);
|
|
85
|
+
|
|
86
|
+
const ecoPath = join(dir, 'ecosystem.config.cjs');
|
|
87
|
+
const ecoHasStaleRuntime =
|
|
88
|
+
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
89
|
+
|
|
90
|
+
const sideFileChanges = [];
|
|
91
|
+
if (hasStaleConfig) sideFileChanges.push(`${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`);
|
|
92
|
+
if (ecoHasStaleRuntime) sideFileChanges.push(`${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`);
|
|
93
|
+
|
|
94
|
+
if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
|
|
95
|
+
p.outro(pc.green('Already on the current layout — nothing to migrate.'));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
p.log.info('Changes to apply:');
|
|
100
|
+
for (const line of pkgDiff) console.log(' ' + line);
|
|
101
|
+
for (const line of sideFileChanges) console.log(' ' + line);
|
|
102
|
+
|
|
103
|
+
const confirmed = await p.confirm({
|
|
104
|
+
message: 'Apply these changes, reinstall, and restart?',
|
|
105
|
+
initialValue: true
|
|
106
|
+
});
|
|
107
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
108
|
+
p.cancel('Cancelled.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Stop pm2 before mutating node_modules. Same reasoning as `selva update`:
|
|
113
|
+
// SvelteKit's node adapter lazy-imports chunks, so swapping the build dir
|
|
114
|
+
// under a live process causes ERR_MODULE_NOT_FOUND on in-flight requests.
|
|
115
|
+
const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
116
|
+
if (stopStatus !== 0) {
|
|
117
|
+
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Back up everything we're about to mutate. Restored on npm-install failure
|
|
121
|
+
// so the operator isn't left with a half-migrated deployment.
|
|
122
|
+
const bakPath = pkgPath + '.bak';
|
|
123
|
+
copyFileSync(pkgPath, bakPath);
|
|
124
|
+
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
125
|
+
|
|
126
|
+
if (hasStaleConfig) {
|
|
127
|
+
copyFileSync(configPath, configPath + '.bak');
|
|
128
|
+
rmSync(configPath, { force: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (ecoHasStaleRuntime) {
|
|
132
|
+
copyFileSync(ecoPath, ecoPath + '.bak');
|
|
133
|
+
// Single-line rewrite: only @selvajs/runtime → @selvajs/selva. We don't
|
|
134
|
+
// regenerate from the canonical template here because the operator may
|
|
135
|
+
// have customized port/memory/cluster settings; preserve their edits.
|
|
136
|
+
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
137
|
+
/@selvajs\/runtime/g,
|
|
138
|
+
'@selvajs/selva'
|
|
139
|
+
);
|
|
140
|
+
writeFileSync(ecoPath, ecoContent, 'utf8');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Nuke node_modules + lockfile. A simple `npm install` won't always
|
|
144
|
+
// resolve correctly when major version ranges change (e.g. runtime 0.10
|
|
145
|
+
// → selva 2.0) — the lockfile pins old transitive deps that no longer
|
|
146
|
+
// belong. Clean install is the only reliable path.
|
|
147
|
+
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
|
|
148
|
+
rmSync(join(dir, 'package-lock.json'), { force: true });
|
|
149
|
+
|
|
150
|
+
const s = p.spinner();
|
|
151
|
+
s.start('Installing new dependencies (this can take a minute)');
|
|
152
|
+
try {
|
|
153
|
+
// --prefer-online to bypass the stale-packument trap documented in
|
|
154
|
+
// docs/Hotfix-CLI-Runtime.md.
|
|
155
|
+
execSync('npm install --prefer-online', {
|
|
156
|
+
cwd: dir,
|
|
157
|
+
stdio: 'pipe'
|
|
158
|
+
});
|
|
159
|
+
s.stop('Dependencies installed');
|
|
160
|
+
} catch (err) {
|
|
161
|
+
s.stop(pc.red('npm install failed — rolling back package.json'));
|
|
162
|
+
copyFileSync(bakPath, pkgPath);
|
|
163
|
+
if (hasStaleConfig && existsSync(configPath + '.bak')) {
|
|
164
|
+
copyFileSync(configPath + '.bak', configPath);
|
|
165
|
+
}
|
|
166
|
+
if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
|
|
167
|
+
copyFileSync(ecoPath + '.bak', ecoPath);
|
|
168
|
+
}
|
|
169
|
+
// Try to bring the old process back up so we don't leave the operator
|
|
170
|
+
// with downtime. If node_modules was wiped this won't help, but at
|
|
171
|
+
// least package.json matches what's on disk.
|
|
172
|
+
runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
173
|
+
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
178
|
+
const backupHints = ['package.json.bak'];
|
|
179
|
+
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
180
|
+
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
181
|
+
|
|
182
|
+
if (status === 0) {
|
|
183
|
+
p.outro(
|
|
184
|
+
[
|
|
185
|
+
pc.green('Migration complete.'),
|
|
186
|
+
pc.dim('Backups saved as ') + pc.cyan(backupHints.join(', ')),
|
|
187
|
+
pc.dim('Run ') + pc.cyan('selva doctor') + pc.dim(' to verify.')
|
|
188
|
+
].join('\n')
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
p.outro(pc.yellow(`Migration applied but pm2 start failed — check \`pm2 logs ${APP_NAME}\`.`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Compute what package.json should look like given the current contents.
|
|
196
|
+
//
|
|
197
|
+
// Wholesale-replace semantics: any @selvajs/* or pm2 entry not in our
|
|
198
|
+
// canonical set is dropped. Non-selva deps the operator added are also
|
|
199
|
+
// dropped — that's the design choice we made up-front.
|
|
200
|
+
//
|
|
201
|
+
// Provider packages (@selvajs/local-provider etc.) are NOT preserved even
|
|
202
|
+
// when the operator's .env selects them: they're bundled into @selvajs/selva
|
|
203
|
+
// in v2.1+ and the standalone packages are legacy. Providers are picked at
|
|
204
|
+
// runtime from SELVA_*_PROVIDER env vars.
|
|
205
|
+
function buildTargetPackageJson(current) {
|
|
206
|
+
const deps = {
|
|
207
|
+
'@selvajs/cli': 'latest',
|
|
208
|
+
'@selvajs/selva': 'latest',
|
|
209
|
+
pm2: '^5.4.0'
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
name: current.name ?? 'selva-deployment',
|
|
214
|
+
version: current.version ?? '0.1.0',
|
|
215
|
+
private: true,
|
|
216
|
+
type: 'module',
|
|
217
|
+
scripts: { ...CANONICAL_SCRIPTS },
|
|
218
|
+
dependencies: deps
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Produce human-readable diff lines for the confirmation prompt. Keep it
|
|
223
|
+
// short — the operator just needs to see what's moving, not a unified diff.
|
|
224
|
+
function diffPackageJson(before, after) {
|
|
225
|
+
const lines = [];
|
|
226
|
+
|
|
227
|
+
const beforeDeps = before.dependencies ?? {};
|
|
228
|
+
const afterDeps = after.dependencies ?? {};
|
|
229
|
+
|
|
230
|
+
const allNames = new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)]);
|
|
231
|
+
for (const name of [...allNames].sort()) {
|
|
232
|
+
const a = beforeDeps[name];
|
|
233
|
+
const b = afterDeps[name];
|
|
234
|
+
if (a && !b) lines.push(`${pc.red('-')} ${name} ${pc.dim(a)}`);
|
|
235
|
+
else if (!a && b) lines.push(`${pc.green('+')} ${name} ${pc.dim(b)}`);
|
|
236
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const beforeScripts = before.scripts ?? {};
|
|
240
|
+
const afterScripts = after.scripts ?? {};
|
|
241
|
+
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
242
|
+
for (const name of [...allScripts].sort()) {
|
|
243
|
+
const a = beforeScripts[name];
|
|
244
|
+
const b = afterScripts[name];
|
|
245
|
+
if (a !== b) {
|
|
246
|
+
if (a && !b) lines.push(`${pc.red('-')} script.${name} ${pc.dim(a)}`);
|
|
247
|
+
else if (!a && b) lines.push(`${pc.green('+')} script.${name} ${pc.dim(b)}`);
|
|
248
|
+
else lines.push(`${pc.yellow('~')} script.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return lines;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Local copy of pm2.js's runner. Importing it would create a circular
|
|
256
|
+
// dependency via the `pm2.js` exports; pm2 invocation is small enough to
|
|
257
|
+
// duplicate.
|
|
258
|
+
function runPm2(dir, args, { inherit = true } = {}) {
|
|
259
|
+
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
260
|
+
const bin = existsSync(local) ? local : 'pm2';
|
|
261
|
+
const result = spawnSync(bin, args, {
|
|
262
|
+
cwd: dir,
|
|
263
|
+
stdio: inherit ? 'inherit' : 'pipe',
|
|
264
|
+
shell: process.platform === 'win32'
|
|
265
|
+
});
|
|
266
|
+
if (result.error) return 1;
|
|
267
|
+
return result.status ?? 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Exported for use by `selva doctor` so it can warn about layout drift
|
|
271
|
+
// without duplicating the detection logic.
|
|
272
|
+
export function detectDrift(pkgJson, dir) {
|
|
273
|
+
const deps = pkgJson?.dependencies ?? {};
|
|
274
|
+
const reasons = [];
|
|
275
|
+
if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
|
|
276
|
+
if (deps['@selvajs/create']) reasons.push('@selvajs/create is the old CLI package');
|
|
277
|
+
if (deps['@selvajs/platform']) reasons.push('@selvajs/platform is now bundled into @selvajs/selva');
|
|
278
|
+
if (deps['@selvajs/local-provider'])
|
|
279
|
+
reasons.push('@selvajs/local-provider is now bundled into @selvajs/selva');
|
|
280
|
+
if (deps['@selvajs/supabase-provider'])
|
|
281
|
+
reasons.push('@selvajs/supabase-provider is now bundled into @selvajs/selva');
|
|
282
|
+
if (deps['@selvajs/header-auth-provider'])
|
|
283
|
+
reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
|
|
284
|
+
if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
|
|
285
|
+
if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
|
|
286
|
+
if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
|
|
287
|
+
|
|
288
|
+
if (dir) {
|
|
289
|
+
const configPath = join(dir, 'selva.config.js');
|
|
290
|
+
if (existsSync(configPath)) {
|
|
291
|
+
reasons.push('selva.config.js is no longer needed (providers are env-driven)');
|
|
292
|
+
}
|
|
293
|
+
const ecoPath = join(dir, 'ecosystem.config.cjs');
|
|
294
|
+
if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
|
|
295
|
+
reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return reasons;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Re-exported so tests/imports can verify what migrate would write without
|
|
303
|
+
// actually running it.
|
|
304
|
+
export { buildTargetPackageJson, SELVA_DEPS };
|
package/src/commands/pm2.js
CHANGED
|
@@ -78,17 +78,10 @@ export async function runUpdate() {
|
|
|
78
78
|
const before = readRuntimeVersion(dir);
|
|
79
79
|
p.log.info(`Current @selvajs/selva: ${before ?? 'unknown'}`);
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
const packages = [
|
|
85
|
-
'@selvajs/cli',
|
|
86
|
-
'@selvajs/selva',
|
|
87
|
-
'@selvajs/platform',
|
|
88
|
-
'@selvajs/local-provider',
|
|
89
|
-
'@selvajs/supabase-provider',
|
|
90
|
-
'@selvajs/header-auth-provider'
|
|
91
|
-
];
|
|
81
|
+
// Providers are bundled into @selvajs/selva — the only @selvajs/* packages
|
|
82
|
+
// an operator install carries are the runtime and the CLI. The admin-center
|
|
83
|
+
// "Run update" button runs the same list — keep them in sync if you edit.
|
|
84
|
+
const packages = ['@selvajs/cli', '@selvajs/selva'];
|
|
92
85
|
|
|
93
86
|
const confirmed = await p.confirm({
|
|
94
87
|
message: 'Refresh all @selvajs/* packages and restart the app?',
|
package/src/env.js
CHANGED
|
@@ -78,7 +78,13 @@ export function mergeEnv(template, values) {
|
|
|
78
78
|
out.push(...appended);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
// Always end with a single trailing newline. Without it, a later
|
|
82
|
+
// `echo VAR=value >> .env` concatenates onto the last line, producing
|
|
83
|
+
// `HOST=127.0.0.1HEADER_AUTH_DATA_DIR=...` and a `getaddrinfo ENOTFOUND`
|
|
84
|
+
// boot crash. Strip any existing trailing blanks first so we don't grow
|
|
85
|
+
// the file by a line on each rewrite.
|
|
86
|
+
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
87
|
+
return out.join('\n') + '\n';
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
export function writeEnvFile(path, template, values) {
|
package/bin/create.js
DELETED