@selvajs/cli 2.0.1 → 2.0.3

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/bin/cli.js CHANGED
@@ -1,7 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ // Bootstrap entry: `npx @selvajs/cli <dir>` scaffolds a fresh deployment.
3
+ // Kept as a thin shim so the real logic stays in src/ and tests can import it
4
+ // without going through process.argv.
5
+
6
+ import pc from 'picocolors';
2
7
  import { runCreate } from '../src/commands/create.js';
3
8
 
4
- runCreate(process.argv.slice(2)).catch((err) => {
5
- console.error(err instanceof Error ? err.message : err);
9
+ try {
10
+ await runCreate(process.argv.slice(2));
11
+ } catch (err) {
12
+ console.error(`${pc.red('✗')} ${err?.message ?? err}`);
6
13
  process.exit(1);
7
- });
14
+ }
package/bin/selva.js CHANGED
@@ -1,7 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ // Operator entry: `selva <command>` dispatches to one of the commands in
3
+ // src/commands/. Linked into the deployment's node_modules/.bin/ via the
4
+ // package.json bin field, so `npm run start` / `npm run doctor` resolve here.
5
+
6
+ import pc from 'picocolors';
2
7
  import { runSelva } from '../src/cli.js';
3
8
 
4
- runSelva(process.argv.slice(2)).catch((err) => {
5
- console.error(err instanceof Error ? err.message : err);
9
+ try {
10
+ await runSelva(process.argv.slice(2));
11
+ } catch (err) {
12
+ console.error(`${pc.red('✗')} ${err?.message ?? err}`);
6
13
  process.exit(1);
7
- });
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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": {
@@ -25,6 +25,6 @@
25
25
  "scripts": {
26
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/migrate.js','./src/commands/keys.js']) { await import(m); }\""
28
+ "test": "node --check ./bin/cli.js && node --check ./bin/selva.js && 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
  }
@@ -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,11 +1,12 @@
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.
@@ -32,7 +33,6 @@ export async function runDoctor() {
32
33
 
33
34
  // ── Files ──────────────────────────────────────────────────────────
34
35
  checks.push(checkFile(join(dir, '.env'), '.env present'));
35
- checks.push(checkFile(join(dir, 'selva.config.js'), 'selva.config.js present'));
36
36
  checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
37
37
 
38
38
  // ── Layout drift ───────────────────────────────────────────────────
@@ -47,7 +47,7 @@ export async function runDoctor() {
47
47
 
48
48
  // ── Provider wiring ────────────────────────────────────────────────
49
49
  // `header` is only valid for the auth slot — data/storage stay
50
- // local|supabase. Mirror what selva.config.ts enforces.
50
+ // local|supabase. Mirror what providers.server.ts enforces.
51
51
  const providers = {
52
52
  auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
53
53
  data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
@@ -90,10 +90,9 @@ export async function runDoctor() {
90
90
  }
91
91
 
92
92
  // ── Installed packages ─────────────────────────────────────────────
93
+ // Provider implementations are bundled into @selvajs/selva — only the
94
+ // runtime package needs to be on disk.
93
95
  checks.push(checkPackage(dir, '@selvajs/selva'));
94
- if (used.has('local')) checks.push(checkPackage(dir, '@selvajs/local-provider'));
95
- if (used.has('supabase')) checks.push(checkPackage(dir, '@selvajs/supabase-provider'));
96
- if (used.has('header')) checks.push(checkPackage(dir, '@selvajs/header-auth-provider'));
97
96
 
98
97
  // ── Origin (best-effort) ───────────────────────────────────────────
99
98
  if (env.ORIGIN) {
@@ -208,18 +207,29 @@ function checkLayoutDrift(dir) {
208
207
  } catch {
209
208
  return red('package.json is not valid JSON');
210
209
  }
211
- const reasons = detectDrift(pkg);
212
- if (reasons.length === 0) return green('package.json layout is current');
210
+ const reasons = detectDrift(pkg, dir);
211
+ if (reasons.length === 0) return green('deployment layout is current');
213
212
  return red(
214
- `package.json layout is outdated — run \`selva migrate\`:\n ` +
213
+ `deployment layout is outdated — run \`selva migrate\`:\n ` +
215
214
  reasons.map((r) => '· ' + r).join('\n ')
216
215
  );
217
216
  }
218
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
+
219
228
  // Header-auth-specific sanity checks. None of these catch the truly dangerous
220
229
  // misconfigurations (header spoofing, missing proxy auth) — those are
221
230
  // runtime invariants we can't verify from here. We DO check the things we can:
222
- // 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.
223
233
  function checkHeaderAuth(dir, env, dataProvider) {
224
234
  const out = [];
225
235
 
@@ -228,7 +238,8 @@ function checkHeaderAuth(dir, env, dataProvider) {
228
238
  if (!allowlistDir) {
229
239
  out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
230
240
  } else {
231
- const allowlistPath = resolve(dir, allowlistDir, 'header-allowlist.json');
241
+ const allowlistAbsDir = resolve(dir, allowlistDir);
242
+ const allowlistPath = join(allowlistAbsDir, 'header-allowlist.json');
232
243
  if (existsSync(allowlistPath)) {
233
244
  out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
234
245
  } else {
@@ -240,6 +251,14 @@ function checkHeaderAuth(dir, env, dataProvider) {
240
251
  `header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
241
252
  )
242
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
+ }
243
262
  }
244
263
  }
245
264
 
@@ -288,5 +307,56 @@ function checkHeaderAuth(dir, env, dataProvider) {
288
307
  out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
289
308
  }
290
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
+
291
342
  return out;
292
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
+ }
@@ -1,13 +1,19 @@
1
- // `selva migrate` — bring an existing deployment's package.json onto the
2
- // current layout.
1
+ // `selva migrate` — bring an existing deployment onto the current layout.
3
2
  //
4
- // Two historical migrations exist for Selva deployments:
3
+ // Three historical migrations exist for Selva deployments:
5
4
  // 1. `@selvajs/create` → `@selvajs/cli` (CLI bootstrap; can't be automated
6
5
  // since the operator has no `selva` binary yet — they run two npm
7
6
  // commands by hand from the Hotfix doc).
8
7
  // 2. `@selvajs/runtime` → `@selvajs/selva` (runtime bundling: UI, schemas,
9
8
  // ui-kit and the providers' workspace deps are now built into
10
- // @selvajs/selva). This is what `selva migrate` automates.
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.
11
17
  //
12
18
  // Future package-layout shifts go here too — the command should remain
13
19
  // idempotent. On an already-current deployment it prints "nothing to
@@ -17,12 +23,17 @@
17
23
  // pm2 again with --update-env. Rollback restores package.json.bak on
18
24
  // npm-install failure so the operator isn't left with a broken deployment.
19
25
 
20
- import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
26
+ import {
27
+ existsSync,
28
+ readFileSync,
29
+ writeFileSync,
30
+ copyFileSync,
31
+ rmSync
32
+ } from 'node:fs';
21
33
  import { join } from 'node:path';
22
34
  import { spawnSync, execSync } from 'node:child_process';
23
35
  import * as p from '@clack/prompts';
24
36
  import pc from 'picocolors';
25
- import { readEnvFile } from '../env.js';
26
37
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
27
38
 
28
39
  const APP_NAME = 'selva-compute';
@@ -35,10 +46,10 @@ const SELVA_DEPS = new Set([
35
46
  '@selvajs/cli',
36
47
  '@selvajs/selva',
37
48
  '@selvajs/runtime', // legacy — removed during migrate
38
- '@selvajs/platform',
39
- '@selvajs/local-provider',
40
- '@selvajs/supabase-provider',
41
- '@selvajs/header-auth-provider',
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
42
53
  '@selvajs/create' // legacy CLI — removed during migrate
43
54
  ]);
44
55
 
@@ -64,18 +75,30 @@ export async function runMigrate() {
64
75
  }
65
76
 
66
77
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
67
- const env = readEnvFile(join(dir, '.env'));
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');
68
89
 
69
- const target = buildTargetPackageJson(before, env);
70
- const diff = diffPackageJson(before, target);
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)')}`);
71
93
 
72
- if (diff.length === 0) {
94
+ if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
73
95
  p.outro(pc.green('Already on the current layout — nothing to migrate.'));
74
96
  return;
75
97
  }
76
98
 
77
99
  p.log.info('Changes to apply:');
78
- for (const line of diff) console.log(' ' + line);
100
+ for (const line of pkgDiff) console.log(' ' + line);
101
+ for (const line of sideFileChanges) console.log(' ' + line);
79
102
 
80
103
  const confirmed = await p.confirm({
81
104
  message: 'Apply these changes, reinstall, and restart?',
@@ -94,12 +117,29 @@ export async function runMigrate() {
94
117
  p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
95
118
  }
96
119
 
97
- // Write package.json.bak before touching anything. Restored if npm install
98
- // fails so the operator can re-run with their previous state intact.
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.
99
122
  const bakPath = pkgPath + '.bak';
100
123
  copyFileSync(pkgPath, bakPath);
101
124
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
102
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
+
103
143
  // Nuke node_modules + lockfile. A simple `npm install` won't always
104
144
  // resolve correctly when major version ranges change (e.g. runtime 0.10
105
145
  // → selva 2.0) — the lockfile pins old transitive deps that no longer
@@ -120,6 +160,12 @@ export async function runMigrate() {
120
160
  } catch (err) {
121
161
  s.stop(pc.red('npm install failed — rolling back package.json'));
122
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
+ }
123
169
  // Try to bring the old process back up so we don't leave the operator
124
170
  // with downtime. If node_modules was wiped this won't help, but at
125
171
  // least package.json matches what's on disk.
@@ -129,11 +175,15 @@ export async function runMigrate() {
129
175
  }
130
176
 
131
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
+
132
182
  if (status === 0) {
133
183
  p.outro(
134
184
  [
135
185
  pc.green('Migration complete.'),
136
- pc.dim('Old package.json saved as ') + pc.cyan('package.json.bak'),
186
+ pc.dim('Backups saved as ') + pc.cyan(backupHints.join(', ')),
137
187
  pc.dim('Run ') + pc.cyan('selva doctor') + pc.dim(' to verify.')
138
188
  ].join('\n')
139
189
  );
@@ -142,30 +192,22 @@ export async function runMigrate() {
142
192
  }
143
193
  }
144
194
 
145
- // Compute what package.json should look like given the current contents and
146
- // the operator's .env (which tells us which providers are in use).
195
+ // Compute what package.json should look like given the current contents.
147
196
  //
148
197
  // Wholesale-replace semantics: any @selvajs/* or pm2 entry not in our
149
198
  // canonical set is dropped. Non-selva deps the operator added are also
150
199
  // dropped — that's the design choice we made up-front.
151
- function buildTargetPackageJson(current, env) {
152
- const providers = new Set(
153
- [
154
- env.SELVA_AUTH_PROVIDER,
155
- env.SELVA_DATA_PROVIDER,
156
- env.SELVA_STORAGE_PROVIDER
157
- ].filter(Boolean).map((v) => v.toLowerCase())
158
- );
159
-
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) {
160
206
  const deps = {
161
207
  '@selvajs/cli': 'latest',
162
208
  '@selvajs/selva': 'latest',
163
- '@selvajs/platform': 'latest',
164
209
  pm2: '^5.4.0'
165
210
  };
166
- if (providers.has('local')) deps['@selvajs/local-provider'] = 'latest';
167
- if (providers.has('supabase')) deps['@selvajs/supabase-provider'] = 'latest';
168
- if (providers.has('header')) deps['@selvajs/header-auth-provider'] = 'latest';
169
211
 
170
212
  return {
171
213
  name: current.name ?? 'selva-deployment',
@@ -227,14 +269,33 @@ function runPm2(dir, args, { inherit = true } = {}) {
227
269
 
228
270
  // Exported for use by `selva doctor` so it can warn about layout drift
229
271
  // without duplicating the detection logic.
230
- export function detectDrift(pkgJson) {
272
+ export function detectDrift(pkgJson, dir) {
231
273
  const deps = pkgJson?.dependencies ?? {};
232
274
  const reasons = [];
233
275
  if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
234
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');
235
284
  if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
236
285
  if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
237
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
+
238
299
  return reasons;
239
300
  }
240
301
 
@@ -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) {