@selvajs/cli 4.8.0-beta.11 → 4.8.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.8.0-beta.11",
3
+ "version": "4.8.0-beta.12",
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
  "author": "VektorNode",
@@ -65,6 +65,106 @@ export function checkOrigin(env) {
65
65
  return green(`ORIGIN=${env.ORIGIN}`);
66
66
  }
67
67
 
68
+ // Duplicated from HeaderAuthProvider.DEFAULT_HEADERS to avoid loading the runtime here.
69
+ // A test in providers/header-auth pins both sides, so drift surfaces in CI.
70
+ export const DEFAULT_HEADER_NAMES = {
71
+ upn: 'SELVA-UserPrincipalName',
72
+ email: 'SELVA-Email',
73
+ displayName: 'SELVA-DisplayName'
74
+ };
75
+
76
+ // oauth2-proxy's `set_xauthrequest` names. Two proxy styles are documented and
77
+ // both are correct, but they need opposite env config:
78
+ //
79
+ // rewrite — Caddy maps X-Auth-Request-* onto SELVA-* before proxying.
80
+ // Selva sees SELVA-*, so the overrides must be UNSET.
81
+ // copy-through — Caddy forwards X-Auth-Request-* unchanged.
82
+ // Selva sees those names, so all three must be overridden.
83
+ //
84
+ // Mixing them is the failure this check exists to catch: a name Selva reads but
85
+ // the proxy never sends yields `null` from identifyFromHeaders (for the UPN, a
86
+ // total login failure) with nothing in the logs pointing at the env var.
87
+ const OAUTH2_PROXY_HEADERS = new Set([
88
+ 'x-auth-request-user',
89
+ 'x-auth-request-email',
90
+ 'x-auth-request-preferred-username'
91
+ ]);
92
+
93
+ /**
94
+ * Reports the header names the provider will actually read, and flags the
95
+ * combinations that can't match any documented proxy config.
96
+ *
97
+ * Deliberately not a "did you override all three" count: with the rewrite
98
+ * style, zero overrides is the correct answer, so counting alone would push
99
+ * operators toward breaking a working deployment.
100
+ */
101
+ export function checkHeaderNames(env) {
102
+ const resolved = {
103
+ upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
104
+ email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
105
+ displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
106
+ };
107
+ const list = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
108
+
109
+ const oauth2 = Object.values(resolved).filter((h) =>
110
+ OAUTH2_PROXY_HEADERS.has(h.toLowerCase())
111
+ ).length;
112
+ const defaults = Object.entries(resolved).filter(
113
+ ([slot, name]) => name === DEFAULT_HEADER_NAMES[slot]
114
+ ).length;
115
+
116
+ if (oauth2 === 0) {
117
+ return defaults === 3
118
+ ? green(`header names (bundled defaults): ${list}`)
119
+ : green(`header names (custom): ${list}`);
120
+ }
121
+
122
+ // Some X-Auth-Request-*, some SELVA-* defaults — neither proxy style emits
123
+ // that mix, so whichever slots are still on a default are being read from a
124
+ // header nothing sets.
125
+ if (defaults > 0) {
126
+ const stale = Object.entries(resolved)
127
+ .filter(([slot, name]) => name === DEFAULT_HEADER_NAMES[slot])
128
+ .map(([slot, name]) => `${slot}=${name}`)
129
+ .join(', ');
130
+ return yellow(
131
+ `header names mix oauth2-proxy and bundled-default names: ${list} — ` +
132
+ `${stale} will never arrive if your proxy forwards X-Auth-Request-* unchanged. ` +
133
+ `Either override all three with the X-Auth-Request-* names, or map them to SELVA-* ` +
134
+ `in the proxy and unset all three overrides.`
135
+ );
136
+ }
137
+
138
+ return green(`header names (oauth2-proxy pass-through): ${list}`);
139
+ }
140
+
141
+ // Below this, an annotated .env is just a slightly chatty config file; above it
142
+ // the operator is scrolling past screens of prose that no command can correct.
143
+ const COMMENT_LINE_BUDGET = 40;
144
+
145
+ /**
146
+ * Flags a `.env` still carrying the shipped documentation block.
147
+ *
148
+ * The block is a snapshot of the release the deployment was installed at.
149
+ * `migrate` rewrites keys but never prose, so those comments keep describing
150
+ * vars the code has since renamed or retired — confidently wrong instructions
151
+ * sitting in the file an operator reaches for first. Nothing breaks, which is
152
+ * why this is yellow, and why it needs saying at all.
153
+ */
154
+ export function checkEnvDocumentation(commentLines, fix) {
155
+ if (commentLines <= COMMENT_LINE_BUDGET) {
156
+ return green('.env is values-only');
157
+ }
158
+ return yellow(
159
+ `.env carries ~${commentLines} lines of shipped documentation. Comments are ` +
160
+ `never updated by \`selva migrate\`, so they still describe the release this ` +
161
+ `deployment was installed at — including variables the code no longer reads. ` +
162
+ `Strip them (values and your own inline notes are kept, .env.bak written):\n ` +
163
+ `selva doctor --fix`,
164
+ fix
165
+ );
166
+ }
167
+
68
168
  // A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
69
169
  // encodes a value in the new name, so migrate leaves it alone rather than
70
170
  // guessing — it's reported here and nowhere else.
@@ -112,7 +112,9 @@ function checkNodeProvenance(env) {
112
112
  ?.trim();
113
113
 
114
114
  if (!onPath) {
115
- return yellow(`Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`);
115
+ return yellow(
116
+ `Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`
117
+ );
116
118
  }
117
119
 
118
120
  if (env.realpath(onPath) === running) {
@@ -15,7 +15,11 @@ import {
15
15
  runtimeTemplatePath,
16
16
  scaffoldVersion
17
17
  } from '../paths.js';
18
- import { buildDeploymentPackageJson } from '../deployment-package.js';
18
+ import {
19
+ buildDeploymentPackageJson,
20
+ npmDistTagVersion,
21
+ resolveSelvaPins
22
+ } from '../deployment-package.js';
19
23
  import { satisfiesNodeRange } from '../node-range.js';
20
24
 
21
25
  export async function runCreate(argv) {
@@ -57,9 +61,19 @@ export async function runCreate(argv) {
57
61
 
58
62
  const deployName = basename(targetDir);
59
63
 
64
+ // Resolve the dist-tags to concrete versions before writing. A stored
65
+ // `"latest"` re-resolves on every later `npm install`, so the deployment
66
+ // would follow the tag instead of the version it was scaffolded against —
67
+ // and `selva doctor` reports a floating pin as drift.
68
+ const { pins } = resolveSelvaPins({}, npmDistTagVersion);
69
+
60
70
  // package.json first: npm install needs it, and the runtime templates below
61
71
  // only exist once @selvajs/selva is installed into node_modules.
62
- const pkgJson = JSON.stringify(buildDeploymentPackageJson({ name: deployName }), null, 2);
72
+ const pkgJson = JSON.stringify(
73
+ buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
74
+ null,
75
+ 2
76
+ );
63
77
  writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
64
78
 
65
79
  if (!skipInstall) {
@@ -8,6 +8,8 @@
8
8
  import {
9
9
  existsSync,
10
10
  readFileSync,
11
+ writeFileSync,
12
+ copyFileSync,
11
13
  readdirSync,
12
14
  accessSync,
13
15
  constants,
@@ -18,7 +20,7 @@ import { join, resolve, dirname } from 'node:path';
18
20
  import { fileURLToPath } from 'node:url';
19
21
  import * as p from '@clack/prompts';
20
22
  import pc from 'picocolors';
21
- import { readEnvFile } from '../env.js';
23
+ import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
22
24
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
23
25
  import { satisfiesNodeRange } from '../node-range.js';
24
26
  import { green, yellow, red, fixable } from '../checks/result.js';
@@ -26,6 +28,8 @@ import { checkBootPersistence } from '../checks/boot.js';
26
28
  import { checkRuntimeEnvironment } from '../checks/runtime.js';
27
29
  import {
28
30
  checkDeprecatedEnv,
31
+ checkEnvDocumentation,
32
+ checkHeaderNames,
29
33
  checkOrigin,
30
34
  checkProviders,
31
35
  checkSecret,
@@ -78,6 +82,7 @@ export async function runDoctor(argv = []) {
78
82
  checks.push(...checkBootPersistence(dir));
79
83
  checks.push(checkOrigin(env));
80
84
  checks.push(...checkDeprecatedEnv(env));
85
+ checks.push(checkEnvDocs(dir));
81
86
 
82
87
  // ── Render ─────────────────────────────────────────────────────────
83
88
  const resolved = await Promise.all(checks);
@@ -372,6 +377,58 @@ function checkMigrationLeftovers(dir) {
372
377
  );
373
378
  }
374
379
 
380
+ // Reads the raw file rather than the parsed env: the finding IS the comments,
381
+ // which parseEnv discards. The repair is attached here (not in checks/config.js)
382
+ // to keep that module filesystem-free.
383
+ function checkEnvDocs(dir) {
384
+ const envPath = join(dir, '.env');
385
+ if (!existsSync(envPath)) return green('.env documentation check skipped (no .env)');
386
+
387
+ let text;
388
+ try {
389
+ text = readFileSync(envPath, 'utf8');
390
+ } catch (err) {
391
+ return yellow(`.env unreadable (${err instanceof Error ? err.message : err})`);
392
+ }
393
+
394
+ return checkEnvDocumentation(
395
+ countEnvCommentLines(text),
396
+ fixable('strip the shipped documentation from .env (backup: .env.bak)', () => {
397
+ try {
398
+ // Re-read at repair time: doctor may have run minutes ago in --fix's
399
+ // confirmation loop, and rewriting from a stale snapshot would silently
400
+ // revert an edit made in between.
401
+ const current = readFileSync(envPath, 'utf8');
402
+ const { text: strippedText, removed } = stripEnvComments(current);
403
+ // A rewrite that changes which settings are live is a bug, not a
404
+ // cleanup — refuse rather than hand back a subtly different deployment.
405
+ const before = parseEnv(current);
406
+ const after = parseEnv(strippedText);
407
+ const drift = diffEnvKeys(before, after);
408
+ if (drift) return red(`refused to strip .env — ${drift}`);
409
+
410
+ copyFileSync(envPath, envPath + '.bak');
411
+ writeFileSync(envPath, strippedText, 'utf8');
412
+ return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
413
+ } catch (err) {
414
+ return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
415
+ }
416
+ })
417
+ );
418
+ }
419
+
420
+ // Names the first discrepancy between two parsed envs, or null when identical.
421
+ function diffEnvKeys(before, after) {
422
+ for (const key of Object.keys(before)) {
423
+ if (!(key in after)) return `${key} would be lost`;
424
+ if (before[key] !== after[key]) return `${key} would change value`;
425
+ }
426
+ for (const key of Object.keys(after)) {
427
+ if (!(key in before)) return `${key} would be added`;
428
+ }
429
+ return null;
430
+ }
431
+
375
432
  function checkLayoutDrift(dir) {
376
433
  const pkgPath = join(dir, 'package.json');
377
434
  if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
@@ -389,14 +446,6 @@ function checkLayoutDrift(dir) {
389
446
  );
390
447
  }
391
448
 
392
- // Duplicated from HeaderAuthProvider.DEFAULT_HEADERS to avoid loading the runtime here.
393
- // A test in providers/header-auth pins both sides, so drift surfaces in CI.
394
- const DEFAULT_HEADER_NAMES = {
395
- upn: 'SELVA-UserPrincipalName',
396
- email: 'SELVA-Email',
397
- displayName: 'SELVA-DisplayName'
398
- };
399
-
400
449
  // Static config only — runtime invariants like spoofing protection can't be checked from here.
401
450
  function checkHeaderAuth(dir, env, dataProvider) {
402
451
  const out = [];
@@ -466,29 +515,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
466
515
  out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
467
516
  }
468
517
 
469
- const resolved = {
470
- upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
471
- email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
472
- displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
473
- };
474
- const overrides = [
475
- Boolean(env.HEADER_AUTH_UPN_HEADER),
476
- Boolean(env.HEADER_AUTH_EMAIL_HEADER),
477
- Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
478
- ].filter(Boolean).length;
479
- const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
480
- if (overrides === 0) {
481
- out.push(green(`header names (bundled defaults): ${headerList}`));
482
- } else if (overrides === 3) {
483
- out.push(green(`header names (all overridden): ${headerList}`));
484
- } else {
485
- out.push(
486
- yellow(
487
- `header names partially overridden (${overrides}/3 set): ${headerList} — ` +
488
- `a partial override is usually a typo. Set all three or none.`
489
- )
490
- );
491
- }
518
+ out.push(checkHeaderNames(env));
492
519
 
493
520
  return out;
494
521
  }
@@ -42,7 +42,11 @@ export async function runInit() {
42
42
  p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
43
43
  }
44
44
 
45
- writeEnvFile(envPath, readEnvTemplate(dir), values);
45
+ // A values-only rewrite drops anything it isn't handed, and the prompts cover
46
+ // only the vars they ask about — every tuned knob the operator set by hand
47
+ // (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
48
+ // the existing file forward and let the prompt answers win on top.
49
+ writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
46
50
 
47
51
  p.outro(
48
52
  [
@@ -13,8 +13,12 @@ import {
13
13
  buildDeploymentPackageJson,
14
14
  CANONICAL_FIELDS,
15
15
  DEPENDENCIES,
16
+ isFloatingPin,
16
17
  LEGACY_DEPENDENCIES,
17
- OVERRIDES
18
+ npmDistTagVersion,
19
+ OVERRIDES,
20
+ resolveSelvaPins,
21
+ SELVA_PACKAGES
18
22
  } from '../deployment-package.js';
19
23
  import { APP_NAME, runPm2 } from './pm2.js';
20
24
 
@@ -34,7 +38,8 @@ export async function runMigrate(
34
38
  // predates the release being migrated to.
35
39
  install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
36
40
  confirm = (message) => p.confirm({ message, initialValue: true }),
37
- pm2 = runPm2
41
+ pm2 = runPm2,
42
+ resolveVersion = npmDistTagVersion
38
43
  } = {}
39
44
  ) {
40
45
  const dir = injectedDir ?? resolveDeploymentDir();
@@ -49,7 +54,7 @@ export async function runMigrate(
49
54
  }
50
55
 
51
56
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
52
- const target = buildTargetPackageJson(before);
57
+ const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
53
58
  const pkgDiff = diffPackageJson(before, target);
54
59
 
55
60
  const configPath = join(dir, 'selva.config.js');
@@ -94,6 +99,23 @@ export async function runMigrate(
94
99
  for (const line of pkgDiff) console.log(' ' + line);
95
100
  for (const line of sideFileChanges) console.log(' ' + line);
96
101
 
102
+ // The diff shows the pin that will be written; these explain why it is that
103
+ // pin. A preserved beta and a pin that couldn't be resolved both look like
104
+ // "no change" in the diff, and the second one is a stale pin, not a stable one.
105
+ for (const note of pinNotes) {
106
+ if (note.kind === 'prerelease') {
107
+ p.log.info(
108
+ `${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
109
+ `means newest stable and would move this deployment backwards.`
110
+ );
111
+ } else {
112
+ p.log.warn(
113
+ `Could not resolve the current version of ${note.name} (${note.reason}). ` +
114
+ `Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
115
+ );
116
+ }
117
+ }
118
+
97
119
  const confirmed = await confirm('Apply these changes, reinstall, and restart?');
98
120
  if (p.isCancel(confirmed) || !confirmed) {
99
121
  p.cancel('Cancelled.');
@@ -242,12 +264,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
242
264
  }
243
265
  }
244
266
 
245
- function buildTargetPackageJson(current) {
246
- return buildDeploymentPackageJson({
267
+ function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
268
+ const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
269
+ const pkg = buildDeploymentPackageJson({
247
270
  name: current.name ?? 'selva-deployment',
248
271
  version: current.version ?? '0.1.0',
249
- engines: current.engines
272
+ engines: current.engines,
273
+ dependencies: pins
250
274
  });
275
+ return { pkg, notes };
251
276
  }
252
277
 
253
278
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -331,6 +356,20 @@ export function detectDrift(pkgJson, dir) {
331
356
  reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
332
357
  }
333
358
 
359
+ // A stored dist-tag re-resolves on every `npm install`, so the deployment
360
+ // follows the tag instead of a version an operator chose. Worse on a
361
+ // prerelease line, where `latest` points at the older stable release and the
362
+ // "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
363
+ // tags, so existing deployments carry one and nothing else reports it.
364
+ for (const name of SELVA_PACKAGES) {
365
+ if (deps[name] && isFloatingPin(deps[name])) {
366
+ reasons.push(
367
+ `${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
368
+ `(re-resolves on every install; migrate writes a concrete version)`
369
+ );
370
+ }
371
+ }
372
+
334
373
  // Overrides are security shims for vulnerable transitive deps — a
335
374
  // deployment without them installs the vulnerable version on its next
336
375
  // `npm install`, silently.
@@ -370,8 +409,14 @@ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
370
409
  `still the old version. Finish the upgrade (brief restart of managed processes):`,
371
410
  ``,
372
411
  ` npx pm2 update`,
412
+ ` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
373
413
  ` npx pm2 save`,
374
414
  ``,
415
+ `Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
416
+ `process table before restoring it, so saving a failed restore overwrites`,
417
+ `~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
418
+ `recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
419
+ ``,
375
420
  `If doctor reports a pm2 outside this deployment (global or apt install), follow`,
376
421
  `the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
377
422
  ].join('\n');
@@ -4,6 +4,7 @@
4
4
  // exactly the field where a silent difference costs the most.
5
5
 
6
6
  import { readFileSync } from 'node:fs';
7
+ import { execSync } from 'node:child_process';
7
8
  import { join, dirname } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
 
@@ -51,6 +52,11 @@ const SCRIPTS = {
51
52
  * Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
52
53
  * the CLI is a dependency so `npm run start` resolves the `selva` bin locally
53
54
  * instead of relying on a global install.
55
+ *
56
+ * The `latest` entries are what `create` resolves at scaffold time, never what
57
+ * gets written: `resolveSelvaPins` turns them into concrete versions. A stored
58
+ * `"latest"` re-resolves on every `npm install`, so the deployment follows the
59
+ * dist-tag forever and can change under the operator with no migrate at all.
54
60
  */
55
61
  const DEPENDENCIES = {
56
62
  '@selvajs/cli': 'latest',
@@ -58,6 +64,34 @@ const DEPENDENCIES = {
58
64
  pm2: PM2_VERSION
59
65
  };
60
66
 
67
+ /** The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold. */
68
+ export const SELVA_PACKAGES = ['@selvajs/cli', '@selvajs/selva'];
69
+
70
+ /**
71
+ * True for a pin that names no version — a dist-tag (`latest`, `beta`) or the
72
+ * empty-ish values npm also accepts (`*`, `""`). These are the pins that move
73
+ * on their own, which is what makes them unfit to store.
74
+ */
75
+ export function isFloatingPin(pin) {
76
+ if (typeof pin !== 'string') return true;
77
+ const trimmed = pin.trim();
78
+ if (trimmed === '' || trimmed === '*' || trimmed === 'x') return true;
79
+ // A range still names versions; only a bare tag has no digits at all.
80
+ return !/\d/.test(trimmed);
81
+ }
82
+
83
+ /**
84
+ * True for a pin on a prerelease line (`^4.8.0-beta.11`).
85
+ *
86
+ * These are preserved across a migration. An operator on a beta channel chose
87
+ * it, and npm's `latest` tag points at the newest *stable* release — so
88
+ * resolving their pin through `latest` is a downgrade that reads as an upgrade
89
+ * in the confirmation diff.
90
+ */
91
+ export function isPrereleasePin(pin) {
92
+ return typeof pin === 'string' && /\d+\.\d+\.\d+-/.test(pin);
93
+ }
94
+
61
95
  /**
62
96
  * npm `overrides` forced onto every deployment — security patches for
63
97
  * transitive deps whose direct parent hasn't picked up the fix yet. Each entry
@@ -100,21 +134,98 @@ export const LEGACY_DEPENDENCIES = {
100
134
  * deliberately, and dropping it removes a guard whose absence shows up only
101
135
  * under real traffic (issue #176). Everything else a migration discards is
102
136
  * listed by `diffPackageJson` before the operator confirms.
137
+ *
138
+ * `dependencies` is the second exception, for the same reason: a version pin is
139
+ * an operator choice too. `migrate` passes the resolved `@selvajs/*` pins in
140
+ * here; a fresh scaffold passes none and inherits the defaults.
103
141
  */
104
- export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
142
+ export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
105
143
  const pkg = {
106
144
  name: sanitizePackageName(name),
107
145
  version,
108
146
  private: true,
109
147
  type: 'module',
110
148
  scripts: { ...SCRIPTS },
111
- dependencies: { ...DEPENDENCIES },
149
+ dependencies: { ...DEPENDENCIES, ...dependencies },
112
150
  overrides: { ...OVERRIDES }
113
151
  };
114
152
  if (engines && typeof engines === 'object') pkg.engines = { ...engines };
115
153
  return pkg;
116
154
  }
117
155
 
156
+ /**
157
+ * Ask npm what a dist-tag currently points at. Kept beside the pin logic it
158
+ * serves, and injected rather than called directly so tests never hit the
159
+ * network.
160
+ */
161
+ export function npmDistTagVersion(name, tag) {
162
+ const out = execSync(`npm view ${name}@${tag} version`, {
163
+ encoding: 'utf8',
164
+ stdio: ['ignore', 'pipe', 'pipe']
165
+ });
166
+ const version = String(out).trim().split('\n').pop()?.trim();
167
+ if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
168
+ throw new Error(`npm view returned no usable version for ${name}@${tag}`);
169
+ }
170
+ return version;
171
+ }
172
+
173
+ /**
174
+ * Decide the `@selvajs/*` pins a migration should write.
175
+ *
176
+ * Two rules, both aimed at the same failure: a migration silently moving a
177
+ * deployment to a version the operator didn't ask for.
178
+ *
179
+ * - A prerelease pin is kept as-is. `latest` means newest stable, so
180
+ * resolving a beta pin through it walks the deployment backwards.
181
+ * - Anything else resolves to a concrete version via `resolveVersion`, so
182
+ * `"latest"` never reaches disk and the diff can show what it resolved to.
183
+ *
184
+ * `resolveVersion` hits the network. When it fails — offline, registry down, a
185
+ * package with no such tag — the existing pin is kept and the reason returned,
186
+ * so migrate can report it rather than inventing a version or aborting a
187
+ * migration whose other half (layout, overrides, env renames) is still valid.
188
+ */
189
+ export function resolveSelvaPins(currentDeps, resolveVersion) {
190
+ const pins = {};
191
+ const notes = [];
192
+
193
+ for (const name of SELVA_PACKAGES) {
194
+ const current = currentDeps?.[name];
195
+
196
+ if (isPrereleasePin(current)) {
197
+ pins[name] = current;
198
+ notes.push({ name, kind: 'prerelease', pin: current });
199
+ continue;
200
+ }
201
+
202
+ let resolved = null;
203
+ try {
204
+ resolved = resolveVersion(name, 'latest');
205
+ } catch (err) {
206
+ notes.push({ name, kind: 'unresolved', pin: current, reason: err?.message ?? String(err) });
207
+ }
208
+
209
+ if (resolved) {
210
+ pins[name] = `^${resolved}`;
211
+ continue;
212
+ }
213
+
214
+ // Nothing resolvable and nothing worth keeping — fall back to the tag so
215
+ // the install still succeeds. Rare: offline *and* a floating pin.
216
+ if (isFloatingPin(current)) {
217
+ pins[name] = DEPENDENCIES[name];
218
+ if (!notes.some((n) => n.name === name)) {
219
+ notes.push({ name, kind: 'unresolved', pin: current, reason: 'no version returned' });
220
+ }
221
+ } else {
222
+ pins[name] = current;
223
+ }
224
+ }
225
+
226
+ return { pins, notes };
227
+ }
228
+
118
229
  /**
119
230
  * Top-level keys the canonical package.json defines. Anything outside this set
120
231
  * that an operator added is dropped by `migrate` — `diffPackageJson` reports
package/src/env.js CHANGED
@@ -19,6 +19,142 @@ export const REPLACED_ENV_VARS = {
19
19
  SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
20
20
  };
21
21
 
22
+ // Header written above the settings in a stripped .env. The pointer matters:
23
+ // without it the operator has no way back to what a var means, and the reflex
24
+ // is to paste the old annotated template back in.
25
+ export const ENV_HEADER = [
26
+ '# Selva deployment configuration.',
27
+ '#',
28
+ '# Values only — every variable is documented in the runtime template at',
29
+ '# node_modules/@selvajs/selva/templates/.env.example, which is refreshed on',
30
+ '# every update. Do not paste that template in here: a copy stops tracking',
31
+ '# the release and starts describing variables the code no longer reads.'
32
+ ].join('\n');
33
+
34
+ /**
35
+ * Strip a deployment `.env` down to its live settings, dropping the shipped
36
+ * documentation block.
37
+ *
38
+ * A comment block copied onto a server is a snapshot with no update path —
39
+ * `migrate` rewrites keys but never prose, so a deployment keeps documenting
40
+ * the release it was installed at, describing vars the code has since renamed
41
+ * or retired. Deleting it is what makes the file navigable AND unable to lie.
42
+ *
43
+ * Operator comments are NOT prose to be discarded: a short `#` run directly
44
+ * above a setting (no blank line between) is that setting's note and rides
45
+ * along with it. Everything else goes.
46
+ *
47
+ * "Short" is doing real work here. The shipped template also writes prose
48
+ * immediately above a setting — its banner blocks run 40+ lines and end with a
49
+ * bare `#` — so proximity alone would keep the very documentation this exists
50
+ * to remove. A hand-written note is a line or two; a block longer than
51
+ * ATTACHED_NOTE_MAX_LINES, or one containing a `# ====` banner rule, is the
52
+ * template's and is dropped.
53
+ *
54
+ * Returns `{ text, removed }`, `removed` being the count of dropped comment
55
+ * lines. `removed === 0` means the file was already values-only.
56
+ */
57
+ export function stripEnvComments(text, { header = ENV_HEADER } = {}) {
58
+ // Our own header is re-emitted below, so counting it as prose we removed
59
+ // would make every run report work it didn't do — and break idempotency,
60
+ // which is what lets doctor call a stripped file clean. Matched as a whole
61
+ // leading block, not line-by-line: the header contains a bare `#`, and
62
+ // excluding that by value would silently stop counting an operator's own
63
+ // `#` separators anywhere in the file.
64
+ let lines = text.split(/\r?\n/);
65
+ if (header) {
66
+ const headerLines = header.split('\n');
67
+ const leads = headerLines.every((h, i) => lines[i]?.trim() === h.trim());
68
+ if (leads) lines = lines.slice(headerLines.length);
69
+ }
70
+ const out = [];
71
+ let pending = [];
72
+ let removed = 0;
73
+ let sawSetting = false;
74
+ // Whether a blank line or dropped prose separated the last setting from the
75
+ // next one — the original file's own grouping, worth carrying over.
76
+ let groupBreak = false;
77
+
78
+ const flushDropped = () => {
79
+ const dropped = pending.filter((l) => l.trim().startsWith('#'));
80
+ removed += dropped.length;
81
+ // A discarded banner marked a section boundary in the original; keep that
82
+ // boundary as a blank line so the result still reads as grouped settings.
83
+ if (dropped.length > 0 && sawSetting) groupBreak = true;
84
+ pending = [];
85
+ };
86
+
87
+ for (const line of lines) {
88
+ const stripped = line.trim();
89
+
90
+ if (stripped.startsWith('#')) {
91
+ pending.push(line);
92
+ continue;
93
+ }
94
+
95
+ // A blank line ends whatever comment block was accumulating: those
96
+ // comments float free rather than annotating the next setting.
97
+ if (!stripped) {
98
+ if (sawSetting) groupBreak = true;
99
+ flushDropped();
100
+ continue;
101
+ }
102
+
103
+ if (stripped.indexOf('=') === -1) {
104
+ flushDropped();
105
+ continue;
106
+ }
107
+
108
+ // Comments still pending sit directly above this setting. Keep them only
109
+ // if they read as a hand-written note rather than a template banner.
110
+ const note = isAttachedNote(pending);
111
+ // Blank line only where it earns one: before a kept note, or where the
112
+ // original had a break. Separating every key would just trade a wall of
113
+ // prose for a file twice as long as it needs to be.
114
+ if (sawSetting && (note || groupBreak)) out.push('');
115
+ if (note) {
116
+ out.push(...pending);
117
+ } else {
118
+ flushDropped();
119
+ }
120
+ pending = [];
121
+ groupBreak = false;
122
+ out.push(line);
123
+ sawSetting = true;
124
+ }
125
+
126
+ // Trailing comments annotate nothing.
127
+ flushDropped();
128
+
129
+ const body = out.join('\n');
130
+ return {
131
+ text: header ? `${header}\n\n${body}\n` : `${body}\n`,
132
+ removed
133
+ };
134
+ }
135
+
136
+ // A hand-written note is a line or two. Longer than this and it is the shipped
137
+ // template's prose, which is what stripping exists to remove.
138
+ const ATTACHED_NOTE_MAX_LINES = 3;
139
+
140
+ // A `# ====` rule is the template's section-banner style; nothing hand-written
141
+ // above a single setting uses it.
142
+ const BANNER_RULE = /^#\s*[=-]{4,}/;
143
+
144
+ function isAttachedNote(pending) {
145
+ if (pending.length === 0 || pending.length > ATTACHED_NOTE_MAX_LINES) return false;
146
+ return !pending.some((l) => BANNER_RULE.test(l.trim()));
147
+ }
148
+
149
+ /**
150
+ * How many lines of the file are shipped documentation rather than config.
151
+ * Used by `selva doctor` to flag an annotated `.env` without rewriting it.
152
+ */
153
+ export function countEnvCommentLines(text) {
154
+ const { removed } = stripEnvComments(text);
155
+ return removed;
156
+ }
157
+
22
158
  export function parseEnv(text) {
23
159
  const out = {};
24
160
  for (const rawLine of text.split(/\r?\n/)) {
@@ -88,8 +224,55 @@ export function mergeEnv(template, values) {
88
224
  return out.join('\n') + '\n';
89
225
  }
90
226
 
91
- export function writeEnvFile(path, template, values) {
92
- writeFileSync(path, mergeEnv(template, values), 'utf8');
227
+ /**
228
+ * Render a values-only `.env` — the scaffold's default shape.
229
+ *
230
+ * The annotated template is genuinely useful while you are deciding what to
231
+ * set, which is why the runtime still ships it; it stops being useful the
232
+ * moment it is written to a server and cannot be corrected there. So the
233
+ * template stays a reference and the deployment gets values, in the template's
234
+ * own key order (it groups related settings) with anything unknown appended.
235
+ */
236
+ export function renderEnvValues(template, values, { header = ENV_HEADER } = {}) {
237
+ const ordered = [];
238
+ const seen = new Set();
239
+
240
+ // The template names a var three ways — live (`KEY=v`), commented-out
241
+ // (`# KEY=v`), and as a bare mention in prose (`# KEY single | multi`).
242
+ // All three signal where the var belongs, so the first token of any line is
243
+ // enough; anything that isn't a known key is filtered out below.
244
+ for (const rawLine of template.split(/\r?\n/)) {
245
+ const stripped = rawLine.trim().replace(/^#\s*/, '');
246
+ const key = stripped.split(/[\s=]/)[0]?.trim();
247
+ if (!key || !/^[A-Z][A-Z0-9_]{2,}$/.test(key)) continue;
248
+ if (seen.has(key)) continue;
249
+ if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
250
+ seen.add(key);
251
+ ordered.push(key);
252
+ }
253
+
254
+ for (const key of Object.keys(values)) {
255
+ if (!seen.has(key)) ordered.push(key);
256
+ }
257
+
258
+ const lines = [];
259
+ for (const key of ordered) {
260
+ const value = values[key];
261
+ if (value === undefined || value === null || value === '') continue;
262
+ lines.push(`${key}=${quoteIfNeeded(value)}`);
263
+ }
264
+
265
+ return header ? `${header}\n\n${lines.join('\n')}\n` : `${lines.join('\n')}\n`;
266
+ }
267
+
268
+ /**
269
+ * Writes the deployment `.env`. Values-only by default — pass
270
+ * `{ annotated: true }` to merge into the template's comments instead, which
271
+ * only an operator who explicitly wants the old shape should do.
272
+ */
273
+ export function writeEnvFile(path, template, values, { annotated = false } = {}) {
274
+ const text = annotated ? mergeEnv(template, values) : renderEnvValues(template, values);
275
+ writeFileSync(path, text, 'utf8');
93
276
  }
94
277
 
95
278
  /**