@selvajs/cli 4.8.0-beta.9 → 4.8.1

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.
@@ -3,6 +3,11 @@
3
3
  // than in either command — two copies drifted once already, and the pm2 pin is
4
4
  // exactly the field where a silent difference costs the most.
5
5
 
6
+ import { readFileSync } from 'node:fs';
7
+ import { execSync } from 'node:child_process';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
6
11
  /**
7
12
  * pm2 is pinned exactly, not caret-ranged.
8
13
  *
@@ -10,8 +15,29 @@
10
15
  * two deployments installed a week apart resolve differently, and the newer one
11
16
  * silently adopts the daemon the older one started — the skew `ensurePm2InSync`
12
17
  * has to detect and, in the daemon-is-newer direction, cannot repair (#118).
18
+ *
19
+ * Read from this package's own devDependencies rather than written here as a
20
+ * literal. Dependabot scans manifests, not source, so a literal is invisible to
21
+ * it — this pin sat on 5.4.3 while upstream reached 7.x, which is precisely the
22
+ * drift operators point at when they argue for distro packages instead.
23
+ * `checkPinIsExact` keeps the manifest entry from being loosened to a range.
13
24
  */
14
- const PM2_VERSION = '5.4.3';
25
+ const PM2_VERSION = readPinnedPm2Version();
26
+
27
+ function readPinnedPm2Version() {
28
+ const manifest = JSON.parse(
29
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
30
+ );
31
+ const pinned = manifest.devDependencies?.pm2;
32
+ if (!pinned || !/^\d+\.\d+\.\d+$/.test(pinned)) {
33
+ throw new Error(
34
+ `@selvajs/cli devDependencies.pm2 must be an exact version, got ${JSON.stringify(pinned)}. ` +
35
+ `Every deployment this CLI scaffolds inherits it, and a range there lets two ` +
36
+ `installs resolve different pm2s that then fight over one daemon.`
37
+ );
38
+ }
39
+ return pinned;
40
+ }
15
41
 
16
42
  const SCRIPTS = {
17
43
  start: 'selva start',
@@ -26,6 +52,11 @@ const SCRIPTS = {
26
52
  * Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
27
53
  * the CLI is a dependency so `npm run start` resolves the `selva` bin locally
28
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.
29
60
  */
30
61
  const DEPENDENCIES = {
31
62
  '@selvajs/cli': 'latest',
@@ -33,6 +64,49 @@ const DEPENDENCIES = {
33
64
  pm2: PM2_VERSION
34
65
  };
35
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
+
95
+ /**
96
+ * npm `overrides` forced onto every deployment — security patches for
97
+ * transitive deps whose direct parent hasn't picked up the fix yet. Each entry
98
+ * is a temporary shim: remove it once the parent ships the fixed version on
99
+ * its own (the override then matches what would install anyway, so removal is
100
+ * cleanup, never urgent).
101
+ *
102
+ * js-yaml: pm2 <= 7.0.3 pins 4.3.0, which carries the quadratic-complexity
103
+ * DoS advisories (GHSA-5p4m-2wfm-xmqj and friends); the fix landed in 4.3.1.
104
+ * Drop when pm2's own js-yaml dependency reaches >= 4.3.1.
105
+ */
106
+ export const OVERRIDES = {
107
+ 'js-yaml': '^4.3.1'
108
+ };
109
+
36
110
  /**
37
111
  * Legacy `@selvajs/*` packages that a deployment may still list. Each was
38
112
  * either renamed or folded into `@selvajs/selva`; `migrate` drops them and
@@ -60,20 +134,98 @@ export const LEGACY_DEPENDENCIES = {
60
134
  * deliberately, and dropping it removes a guard whose absence shows up only
61
135
  * under real traffic (issue #176). Everything else a migration discards is
62
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.
63
141
  */
64
- export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
142
+ export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
65
143
  const pkg = {
66
144
  name: sanitizePackageName(name),
67
145
  version,
68
146
  private: true,
69
147
  type: 'module',
70
148
  scripts: { ...SCRIPTS },
71
- dependencies: { ...DEPENDENCIES }
149
+ dependencies: { ...DEPENDENCIES, ...dependencies },
150
+ overrides: { ...OVERRIDES }
72
151
  };
73
152
  if (engines && typeof engines === 'object') pkg.engines = { ...engines };
74
153
  return pkg;
75
154
  }
76
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
+
77
229
  /**
78
230
  * Top-level keys the canonical package.json defines. Anything outside this set
79
231
  * that an operator added is dropped by `migrate` — `diffPackageJson` reports
@@ -86,11 +238,11 @@ export const CANONICAL_FIELDS = new Set([
86
238
  'type',
87
239
  'scripts',
88
240
  'dependencies',
241
+ 'overrides',
89
242
  'engines'
90
243
  ]);
91
244
 
92
245
  export function sanitizePackageName(name) {
93
- // npm package names: lowercase, no spaces, limited punctuation.
94
246
  return (
95
247
  String(name ?? '')
96
248
  .toLowerCase()
package/src/env.js CHANGED
@@ -2,13 +2,10 @@
2
2
 
3
3
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
4
 
5
- /**
6
- * Old env name → its replacement, for keys renamed without changing meaning.
7
- * The server still reads each old name for one minor version and warns at boot
8
- * (`readRenamed` in `@selvajs/server` `resolveComputeLimits`); `selva doctor`
9
- * reports them and `selva migrate` rewrites them. Keep in sync with that list,
10
- * and drop an entry when the server stops reading it.
11
- */
5
+ // Old env name → its replacement, for keys renamed without changing meaning.
6
+ // The server still reads each old name for one minor version and warns at
7
+ // boot; keep this in sync with the server's list and drop an entry once the
8
+ // server stops reading it.
12
9
  export const RENAMED_ENV_VARS = {
13
10
  COMPUTE_DEFINITION_BYTE_CACHE_MB: 'COMPUTE_DEFINITION_CACHE_MB',
14
11
  COMPUTE_RESPONSE_CACHE_MB: 'COMPUTE_SOLVE_CACHE_MB',
@@ -16,16 +13,148 @@ export const RENAMED_ENV_VARS = {
16
13
  MAX_SOLVE_DURATION_MS: 'COMPUTE_SOLVE_DEADLINE_MS'
17
14
  };
18
15
 
19
- /**
20
- * Deprecated vars whose replacement changes the VALUE too, so they can't be
21
- * rewritten by renaming a key. `selva doctor` reports these; migrate leaves
22
- * them alone rather than guessing.
23
- */
16
+ // Deprecated vars whose replacement changes the VALUE too, so a simple key
17
+ // rename can't migrate them — `selva migrate` leaves these alone rather than guessing.
24
18
  export const REPLACED_ENV_VARS = {
25
19
  SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
26
20
  };
27
21
 
28
- // Parse a .env file into { key: value }. Preserves nothing else.
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
+
29
158
  export function parseEnv(text) {
30
159
  const out = {};
31
160
  for (const rawLine of text.split(/\r?\n/)) {
@@ -51,7 +180,6 @@ export function readEnvFile(path) {
51
180
  return parseEnv(readFileSync(path, 'utf8'));
52
181
  }
53
182
 
54
- // Merge values into template: preserve structure, rewrite in-place, append new keys.
55
183
  export function mergeEnv(template, values) {
56
184
  const seen = new Set();
57
185
  const lines = template.split(/\r?\n/);
@@ -92,27 +220,71 @@ export function mergeEnv(template, values) {
92
220
  out.push(...appended);
93
221
  }
94
222
 
95
- // Ensure single trailing newline (shell append mishap guard).
96
223
  while (out.length > 0 && out[out.length - 1] === '') out.pop();
97
224
  return out.join('\n') + '\n';
98
225
  }
99
226
 
100
- export function writeEnvFile(path, template, values) {
101
- 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');
102
276
  }
103
277
 
104
278
  /**
105
- * Rewrite deprecated keys to their current names, in place. `renames` maps old
106
- * name → new name. Only the key is touched — the value, comments, ordering and
107
- * spacing survive, so an operator's tuned file stays recognisably theirs.
279
+ * Rewrite deprecated keys to their current names, in place. Only the key is
280
+ * touched — value, comments, ordering, and spacing survive.
108
281
  *
109
282
  * A key already present under its new name is left alone and its old line is
110
- * dropped: the server resolves the same way (new name wins), so keeping both
111
- * would preserve a line that does nothing.
283
+ * dropped: the server resolves new-name-wins, so keeping both would preserve
284
+ * a line that does nothing.
112
285
  *
113
- * Returns `{ text, changes }` where `changes` is `[oldName, newName, 'renamed'
114
- * | 'dropped']` per line acted on; an empty array means the file was already
115
- * current and nothing should be written.
286
+ * Returns `{ text, changes }`, `changes` being `[oldName, newName, 'renamed'
287
+ * | 'dropped']` per line acted on. Empty means the file was already current.
116
288
  */
117
289
  export function renameEnvKeys(text, renames) {
118
290
  const present = new Set(Object.keys(parseEnv(text)));
package/src/paths.js CHANGED
@@ -65,9 +65,8 @@ export function readEnvTemplate(dir) {
65
65
  return '';
66
66
  }
67
67
 
68
- // A deployment directory must contain an .env and an ecosystem.config.cjs.
69
- // Anything else (selva.config.js, node_modules) is "should be there" but the
70
- // CLI doesn't require it — `selva init` is allowed to fix a partial install.
68
+ // Either .env or ecosystem.config.cjs is enough — selva.config.js and
69
+ // node_modules are expected too, but init should still fix a partial install.
71
70
  export function isDeploymentDir(dir) {
72
71
  return existsSync(join(dir, '.env')) || existsSync(join(dir, 'ecosystem.config.cjs'));
73
72
  }
package/src/prompts.js CHANGED
@@ -18,9 +18,7 @@ export function collectConfigFromEnv(env = process.env) {
18
18
  'local',
19
19
  'SELVA_AUTH_PROVIDER'
20
20
  );
21
- // Default data/storage to auth — matches the prompt's "use same provider
22
- // for all three" default. Header-auth has no data layer, so it falls
23
- // through to local unless explicitly overridden.
21
+ // Header-auth has no data layer, so it falls through to local unless overridden.
24
22
  const dataDefault = auth === 'header' ? 'local' : auth;
25
23
  const data = pick(
26
24
  env.SELVA_DATA_PROVIDER,
@@ -72,7 +70,6 @@ export function collectConfigFromEnv(env = process.env) {
72
70
  values.HEADER_AUTH_DISPLAY_NAME_HEADER = env.HEADER_AUTH_DISPLAY_NAME_HEADER;
73
71
  }
74
72
 
75
- // ── Bootstrap admin ──────────────────────────────────────────────────
76
73
  const adminRequired = auth === 'header' || tenancy === 'multi';
77
74
  const adminEmail = env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL || '';
78
75
  if (adminRequired && !adminEmail) {
@@ -127,15 +124,11 @@ function requireEnv(env, name) {
127
124
  return v;
128
125
  }
129
126
 
130
- // Run full interactive prompt sequence; return env vars. Caller decides merge vs fresh.
131
- // `defaults` pre-populates prompts (cheap re-run).
132
127
  export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
133
128
  const isInit = mode === 'init';
134
129
 
135
130
  p.intro(pc.bgCyan(pc.black(isInit ? ' selva init ' : ' Selva — new deployment ')));
136
131
 
137
- // Brand prompts (SELVA_BRAND_NAME / COPYRIGHT_NAME / TAGLINE / DESCRIPTION)
138
-
139
132
  const tenancy = await p.select({
140
133
  message: 'Tenancy mode',
141
134
  initialValue: defaults.SELVA_TENANCY ?? 'single',
@@ -169,7 +162,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
169
162
  });
170
163
  cancelOn(auth);
171
164
 
172
- // Header-auth: auth-only; user must pick separate data/storage backend.
173
165
  let data;
174
166
  let storage;
175
167
  if (auth === 'header') {
@@ -197,7 +189,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
197
189
  cancelOn(storageChoice);
198
190
  storage = storageChoice;
199
191
  } else {
200
- // Operators usually want same backend for all three; ask once, default the others.
201
192
  const mixProviders = await p.confirm({
202
193
  message: `Use ${pc.cyan(auth)} for data and storage too?`,
203
194
  initialValue: pickSameProviderDefault(defaults, auth)
@@ -281,7 +272,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
281
272
  }
282
273
 
283
274
  if (auth === 'header') {
284
- // HEADER_AUTH_DATA_DIR: ask only if data provider isn't local (fallback available).
285
275
  if (data !== 'local') {
286
276
  const dataDir = await p.text({
287
277
  message: 'HEADER_AUTH_DATA_DIR — directory for header-allowlist.json',
@@ -330,7 +320,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
330
320
  providerValues.HEADER_AUTH_DISPLAY_NAME_HEADER = stringValue(display);
331
321
  }
332
322
 
333
- // Header-auth: default to loopback (not enforced; Docker networks may override).
323
+ // Not enforced — a Docker network can still route to the container on another host.
334
324
  const bindLoopback = await p.confirm({
335
325
  message: 'Bind the app to 127.0.0.1 only? (recommended for header-auth)',
336
326
  initialValue: !defaults.HOST || defaults.HOST === '127.0.0.1' || defaults.HOST === 'localhost'
@@ -339,7 +329,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
339
329
  providerValues.HOST = bindLoopback ? '127.0.0.1' : stringValue(defaults.HOST);
340
330
  }
341
331
 
342
- // Bootstrap admin (checked only while no admin exists; safe to leave set).
343
332
  if (auth === 'header') {
344
333
  p.note(
345
334
  [
@@ -494,10 +483,11 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
494
483
  return values;
495
484
  }
496
485
 
497
- // Init: default to "same provider" only if current .env already shows that.
498
486
  function pickSameProviderDefault(defaults, auth) {
499
487
  const data = defaults.SELVA_DATA_PROVIDER ?? auth;
500
488
  const storage = defaults.SELVA_STORAGE_PROVIDER ?? auth;
489
+ // No existing defaults means a fresh `create`, not a re-prompted `init` — default to
490
+ // "same provider for all three" since there's nothing yet to disagree with auth.
501
491
  if (!defaults.SELVA_DATA_PROVIDER && !defaults.SELVA_STORAGE_PROVIDER) return true;
502
492
  return data === auth && storage === auth;
503
493
  }