@selvajs/cli 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ // `selva doctor` — validate a deployment without starting it.
2
+ //
3
+ // Checks:
4
+ // • .env exists and has the required keys for the chosen providers
5
+ // • Secrets are present and look like 32-byte hex
6
+ // • DATA_PATH writable (when local provider is in use)
7
+ // • Supabase URL reachable (when supabase provider is in use)
8
+ // • @selvajs/selva + chosen provider packages installed
9
+ // • Origin set when behind a reverse proxy looks set
10
+ //
11
+ // Exits 0 (green) or 1 (any red); yellow checks don't fail the run.
12
+
13
+ import { existsSync, accessSync, constants, statSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ import * as p from '@clack/prompts';
16
+ import pc from 'picocolors';
17
+ import { readEnvFile } from '../env.js';
18
+ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
19
+
20
+ const HEX_64 = /^[0-9a-f]{64}$/i;
21
+ const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
22
+
23
+ export async function runDoctor() {
24
+ const dir = resolveDeploymentDir();
25
+ requireDeploymentDir(dir);
26
+
27
+ p.intro(pc.bgCyan(pc.black(' selva doctor ')));
28
+
29
+ const checks = [];
30
+ const env = readEnvFile(join(dir, '.env'));
31
+
32
+ // ── Files ──────────────────────────────────────────────────────────
33
+ checks.push(checkFile(join(dir, '.env'), '.env present'));
34
+ checks.push(checkFile(join(dir, 'selva.config.js'), 'selva.config.js present'));
35
+ checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
36
+
37
+ // ── Secrets ────────────────────────────────────────────────────────
38
+ checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
39
+ checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
40
+
41
+ // ── Provider wiring ────────────────────────────────────────────────
42
+ // `header` is only valid for the auth slot — data/storage stay
43
+ // local|supabase. Mirror what selva.config.ts enforces.
44
+ const providers = {
45
+ auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
46
+ data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
47
+ storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
48
+ };
49
+
50
+ const validForAuth = new Set(['local', 'supabase', 'header']);
51
+ const validForData = new Set(['local', 'supabase']);
52
+
53
+ if (!validForAuth.has(providers.auth)) {
54
+ checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
55
+ }
56
+ if (!validForData.has(providers.data)) {
57
+ checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
58
+ }
59
+ if (!validForData.has(providers.storage)) {
60
+ checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
61
+ }
62
+
63
+ const used = new Set(Object.values(providers));
64
+
65
+ if (used.has('local')) {
66
+ checks.push(checkDataPath(dir, env.DATA_PATH ?? './.selva-data'));
67
+ }
68
+
69
+ if (used.has('supabase')) {
70
+ checks.push(checkSupabase(env));
71
+ }
72
+
73
+ if (providers.auth === 'header') {
74
+ checks.push(...checkHeaderAuth(dir, env, providers.data));
75
+ }
76
+
77
+ // ── Tenancy ────────────────────────────────────────────────────────
78
+ const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
79
+ if (tenancy !== 'single' && tenancy !== 'multi') {
80
+ checks.push(red(`SELVA_TENANCY="${tenancy}" — expected single|multi`));
81
+ } else {
82
+ checks.push(green(`SELVA_TENANCY=${tenancy}`));
83
+ }
84
+
85
+ // ── Installed packages ─────────────────────────────────────────────
86
+ 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
+
91
+ // ── Origin (best-effort) ───────────────────────────────────────────
92
+ if (env.ORIGIN) {
93
+ try {
94
+ new URL(env.ORIGIN);
95
+ checks.push(green(`ORIGIN=${env.ORIGIN}`));
96
+ } catch {
97
+ checks.push(red(`ORIGIN="${env.ORIGIN}" is not a valid URL`));
98
+ }
99
+ } else {
100
+ checks.push(yellow('ORIGIN unset — required behind a reverse proxy'));
101
+ }
102
+
103
+ // ── Render ─────────────────────────────────────────────────────────
104
+ let failures = 0;
105
+ for (const c of await Promise.all(checks)) {
106
+ console.log(' ' + c.line);
107
+ if (c.severity === 'red') failures += 1;
108
+ }
109
+
110
+ if (failures === 0) {
111
+ p.outro(pc.green('All checks passed.'));
112
+ } else {
113
+ p.outro(pc.red(`${failures} check${failures === 1 ? '' : 's'} failed.`));
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ function green(text) {
119
+ return { severity: 'green', line: `${pc.green('✓')} ${text}` };
120
+ }
121
+ function yellow(text) {
122
+ return { severity: 'yellow', line: `${pc.yellow('!')} ${text}` };
123
+ }
124
+ function red(text) {
125
+ return { severity: 'red', line: `${pc.red('✗')} ${text}` };
126
+ }
127
+
128
+ function checkFile(path, label) {
129
+ return existsSync(path) ? green(label) : red(`${label} (missing: ${path})`);
130
+ }
131
+
132
+ function checkSecret(value, label) {
133
+ if (!value) return red(`${label} — unset`);
134
+ if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
135
+ if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
136
+ return green(label);
137
+ }
138
+
139
+ function checkDataPath(dir, dataPath) {
140
+ const absolute = resolve(dir, dataPath);
141
+ try {
142
+ if (existsSync(absolute)) {
143
+ const stat = statSync(absolute);
144
+ if (!stat.isDirectory()) {
145
+ return red(`DATA_PATH=${dataPath} exists but isn't a directory`);
146
+ }
147
+ accessSync(absolute, constants.W_OK);
148
+ return green(`DATA_PATH=${dataPath} (writable)`);
149
+ }
150
+ // Parent must be writable so the runtime can create the directory.
151
+ const parent = resolve(absolute, '..');
152
+ if (!existsSync(parent)) {
153
+ return yellow(`DATA_PATH=${dataPath} doesn't exist yet (parent missing: ${parent})`);
154
+ }
155
+ accessSync(parent, constants.W_OK);
156
+ return yellow(`DATA_PATH=${dataPath} doesn't exist yet — will be created on first run`);
157
+ } catch {
158
+ return red(`DATA_PATH=${dataPath} not writable`);
159
+ }
160
+ }
161
+
162
+ async function checkSupabase(env) {
163
+ if (!env.SUPABASE_URL) return red('SUPABASE_URL unset');
164
+ if (!env.SUPABASE_ANON_KEY) return red('SUPABASE_ANON_KEY unset');
165
+ if (!env.SUPABASE_SERVICE_ROLE_KEY) return red('SUPABASE_SERVICE_ROLE_KEY unset');
166
+
167
+ try {
168
+ new URL(env.SUPABASE_URL);
169
+ } catch {
170
+ return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
171
+ }
172
+
173
+ // Ping the Supabase health endpoint. Soft-fail to yellow on network
174
+ // errors — operators may be offline at install time.
175
+ try {
176
+ const controller = new AbortController();
177
+ const timer = setTimeout(() => controller.abort(), 4000);
178
+ const res = await fetch(env.SUPABASE_URL + '/auth/v1/health', {
179
+ signal: controller.signal
180
+ });
181
+ clearTimeout(timer);
182
+ if (res.ok) return green(`SUPABASE_URL reachable (${res.status})`);
183
+ return yellow(`SUPABASE_URL responded ${res.status} — check project status`);
184
+ } catch (err) {
185
+ return yellow(`SUPABASE_URL unreachable (${err.message ?? err}) — skipping`);
186
+ }
187
+ }
188
+
189
+ function checkPackage(dir, name) {
190
+ const path = join(dir, 'node_modules', ...name.split('/'), 'package.json');
191
+ if (!existsSync(path)) return red(`${name} not installed (run npm install)`);
192
+ return green(`${name} installed`);
193
+ }
194
+
195
+ // Header-auth-specific sanity checks. None of these catch the truly dangerous
196
+ // misconfigurations (header spoofing, missing proxy auth) — those are
197
+ // runtime invariants we can't verify from here. We DO check the things we can:
198
+ // allowlist file presence, HOST binding, ORIGIN, and logout URL.
199
+ function checkHeaderAuth(dir, env, dataProvider) {
200
+ const out = [];
201
+
202
+ // 1. Where does header-allowlist.json live?
203
+ const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
204
+ if (!allowlistDir) {
205
+ out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
206
+ } else {
207
+ const allowlistPath = resolve(dir, allowlistDir, 'header-allowlist.json');
208
+ if (existsSync(allowlistPath)) {
209
+ out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
210
+ } else {
211
+ // The provider creates it lazily, but a fresh deployment with no
212
+ // allowlisted UPNs locks everyone out — surface this so the
213
+ // operator knows to bootstrap.
214
+ out.push(
215
+ yellow(
216
+ `header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
217
+ )
218
+ );
219
+ }
220
+ }
221
+
222
+ // 2. HOST binding. Loopback is strongly recommended for header-auth.
223
+ const host = env.HOST ?? '0.0.0.0';
224
+ if (host === '127.0.0.1' || host === 'localhost') {
225
+ out.push(green(`HOST=${host} (loopback-only)`));
226
+ } else {
227
+ out.push(
228
+ yellow(
229
+ `HOST=${host} — header-auth deployments should bind to 127.0.0.1 unless ` +
230
+ `network isolation is enforced elsewhere (firewall, Docker network).`
231
+ )
232
+ );
233
+ }
234
+
235
+ // 3. ORIGIN — header-auth implies a reverse proxy, so ORIGIN is required.
236
+ if (!env.ORIGIN) {
237
+ out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
238
+ }
239
+
240
+ // 4. If data provider isn't local, the allowlist file is the ONLY local
241
+ // state — make sure the operator picked an explicit dir, not the
242
+ // fall-through DATA_PATH which may not be set.
243
+ if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
244
+ out.push(
245
+ red(
246
+ 'HEADER_AUTH_DATA_DIR must be set when data provider is not local ' +
247
+ '(no DATA_PATH to fall back to)'
248
+ )
249
+ );
250
+ }
251
+
252
+ // 5. Bootstrap admin email. Without it, the first proxy-authenticated
253
+ // visitor's UPN is rejected (not in allowlist) and the operator has to
254
+ // hand-write JSON to claim admin. With it, the first matching visit is
255
+ // auto-allowlisted and granted instance_admin in one step.
256
+ if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
257
+ out.push(
258
+ red(
259
+ 'BOOTSTRAP_INSTANCE_ADMIN_EMAIL unset — header-auth has no /setup form, ' +
260
+ 'so without this you cannot claim admin on first visit.'
261
+ )
262
+ );
263
+ } else {
264
+ out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
265
+ }
266
+
267
+ return out;
268
+ }
@@ -0,0 +1,76 @@
1
+ // `selva init` — reconfigure an existing deployment.
2
+ //
3
+ // Differences from `create`:
4
+ // • Reads current .env values; uses them as prompt defaults.
5
+ // • Never regenerates SELVA_HMAC_KEY / SELVA_AT_REST_KEY if they're set.
6
+ // Rotating those invalidates sessions and at-rest encryption — that's
7
+ // `selva keys rotate`'s job, not init's.
8
+ // • Doesn't touch package.json or run npm install.
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import * as p from '@clack/prompts';
13
+ import pc from 'picocolors';
14
+ import { collectConfig } from '../prompts.js';
15
+ import { readEnvFile, writeEnvFile } from '../env.js';
16
+ import { generateKey } from '../secrets.js';
17
+ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
18
+
19
+ export async function runInit() {
20
+ const dir = resolveDeploymentDir();
21
+ requireDeploymentDir(dir);
22
+
23
+ const envPath = join(dir, '.env');
24
+ const current = readEnvFile(envPath);
25
+ const values = await collectConfig({ defaults: current, mode: 'init' });
26
+
27
+ // Preserve secrets. The only safe time to generate them is at install
28
+ // time (no sessions to invalidate, no encrypted data to lose). If the
29
+ // existing .env doesn't have them — which can happen if a previous
30
+ // scaffold bailed mid-flight — generate now and warn.
31
+ if (
32
+ current.SELVA_HMAC_KEY &&
33
+ current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
34
+ ) {
35
+ values.SELVA_HMAC_KEY = current.SELVA_HMAC_KEY;
36
+ } else {
37
+ values.SELVA_HMAC_KEY = generateKey();
38
+ p.log.warn('SELVA_HMAC_KEY was missing — generated a fresh one.');
39
+ }
40
+
41
+ if (
42
+ current.SELVA_AT_REST_KEY &&
43
+ current.SELVA_AT_REST_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
44
+ ) {
45
+ values.SELVA_AT_REST_KEY = current.SELVA_AT_REST_KEY;
46
+ } else {
47
+ values.SELVA_AT_REST_KEY = generateKey();
48
+ p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
49
+ }
50
+
51
+ // Use whatever .env.example shipped with the installed runtime as the
52
+ // canonical template. Falls back to the current .env if the runtime
53
+ // templates aren't present (shouldn't happen post-install).
54
+ const templatePath = join(
55
+ dir,
56
+ 'node_modules',
57
+ '@selvajs',
58
+ 'selva',
59
+ 'templates',
60
+ '.env.example'
61
+ );
62
+ const template = existsSync(templatePath)
63
+ ? readFileSync(templatePath, 'utf8')
64
+ : existsSync(envPath)
65
+ ? readFileSync(envPath, 'utf8')
66
+ : '';
67
+
68
+ writeEnvFile(envPath, template, values);
69
+
70
+ p.outro(
71
+ [
72
+ pc.green('Updated ' + pc.cyan(envPath)),
73
+ pc.dim('Restart with: ') + pc.cyan('selva restart')
74
+ ].join('\n')
75
+ );
76
+ }
@@ -0,0 +1,93 @@
1
+ // `selva keys rotate <hmac|at-rest>` — generate a fresh secret and write it
2
+ // back to .env. Refuses without an explicit confirm; what gets invalidated is
3
+ // not subtle.
4
+ //
5
+ // hmac — SELVA_HMAC_KEY (HMAC-SHA256). Rotating logs every user out
6
+ // (cookie sessions stop verifying) and breaks any share-link /
7
+ // invite tokens that fell back to it (only relevant when
8
+ // SHARE_LINK_SECRET / INVITE_TOKEN_SECRET are unset — those have
9
+ // their own rotation cycle).
10
+ //
11
+ // at-rest — SELVA_AT_REST_KEY (AES-256-GCM). The encrypted Rhino.Compute
12
+ // API key in compute.config.json becomes undecryptable. The
13
+ // operator has to re-enter the key at /admin/compute.
14
+
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import * as p from '@clack/prompts';
18
+ import pc from 'picocolors';
19
+ import { readEnvFile, writeEnvFile } from '../env.js';
20
+ import { generateKey } from '../secrets.js';
21
+ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
22
+
23
+ const TARGETS = {
24
+ hmac: {
25
+ envVar: 'SELVA_HMAC_KEY',
26
+ warning: [
27
+ 'This will:',
28
+ pc.red(' • log every signed-in user out (existing session cookies stop verifying)'),
29
+ pc.red(' • invalidate share-link and invite tokens that fell back to this key'),
30
+ pc.dim(' (only relevant when SHARE_LINK_SECRET / INVITE_TOKEN_SECRET are unset)')
31
+ ].join('\n')
32
+ },
33
+ 'at-rest': {
34
+ envVar: 'SELVA_AT_REST_KEY',
35
+ warning: [
36
+ 'This will:',
37
+ pc.red(' • make the encrypted Rhino.Compute API key undecryptable'),
38
+ pc.red(' • require re-entering the API key at /admin/compute'),
39
+ pc.dim(' (other data is plaintext on disk — only the compute API key is encrypted)')
40
+ ].join('\n')
41
+ }
42
+ };
43
+
44
+ export async function runKeysRotate(argv) {
45
+ const target = argv[0];
46
+ if (!target || !(target in TARGETS)) {
47
+ console.error(`Usage: selva keys rotate <hmac|at-rest>`);
48
+ process.exit(1);
49
+ }
50
+
51
+ const dir = resolveDeploymentDir();
52
+ requireDeploymentDir(dir);
53
+
54
+ const { envVar, warning } = TARGETS[target];
55
+ const envPath = join(dir, '.env');
56
+ const current = readEnvFile(envPath);
57
+
58
+ p.intro(pc.bgYellow(pc.black(` rotate ${envVar} `)));
59
+ p.note(warning, 'Blast radius');
60
+
61
+ const confirmed = await p.confirm({
62
+ message: `Rotate ${envVar}?`,
63
+ initialValue: false
64
+ });
65
+ if (p.isCancel(confirmed) || !confirmed) {
66
+ p.cancel('Cancelled.');
67
+ return;
68
+ }
69
+
70
+ const fresh = generateKey();
71
+ current[envVar] = fresh;
72
+
73
+ const templatePath = join(
74
+ dir,
75
+ 'node_modules',
76
+ '@selvajs',
77
+ 'selva',
78
+ 'templates',
79
+ '.env.example'
80
+ );
81
+ const template = existsSync(templatePath)
82
+ ? readFileSync(templatePath, 'utf8')
83
+ : readFileSync(envPath, 'utf8');
84
+
85
+ writeEnvFile(envPath, template, current);
86
+
87
+ p.outro(
88
+ [
89
+ pc.green(`${envVar} rotated.`),
90
+ pc.dim('Restart the app to apply: ') + pc.cyan('selva restart')
91
+ ].join('\n')
92
+ );
93
+ }
@@ -0,0 +1,172 @@
1
+ // Thin wrappers around PM2 commands. The point isn't to abstract pm2 —
2
+ // it's to hide footguns the docs already warn about:
3
+ //
4
+ // • `pm2 restart` without --update-env silently keeps the old env, even
5
+ // after the operator edited .env. We always pass --update-env.
6
+ //
7
+ // • The PM2 binary may live in node_modules/.bin (project-local install)
8
+ // or globally. We resolve to the local one first so a fresh deployment
9
+ // works without `npm i -g pm2`.
10
+
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { spawnSync, execSync } from 'node:child_process';
14
+ import * as p from '@clack/prompts';
15
+ import pc from 'picocolors';
16
+ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
17
+
18
+ const APP_NAME = 'selva-compute';
19
+
20
+ function pm2Bin(dir) {
21
+ const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
22
+ if (existsSync(local)) return local;
23
+ return 'pm2'; // fall back to PATH
24
+ }
25
+
26
+ function runPm2(dir, args, { inherit = true } = {}) {
27
+ const bin = pm2Bin(dir);
28
+ const result = spawnSync(bin, args, {
29
+ cwd: dir,
30
+ stdio: inherit ? 'inherit' : 'pipe',
31
+ shell: process.platform === 'win32' // allow .cmd shim resolution
32
+ });
33
+ if (result.error) {
34
+ throw new Error(
35
+ `Failed to invoke pm2 (${bin}): ${result.error.message}. ` +
36
+ `Install pm2 with \`npm install pm2\` in this directory.`
37
+ );
38
+ }
39
+ return result.status ?? 0;
40
+ }
41
+
42
+ export async function runStart() {
43
+ const dir = resolveDeploymentDir();
44
+ requireDeploymentDir(dir);
45
+ const exit = runPm2(dir, ['start', 'ecosystem.config.cjs']);
46
+ process.exit(exit);
47
+ }
48
+
49
+ export async function runStop() {
50
+ const dir = resolveDeploymentDir();
51
+ requireDeploymentDir(dir);
52
+ const exit = runPm2(dir, ['stop', APP_NAME]);
53
+ process.exit(exit);
54
+ }
55
+
56
+ export async function runRestart() {
57
+ const dir = resolveDeploymentDir();
58
+ requireDeploymentDir(dir);
59
+ // --update-env is the whole point of this wrapper — without it, edits to
60
+ // .env have no effect on the running process.
61
+ const exit = runPm2(dir, ['restart', APP_NAME, '--update-env']);
62
+ process.exit(exit);
63
+ }
64
+
65
+ export async function runLogs(argv) {
66
+ const dir = resolveDeploymentDir();
67
+ requireDeploymentDir(dir);
68
+ const exit = runPm2(dir, ['logs', APP_NAME, ...argv]);
69
+ process.exit(exit);
70
+ }
71
+
72
+ export async function runUpdate() {
73
+ const dir = resolveDeploymentDir();
74
+ requireDeploymentDir(dir);
75
+
76
+ p.intro(pc.bgCyan(pc.black(' selva update ')));
77
+
78
+ const before = readRuntimeVersion(dir);
79
+ p.log.info(`Current @selvajs/selva: ${before ?? 'unknown'}`);
80
+
81
+ // All @selvajs/* packages move in lockstep so provider-only fixes and CLI
82
+ // fixes get picked up even when the runtime version hasn't moved. The
83
+ // admin-center button runs the same list — keep them in sync if you edit.
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
+ ];
92
+
93
+ const confirmed = await p.confirm({
94
+ message: 'Refresh all @selvajs/* packages and restart the app?',
95
+ initialValue: true
96
+ });
97
+ if (p.isCancel(confirmed) || !confirmed) {
98
+ p.cancel('Cancelled.');
99
+ return;
100
+ }
101
+
102
+ // Stop the running process BEFORE npm rewrites node_modules/@selvajs/selva/build/.
103
+ // SvelteKit's node adapter lazy-imports chunks from build/server/chunks/ on every
104
+ // request; if we let npm replace them while the old process is still serving
105
+ // traffic, in-flight requests hit ERR_MODULE_NOT_FOUND for chunks whose hash
106
+ // just changed. Brief downtime (~1-2s longer than restart-in-place) but no
107
+ // chunk-mismatch errors.
108
+ const stopStatus = runPm2(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
+
113
+ const s = p.spinner();
114
+ s.start(`npm update ${packages.join(' ')}`);
115
+ try {
116
+ // --prefer-online forces npm to revalidate cached packuments against
117
+ // the registry before using them. Without this, npm's 5+ minute
118
+ // packument cache silently re-installs the same version even when a
119
+ // newer one was published in the meantime. See docs/Hotfix-CLI-Runtime.md
120
+ // "The stale-packument-cache trap".
121
+ execSync(`npm update --save --prefer-online ${packages.join(' ')}`, {
122
+ cwd: dir,
123
+ stdio: 'pipe'
124
+ });
125
+ s.stop('npm update finished');
126
+ } catch (err) {
127
+ s.stop('npm update failed');
128
+ // Bring the old process back up so the operator isn't left with downtime.
129
+ runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
130
+ throw err;
131
+ }
132
+
133
+ const after = readRuntimeVersion(dir);
134
+ p.log.info(`New @selvajs/selva: ${after ?? 'unknown'}`);
135
+
136
+ // Surface no-op updates explicitly. --prefer-online closes most cache
137
+ // holes, but a freshly-published version can take a minute or two to
138
+ // propagate through npm's CDN — operators who run update too quickly
139
+ // after publish still see "Current = New". Tell them how to retry.
140
+ if (before && after && before === after) {
141
+ p.log.warn(
142
+ [
143
+ 'No packages were updated — already on the latest version your npm cache knows about.',
144
+ 'If you expected a newer version (e.g. one was just published), your cache may be stale:',
145
+ '',
146
+ ' npm cache clean --force',
147
+ ' rm -rf node_modules package-lock.json',
148
+ ' npm install --prefer-online',
149
+ ' npm run restart'
150
+ ].join('\n')
151
+ );
152
+ }
153
+
154
+ // Start the new build under PM2.
155
+ const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
156
+ if (status === 0) {
157
+ p.outro(pc.green('Started ' + APP_NAME));
158
+ } else {
159
+ p.outro(pc.yellow(`Start failed — investigate with \`pm2 logs ${APP_NAME}\`.`));
160
+ }
161
+ }
162
+
163
+ function readRuntimeVersion(dir) {
164
+ try {
165
+ const pkg = JSON.parse(
166
+ readFileSync(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'), 'utf8')
167
+ );
168
+ return pkg.version;
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }