@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 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`, `selva.config.js`, `ecosystem.config.cjs`, `package.json`. Runs `npm install`.
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.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
- "create": "./bin/create.js",
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/create.js",
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>')
@@ -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
- // selva.config.js copied from runtime's templates (operator can edit)
6
- // ecosystem.config.cjs copied verbatim
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) plus whichever providers the operator picked. Providers are
261
- // imported by selva.config.js npm needs them resolvable from node_modules.
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 also list @selvajs/cli itself as a dep so the `selva` bin gets linked
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, values) {
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.
@@ -1,21 +1,23 @@
1
1
  // `selva doctor` — validate a deployment without starting it.
2
2
  //
3
3
  // Checks:
4
- // • .env exists and has the required keys for the chosen providers
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 + chosen provider packages installed
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 selva.config.ts enforces.
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 logout URL.
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 allowlistPath = resolve(dir, allowlistDir, 'header-allowlist.json');
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 };
@@ -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
- // 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
- ];
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
- return out.join('\n');
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
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
- import { runCreate } from '../src/commands/create.js';
3
-
4
- runCreate(process.argv.slice(2)).catch((err) => {
5
- console.error(err instanceof Error ? err.message : err);
6
- process.exit(1);
7
- });
package/bin/selva.js DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
- import { runSelva } from '../src/cli.js';
3
-
4
- runSelva(process.argv.slice(2)).catch((err) => {
5
- console.error(err instanceof Error ? err.message : err);
6
- process.exit(1);
7
- });