@timekast/factory 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,43 @@ export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
14
14
  export const PROPOSALS_HUB_REPO = `${FACTORY_ORG}/proposals`;
15
15
  /** HTTPS git URL of the hub (token is injected by the publish command). */
16
16
  export const PROPOSALS_HUB_GIT = `https://github.com/${FACTORY_ORG}/proposals.git`;
17
+ /**
18
+ * Repo-relative directory that holds a derivative's factory-tickets (the
19
+ * derivative→Factory channel — see the `fx-factory-tickets` convention). It is
20
+ * the ONLY directory `factory ticket push` may read from, and the containment
21
+ * check resolves it with `realpath` (never a string prefix — `project/factory-backup/`
22
+ * must not pass, and a symlink out of the tree must not either).
23
+ */
24
+ export const FACTORY_TICKET_DIR = 'project/factory';
25
+ /**
26
+ * Default assignee of a delivered factory-ticket. GitHub always notifies an
27
+ * assignee (mail + web) without them watching the repo, so this is the channel's
28
+ * actual delivery mechanism — not a cosmetic field. Overridable with `--assignee`.
29
+ */
30
+ export const FACTORY_TICKET_ASSIGNEE = 'echarabati';
31
+ /**
32
+ * Label applied to a delivered factory-ticket, so the queue is greppable
33
+ * (`gh issue list -R TimeKast/TimeKast-Factory --label kit-drift`).
34
+ *
35
+ * 🔴 The command APPLIES this label; it never CREATES it. Creating a label needs
36
+ * `write` on the repo while applying one needs only `triage` — creating it would
37
+ * break the command for exactly the population that has `triage`. A maintainer
38
+ * creates it once by hand; a missing label degrades to a warning, never a failure.
39
+ */
40
+ export const FACTORY_TICKET_LABEL = 'kit-drift';
41
+ /**
42
+ * Hard cap GitHub enforces on an issue body (65,536 characters). Some files under
43
+ * `project/factory/` are already ~27 KB, so this is reachable — the command checks
44
+ * it up front and says so, instead of surfacing a raw 422 from the API.
45
+ */
46
+ export const GITHUB_ISSUE_BODY_MAX = 65_536;
47
+ /**
48
+ * Hard cap GitHub enforces on an issue TITLE (256 characters). Same class of input as
49
+ * {@link GITHUB_ISSUE_BODY_MAX} — the title is built from the ticket's H1 plus its
50
+ * filename, both written by hand — so it gets the same treatment: capped up front with
51
+ * a readable truncation, never a raw 422 from the API.
52
+ */
53
+ export const GITHUB_ISSUE_TITLE_MAX = 256;
17
54
  /**
18
55
  * End-pointer printed at the tail of a SUCCESSFUL `new` / `update` flow (PVSKILL-002):
19
56
  * the next step a dev takes to finish setting up the environment. Shared by both
@@ -132,6 +169,66 @@ export const PREFLIGHT_SCRIPT_FILE = 'scripts/tools/preflight.ts';
132
169
  export const INVITE_ADMIN_SCRIPT_NAME = 'invite:admin';
133
170
  export const INVITE_ADMIN_SCRIPT_CMD = 'npx @timekast/factory invite-admin';
134
171
  export const INVITE_ADMIN_SCRIPT_FILE = 'scripts/tools/invite-admin.ts';
172
+ /**
173
+ * Kit-local E2E env-var publishing alias, consumed by Fase 1 of the
174
+ * `e2e-ci-auth-env` retrofit (`.claude/docs/retrofits/e2e-ci-auth-env.md`).
175
+ * tsx-based (not npx) and FILE-GATED, same contract as `preflight`: a
176
+ * core/non-Factory derivative without `scripts/tools/setup-e2e.ts` never gets
177
+ * a broken `setup:e2e:vars`. Insert-if-missing; self-heals on the next update
178
+ * once the script lands. Command confirmed verbatim against `package.json:50`
179
+ * of the kit.
180
+ */
181
+ export const SETUP_E2E_VARS_SCRIPT_NAME = 'setup:e2e:vars';
182
+ export const SETUP_E2E_VARS_SCRIPT_CMD = 'tsx scripts/tools/setup-e2e.ts --publish-vars';
183
+ export const SETUP_E2E_VARS_SCRIPT_FILE = 'scripts/tools/setup-e2e.ts';
184
+ /**
185
+ * Kit-local Playwright browser install alias (`pnpm e2e:setup`).
186
+ *
187
+ * WHY IT EXISTS. The E2E suite needs a browser on disk and nothing ever installed one: not a
188
+ * postinstall, not the runner, not a line of documentation. The gap only ever surfaced as a
189
+ * failure minutes into a run, after a Neon branch and a production build had already been
190
+ * paid for. `scripts/tools/e2e-runner.ts` now refuses to start without the browsers and names
191
+ * this alias in the message — so the alias has to EXIST in the repo the message is printed in.
192
+ *
193
+ * FILE-GATED on the runner itself: a `core` derivative gets no `sk-*`/e2e scripts, so it never
194
+ * receives an alias for a suite it cannot run. Insert-if-missing like every other entry here —
195
+ * a team that pointed `e2e:setup` at `playwright install --with-deps` keeps its own value, byte
196
+ * for byte, and no OBSOLETE entry will ever heal it: the kit never shipped a previous value.
197
+ *
198
+ * Not `npx`/`tsx`: `playwright` is a devDependency binary, so the package manager resolves it
199
+ * from `node_modules/.bin` exactly as the rest of the boilerplate scripts do.
200
+ */
201
+ export const E2E_SETUP_SCRIPT_NAME = 'e2e:setup';
202
+ export const E2E_SETUP_SCRIPT_CMD = 'playwright install chromium';
203
+ export const E2E_SETUP_SCRIPT_FILE = 'scripts/tools/e2e-runner.ts';
204
+ /**
205
+ * `evidence:visual` — el punto de entrada del harness de evidencia visual
206
+ * (`fx-visual-evidence`): captura cada superficie declarada × tema × ancho y escribe el
207
+ * manifest que consume el agente `ui-critic`.
208
+ *
209
+ * FORWARDEA A `test:e2e`, NO A PLAYWRIGHT. El harness es una fase del runner (`optIn`,
210
+ * `sk-e2e §1.7`), así que `--project=evidence` entra por el mismo camino que el resto de la
211
+ * suite: la rama efímera de Neon, la migración, el sembrado y el servidor de producción. Nunca
212
+ * `playwright test` directo — `tests/global-setup.ts` rechaza cualquier corrida cuyo
213
+ * `DATABASE_URL` no sea esa rama (`scripts/tools/e2e-guard.ts`), y este harness no pide
214
+ * excepción: fotografiar pantallas vacías no demuestra nada.
215
+ *
216
+ * FILE-GATED SOBRE EL HARNESS MISMO, no sobre el runner. Los dos viajan por `scripts/**`
217
+ * (tracked), pero el gate correcto es el archivo que ESTE alias necesita: un derivado en perfil
218
+ * `core` no recibe `scripts/tools/**` y por lo tanto nunca recibe un alias que no puede correr.
219
+ * Insert-if-missing como el resto: un equipo que apuntó `evidence:visual` a otra cosa conserva
220
+ * su valor byte por byte, y no hay entrada en `OBSOLETE_SCRIPT_CMDS` porque el kit nunca shippeó
221
+ * un valor anterior que sanar.
222
+ *
223
+ * ⚠️ Que el alias llegue NO implica que el harness corra: `playwright.config.ts` nace congelado
224
+ * en el derivado (BR-FACTORY-006), así que su proyecto `evidence` y el spec de captura los
225
+ * agrega el equipo siguiendo `.claude/docs/retrofits/visual-evidence-adoption.md`. Mientras
226
+ * tanto la fase se salta con aviso (nunca revienta la suite) y `ui-critic` degrada a
227
+ * "no demostrado" — la cláusula que su ficha declara.
228
+ */
229
+ export const EVIDENCE_VISUAL_SCRIPT_NAME = 'evidence:visual';
230
+ export const EVIDENCE_VISUAL_SCRIPT_CMD = 'pnpm test:e2e --project=evidence';
231
+ export const EVIDENCE_VISUAL_SCRIPT_FILE = 'scripts/tools/visual-evidence/manifest.ts';
135
232
  /**
136
233
  * Kit-local reference generators — autogen of `project/reference/*` (INVENTORY,
137
234
  * CODEBASE, HOOKS, SCHEMA, API). Each shells a `scripts/tools/generate-*.ts`
@@ -336,3 +433,39 @@ export const RETIRED_SCRIPT_CMDS = {
336
433
  ],
337
434
  [E2E_UI_SCRIPT_NAME]: [E2E_UI_SCRIPT_CMD, ...(OBSOLETE_SCRIPT_CMDS[E2E_UI_SCRIPT_NAME] ?? [])],
338
435
  };
436
+ /**
437
+ * The Vercel build command of a derivative's `package.json`, and the ONE reordering the CLI
438
+ * performs on it.
439
+ *
440
+ * WHY THE ORDER IS THE WHOLE POINT. The kit used to ship `pnpm db:migrate && pnpm build`, with
441
+ * a reason on record across the kit's prose: "migrate falla → build falla → no deploya". That
442
+ * reasoning holds — Vercel gates on the exit code of the WHOLE command, not on the order of its
443
+ * two halves — but it only ever considered the migration failing. The mirror case is the one
444
+ * that broke a client's production: the migration SUCCEEDS, the build fails after it, Vercel
445
+ * refuses to promote the deployment, and the production alias keeps serving the PREVIOUS build
446
+ * against a database that has already moved to the new schema. A deployment rollback does not
447
+ * undo that, because the schema change is not part of the deployment.
448
+ *
449
+ * Building first makes every failure degrade cleanly: the build fails → nothing touched the
450
+ * database; the migration fails → the command still exits non-zero, the deployment still ends
451
+ * in ERROR, and drizzle applied each migration in its own transaction. The protection the old
452
+ * order provided is preserved in full; what changes is that the window where production runs
453
+ * old code against a new schema shrinks from indefinite to the seconds between the migration
454
+ * finishing and Vercel promoting — which additive migrations, the correct practice, tolerate.
455
+ *
456
+ * WHY IT IS HEAL-ONLY AND NEVER INSERTED. `vercel-build` belongs to the BOILERPLATE, not to the
457
+ * CLI's alias set: it arrives with `src/` at `factory new` and means nothing in a repo without
458
+ * Next.js. Putting it in `FACTORY_SCRIPTS` would insert it into a `core`/non-Factory repo,
459
+ * pointing at a stack that repo does not have. So it is maintained the way `ports.e2e` is —
460
+ * touched only where it already exists, and only when it carries a value the kit itself
461
+ * shipped. `package.json` is frozen at bootstrap (BR-FACTORY-006) and is not in the tracked
462
+ * file set, so the CLI is the only thing that can reach the existing fleet at all.
463
+ *
464
+ * `VERCEL_BUILD_OBSOLETE_CMDS` lists whole COMMANDS, byte for byte, exactly as the kit shipped
465
+ * them — same discipline as `OBSOLETE_SCRIPT_CMDS` and `SHIPPED_E2E_PORT`. A derivative that
466
+ * appended a flag, or wrote the same intent with different spacing, falls through to preserved:
467
+ * an unlisted variant erring toward "leave it alone" is the safe side of this mistake.
468
+ */
469
+ export const VERCEL_BUILD_SCRIPT_NAME = 'vercel-build';
470
+ export const VERCEL_BUILD_SCRIPT_CMD = 'pnpm build && pnpm db:migrate';
471
+ export const VERCEL_BUILD_OBSOLETE_CMDS = ['pnpm db:migrate && pnpm build'];
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
+ import { CLIError } from './cli-error.js';
12
13
  import { generateAuthSecret } from './vercel-env.js';
13
14
  /**
14
15
  * Vars that provision owns and sets PER-ENVIRONMENT (or keeps local-only). The
@@ -182,3 +183,241 @@ export function writeBacklogLocalCreds(rootDir, projectId, apiKey) {
182
183
  chmodSync(localPath, 0o600);
183
184
  return true;
184
185
  }
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+ // EXTPT-004 — the derivative declares ITS OWN per-environment variables.
188
+ //
189
+ // `PROVISION_OWNED` above is the kit's closed list, and by construction it never
190
+ // sees the derivative's vocabulary: a storage bucket, a queue, any service whose
191
+ // credentials differ between production and preview is born in the project, not in
192
+ // the kit. Without a way to declare those, `env push` uploads one value to BOTH
193
+ // targets and production ends up writing to the development bucket.
194
+ //
195
+ // TWO CHANNELS THAT RECONCILE, not one:
196
+ //
197
+ // 1. {@link PER_ENV_REGISTRY_FILE} at the repo root — the DURABLE registry. It is
198
+ // DATA (parsed), never a module (imported): the CLI publishes COMPILED and runs
199
+ // under plain `node` via `npx`, with `tsx` only as a devDependency, so a
200
+ // `*.project.ts` sibling — the shape the kit uses for `preflight.project.ts` and
201
+ // `e2e.project.ts`, both loaded by scripts that DO run under tsx — would throw
202
+ // ERR_UNKNOWN_FILE_EXTENSION in every derivative. It is COMMITTED on purpose:
203
+ // the policy of what must not reach production belongs in the PR and in every
204
+ // teammate's clone.
205
+ // 2. {@link PER_ENV_MARKER} in a comment right above the variable in `.env.local` —
206
+ // the ergonomic channel, declared where the value lives.
207
+ //
208
+ // `env push` RECONCILES: every marker it finds and the registry lacks is appended to
209
+ // the registry (idempotently). See {@link addToPerEnvRegistry} for the add-only rule.
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+ /**
212
+ * Repo-root file where the derivative declares its per-environment variables.
213
+ * Dev-owned (excluded from both distribution profiles → `factory update` never
214
+ * writes or deletes it) and COMMITTED (it is team policy, not a local preference).
215
+ */
216
+ export const PER_ENV_REGISTRY_FILE = 'env.project.json';
217
+ /** The JSON key inside {@link PER_ENV_REGISTRY_FILE} holding the declared names. */
218
+ export const PER_ENV_REGISTRY_KEY = 'perEnv';
219
+ /**
220
+ * Ergonomic marker: a `.env.local` COMMENT carrying this token protects the next
221
+ * assignment below it (`# @per-env` on the line above `STORAGE_BUCKET_URL=...`).
222
+ */
223
+ export const PER_ENV_MARKER = '@per-env';
224
+ /** The shape hint appended to every parse/shape failure, so the fix is obvious. */
225
+ const PER_ENV_SHAPE_HINT = `Forma esperada: \`{ "${PER_ENV_REGISTRY_KEY}": ["STORAGE_BUCKET_URL"] }\` ` +
226
+ '— un objeto con una lista de NOMBRES de variable (nunca valores).';
227
+ /**
228
+ * Read the derivative's per-environment declaration.
229
+ *
230
+ * 🔴 ERROR POLARITY — this is the whole point of the function. `readProductVars` is
231
+ * fail-safe by ACCIDENT OF PURPOSE: it reads VALUES, so returning `{}` on a read
232
+ * error means "push nothing". This reads EXCLUSIONS, where an empty result means
233
+ * "push EVERYTHING" — the dev would believe the lock is shut while the development
234
+ * bucket credentials land in production. So three conditions, kept apart on purpose:
235
+ *
236
+ * - **Absent** → a legitimately empty set, with NO message. This is the common case
237
+ * today and must stay perfectly silent.
238
+ * - **Present but unreadable** (EACCES, EISDIR, …) → throws. Never degrade to empty.
239
+ * - **Present but unparseable or malformed** → throws, naming the file and the
240
+ * problem. Partial exclusions are not a thing: either we know the policy or we stop.
241
+ *
242
+ * The caller must run this BEFORE anything reaches Vercel (a throw here aborts the
243
+ * whole push, `--dry-run` included).
244
+ */
245
+ export function readPerEnvRegistry(rootDir) {
246
+ const file = join(rootDir, PER_ENV_REGISTRY_FILE);
247
+ if (!existsSync(file))
248
+ return { present: false, keys: [], raw: {} };
249
+ let text;
250
+ try {
251
+ text = readFileSync(file, 'utf8');
252
+ }
253
+ catch (error) {
254
+ throw new CLIError(`No pude leer \`${PER_ENV_REGISTRY_FILE}\` (${error.message}). ` +
255
+ 'Ahí están las variables que NO deben subir a Vercel: sin leerlo no puedo ' +
256
+ 'garantizar que se respeten, así que aborto el push en vez de subir de más.');
257
+ }
258
+ let parsed;
259
+ try {
260
+ parsed = JSON.parse(text);
261
+ }
262
+ catch (error) {
263
+ throw new CLIError(`\`${PER_ENV_REGISTRY_FILE}\` no es JSON válido (${error.message}). ` +
264
+ `${PER_ENV_SHAPE_HINT} Aborté el push: un registro que no puedo leer no protege nada.`);
265
+ }
266
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
267
+ throw new CLIError(`\`${PER_ENV_REGISTRY_FILE}\` debe ser un objeto JSON. ${PER_ENV_SHAPE_HINT}`);
268
+ }
269
+ const raw = parsed;
270
+ const declared = raw[PER_ENV_REGISTRY_KEY];
271
+ if (!Array.isArray(declared) ||
272
+ declared.some((key) => typeof key !== 'string' || key.trim() === '')) {
273
+ throw new CLIError(`\`${PER_ENV_REGISTRY_FILE}\` no declara \`${PER_ENV_REGISTRY_KEY}\` como una lista ` +
274
+ `de nombres de variable. ${PER_ENV_SHAPE_HINT}`);
275
+ }
276
+ const keys = [];
277
+ for (const key of declared) {
278
+ const name = key.trim();
279
+ if (!keys.includes(name))
280
+ keys.push(name);
281
+ }
282
+ return { present: true, keys, raw };
283
+ }
284
+ /**
285
+ * Append names to the registry, preserving every other field of the file.
286
+ *
287
+ * 🔴 ADD-ONLY, and this is a guarantee, not an implementation detail. Removing the
288
+ * `# @per-env` marker from `.env.local` does NOT unprotect the variable: it stays in
289
+ * the registry until someone edits that file by hand, in a diff a reviewer can see.
290
+ * Silently unprotecting is the one failure this mechanism cannot afford — a variable
291
+ * that quietly stops being excluded pushes the development credential to production
292
+ * on the very next run, with nothing on screen to notice.
293
+ *
294
+ * Idempotent: with nothing new to add, the file is not touched at all (no rewrite,
295
+ * no reformat, no mtime bump). Returns the names actually added.
296
+ *
297
+ * 🔴 THE GUARANTEE IS AGAINST THE DISK, NOT AGAINST THE ARGUMENT. Writing
298
+ * `[...registry.keys, ...added]` from the caller's snapshot cannot drop a key of the
299
+ * ARGUMENT — and silently drops any key that is on DISK and not in it (a teammate's
300
+ * commit pulled in between, a hand edit, a second process). A guarantee that holds
301
+ * only while the caller behaves is not a guarantee, and this module ships to the whole
302
+ * fleet: so the file is RE-READ here and the union is taken against what is actually
303
+ * there. A registry unreadable at this point throws, exactly as it does on the way in —
304
+ * never degrade to overwriting it with a partial list.
305
+ */
306
+ export function addToPerEnvRegistry(rootDir, registry, keysToAdd) {
307
+ const onDisk = readPerEnvRegistry(rootDir);
308
+ const base = [...onDisk.keys];
309
+ for (const key of registry.keys)
310
+ if (!base.includes(key))
311
+ base.push(key);
312
+ const added = [];
313
+ for (const key of keysToAdd) {
314
+ if (!base.includes(key) && !added.includes(key))
315
+ added.push(key);
316
+ }
317
+ if (added.length === 0)
318
+ return [];
319
+ // The disk's other fields win: it is the version that may have grown since the read.
320
+ const body = { ...registry.raw, ...onDisk.raw, [PER_ENV_REGISTRY_KEY]: [...base, ...added] };
321
+ writeFileSync(join(rootDir, PER_ENV_REGISTRY_FILE), `${JSON.stringify(body, null, 2)}\n`, 'utf8');
322
+ return added;
323
+ }
324
+ /** Whether an env-file COMMENT line carries the {@link PER_ENV_MARKER} token. */
325
+ function isPerEnvMarkerComment(trimmed) {
326
+ if (!trimmed.startsWith('#'))
327
+ return false;
328
+ return trimmed.slice(1).trim().split(/\s+/).includes(PER_ENV_MARKER);
329
+ }
330
+ /**
331
+ * Scan an env-file body for the variables marked `# @per-env`.
332
+ *
333
+ * A marker comment protects the assignment on the NEXT non-empty line. Blank lines in
334
+ * between do not break the pairing; ANY other line does — another comment, a
335
+ * commented-out assignment, a stray word — and the marker is consumed by that one
336
+ * assignment (it never spills onto the following variables).
337
+ *
338
+ * 🔴 THE STRICTNESS IS THE FIX, and the cost is deliberate: an explanatory comment
339
+ * BETWEEN the marker and its variable no longer protects it (put the explanation on
340
+ * the marker line itself — `# @per-env — bucket por ambiente`). It used to, because
341
+ * `armed` only cleared on an assignment, and that over-capture reached the variable
342
+ * two lines down:
343
+ *
344
+ * # @per-env — bucket por ambiente
345
+ * # STORAGE_BUCKET_URL="…" ← commented out
346
+ * NEXT_PUBLIC_APP_NAME="Foo" ← captured, and nobody asked for it
347
+ *
348
+ * That over-capture was once "visible and harmless" — it lived only in memory, for the
349
+ * duration of one push. It is neither now: `env push` RECONCILES the markers into the
350
+ * COMMITTED registry, and {@link addToPerEnvRegistry} is add-only by guarantee, so
351
+ * removing the marker does not undo it. A product variable would stop reaching Vercel
352
+ * permanently, with the "declared but absent" warning silent (the variable does exist),
353
+ * and the only remedy would be hand-editing the JSON.
354
+ */
355
+ export function findPerEnvMarkedKeys(body) {
356
+ const marked = [];
357
+ let armed = false;
358
+ for (const line of body.split('\n')) {
359
+ const trimmed = line.trim();
360
+ if (trimmed === '')
361
+ continue;
362
+ if (trimmed.startsWith('#')) {
363
+ // A marker arms; any OTHER comment disarms (it is a non-empty line like any other).
364
+ armed = isPerEnvMarkerComment(trimmed);
365
+ continue;
366
+ }
367
+ const eq = line.indexOf('=');
368
+ const key = eq === -1 ? '' : line.slice(0, eq).trim();
369
+ if (armed && key !== '' && !marked.includes(key))
370
+ marked.push(key);
371
+ armed = false;
372
+ }
373
+ return marked;
374
+ }
375
+ /**
376
+ * Keys whose ASSIGNMENT line carries the {@link PER_ENV_MARKER} inline
377
+ * (`STORAGE_BUCKET_URL="x" # @per-env`). That form protects NOTHING — the marker is
378
+ * only read from a comment line ABOVE the assignment — and it also CORRUPTS the value:
379
+ * `parseProductVars` splits on the first `=` and does not strip inline comments, and
380
+ * the quote-stripping no longer applies because the value stops ending in a quote, so
381
+ * Vercel receives `"x" # @per-env` verbatim.
382
+ *
383
+ * Returned so the caller can WARN. Detecting it and protecting silently would be the
384
+ * worse fix: the dev would keep writing a form that the next reader cannot tell from
385
+ * the working one.
386
+ */
387
+ export function findInlinePerEnvMarkers(body) {
388
+ const keys = [];
389
+ for (const line of body.split('\n')) {
390
+ const trimmed = line.trimStart();
391
+ if (trimmed === '' || trimmed.startsWith('#'))
392
+ continue;
393
+ const eq = line.indexOf('=');
394
+ if (eq === -1)
395
+ continue;
396
+ const key = line.slice(0, eq).trim();
397
+ if (key === '' || keys.includes(key))
398
+ continue;
399
+ if (line.slice(eq + 1).includes(PER_ENV_MARKER))
400
+ keys.push(key);
401
+ }
402
+ return keys;
403
+ }
404
+ /**
405
+ * Every key assigned in an env-file body, empty values included. Used to tell a
406
+ * declared-and-present variable (omitted from the push) from a declared-but-absent
407
+ * one (almost always a typo that protects nothing).
408
+ */
409
+ export function listEnvKeys(body) {
410
+ const keys = [];
411
+ for (const line of body.split('\n')) {
412
+ const trimmed = line.trimStart();
413
+ if (trimmed === '' || trimmed.startsWith('#'))
414
+ continue;
415
+ const eq = line.indexOf('=');
416
+ if (eq === -1)
417
+ continue;
418
+ const key = line.slice(0, eq).trim();
419
+ if (key !== '' && !keys.includes(key))
420
+ keys.push(key);
421
+ }
422
+ return keys;
423
+ }
@@ -15,6 +15,7 @@ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { CLIError } from './cli-error.js';
17
17
  import { PROVISION_OWNED, setEnvLine } from './env-file.js';
18
+ import PUBLIC_ENV_KEYS from './public-env-keys.json' with { type: 'json' };
18
19
  /**
19
20
  * The real conditionals `.env.example` can't express. Keyed by the DEPENDENT var
20
21
  * → the driver answer that reveals it. Kept deliberately small — adding a new
@@ -40,13 +41,6 @@ const SELECTS = {
40
41
  const DIVIDER = /^#\s*-{3,}\s*$/;
41
42
  const ACTIVE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
42
43
  const COMMENTED = /^#\s*([A-Z][A-Z0-9_]*)=(.*)$/;
43
- const SECRET_KEY = /(_SECRET|_KEY|_TOKEN|_PASSWORD)$/;
44
- /**
45
- * `NEXT_PUBLIC_*` vars are public by Next.js convention — they're inlined into
46
- * the client bundle, so they can never be secrets regardless of their suffix
47
- * (e.g. NEXT_PUBLIC_AUTH_PASSWORD is a boolean feature flag, not a credential).
48
- */
49
- const PUBLIC_PREFIX = /^NEXT_PUBLIC_/;
50
44
  function unquote(raw) {
51
45
  const v = raw.trim();
52
46
  if (v.length >= 2 &&
@@ -111,15 +105,20 @@ export function parseEnvExample(text) {
111
105
  return entries;
112
106
  }
113
107
  /**
114
- * Classify a var into the widget the wizard renders. Secret detection delegates
115
- * to `isSecretKey` so classification, masking and review all share one rule
116
- * (secret suffix AND not `NEXT_PUBLIC_*` — public-by-convention vars are never
117
- * secrets, e.g. NEXT_PUBLIC_AUTH_PASSWORD="true" is a boolean flag).
108
+ * Classify a var into the widget the wizard renders. Secret detection delegates to
109
+ * {@link isPublicEnvKey} — the SAME source `maskValue` reads — so classification,
110
+ * masking and review can never diverge again. It is also the source of the
111
+ * `kind: 'secret'` that `tk-provision §205` keys its display rule off: anything the
112
+ * whitelist does not vouch for arrives at the agent already marked secret, and is
113
+ * shown masked — never its value, at most its last four characters ({@link maskValue}).
114
+ *
115
+ * A var with a fixed choice set (`EMAIL_PROVIDER`) is a `select` first: the choices
116
+ * are the classification, and every var that has them is public by construction.
118
117
  */
119
118
  export function questionKind(key, defaultValue) {
120
119
  if (SELECTS[key])
121
120
  return 'select';
122
- if (isSecretKey(key))
121
+ if (!isPublicEnvKey(key))
123
122
  return 'secret';
124
123
  if (defaultValue === 'true' || defaultValue === 'false')
125
124
  return 'boolean';
@@ -202,12 +201,34 @@ export function applyWizardAnswers(rootDir, answers) {
202
201
  // setups (a provider flag on with empty creds). All pure over their inputs.
203
202
  // ─────────────────────────────────────────────────────────────────────────────
204
203
  /**
205
- * True when `key`'s value is a secret (never printed in review/summary output).
206
- * `NEXT_PUBLIC_*` keys are excluded: Next.js inlines them into the client bundle,
207
- * so they're public by convention even when their suffix looks secret-ish.
204
+ * True when `key` is PROVABLY public — the only thing whose value may be printed.
205
+ *
206
+ * 🔴 THE POLARITY IS THE POINT. This used to be `isSecretKey`, a regex over
207
+ * suffixes that LOOK secret (`_SECRET|_KEY|_TOKEN|_PASSWORD`), which is fail-OPEN:
208
+ * a name it did not recognise got its value printed verbatim. A derivative that
209
+ * invents `STRIPE_PRIVATE`, `GITHUB_PAT`, `TWILIO_CREDENTIALS` or `SHORTIO_APIKEY`
210
+ * had its credential echoed into the `--review` table and into the `--json` an agent
211
+ * reads — which contradicts `tk-provision §205`, where never printing a
212
+ * `kind: 'secret'` value is non-negotiable. Recognising what is public instead is
213
+ * fail-CLOSED: a var nobody declared public protects itself, and the failure mode of
214
+ * a name this list has not caught up with is a mask too many, not a leak.
215
+ *
216
+ * The list lives in `public-env-keys.json` so adding or removing one key is a data
217
+ * edit, not a code change.
218
+ *
219
+ * PREFIXES ARE SEPARATE FROM KEYS ON PURPOSE, and the reason is different for each:
220
+ * - `NEXT_PUBLIC_` is a CONTRACT of Next.js, not a judgement call: those vars are
221
+ * inlined into the browser bundle, so they are public by construction whatever
222
+ * their suffix looks like (`NEXT_PUBLIC_AUTH_PASSWORD` is a boolean feature flag).
223
+ * - `RATE_LIMIT_` is a growing FAMILY of tuning numbers; a derivative that adds
224
+ * `RATE_LIMIT_CHECKOUT_REQUESTS` should not have to edit a list to see its value.
225
+ * The 22 explicit keys ARE judgement, one name at a time — which is why they are
226
+ * enumerated instead of matched.
208
227
  */
209
- export function isSecretKey(key) {
210
- return !PUBLIC_PREFIX.test(key) && SECRET_KEY.test(key);
228
+ export function isPublicEnvKey(key) {
229
+ if (PUBLIC_ENV_KEYS.prefixes.some((prefix) => key.startsWith(prefix)))
230
+ return true;
231
+ return PUBLIC_ENV_KEYS.keys.includes(key);
211
232
  }
212
233
  /** Parse an env-file body into `{ KEY: value }` (unquoted), skipping comments/blanks. */
213
234
  export function parseEnvBody(body) {
@@ -225,11 +246,77 @@ export function parseEnvBody(body) {
225
246
  }
226
247
  return out;
227
248
  }
228
- /** Mask a value for display: secrets → `••••••••`, everything else verbatim. */
249
+ /** Width of a masked display, ALWAYS — suffix or not. See {@link maskValue}. */
250
+ const MASK_WIDTH = 12;
251
+ /** Trailing characters of a masked value left readable. */
252
+ const MASK_VISIBLE_SUFFIX = 4;
253
+ /**
254
+ * Below this length the value is hidden WHOLE — no suffix at all.
255
+ *
256
+ * The threshold is about what FRACTION four characters are: at 12 they are a third, and
257
+ * below that the share climbs fast — at 8 the suffix would be half the value.
258
+ *
259
+ * 🔴 MEASURE AGAINST THE POPULATION THIS FUNCTION ACTUALLY SEES, which is narrower than
260
+ * `.env.example`: `buildQuestionPlan` drops the provision-owned vars (`DATABASE_URL`,
261
+ * `AUTH_SECRET` never reach a review), and the whitelist returns the public ones in the
262
+ * clear. What is left masked today is four keys — the two OAuth secrets, the Resend key and
263
+ * `EMAIL_SERVER_PASSWORD` — plus whatever the derivative invents. The SMTP host, port and
264
+ * user are whitelisted: masking a hostname and a port number protected nothing and made the
265
+ * review less useful.
266
+ */
267
+ const MASK_MIN_LENGTH = 12;
268
+ /** Pad `visible` to {@link MASK_WIDTH} with bullets, so every masked display is one width. */
269
+ function padMask(visible) {
270
+ return '•'.repeat(MASK_WIDTH - visible.length) + visible;
271
+ }
272
+ /**
273
+ * Mask a value for display: a PROVABLY PUBLIC key ({@link isPublicEnvKey}) shows its
274
+ * value, everything else shows its last {@link MASK_VISIBLE_SUFFIX} characters behind
275
+ * bullets — or nothing at all below {@link MASK_MIN_LENGTH}. An empty value stays empty:
276
+ * there is nothing to hide and the caller flags it as `(sin configurar)`.
277
+ *
278
+ * WHY A SUFFIX AT ALL, since `tk-provision §205` is what governs here. Fully hidden, every
279
+ * credential renders identically, so the review cannot answer the one question it is opened
280
+ * for while rotating a key: *is the value in this file the old one or the new one?* Four
281
+ * trailing characters answer it for a provider token and are useless on their own. The rule
282
+ * that survives is the one that matters: the VALUE is never printed — the dev opens
283
+ * `.env.local` to read it whole.
284
+ *
285
+ * 🔴 WHERE THE SUFFIX ANSWERS NOTHING, and the caller should not trust it: a value whose
286
+ * tail is CONSTANT rather than random. A connection string ending in `?sslmode=require`
287
+ * renders `••••••••uire` before and after a rotation, and `parseEnvBody` keeps an inline
288
+ * `# rotado 2026-08` as part of the value, so the four characters shown can be the
289
+ * developer's own comment. The display cannot tell those apart from a real tail, so it
290
+ * distinguishes provider tokens and nothing else.
291
+ *
292
+ * 🔴 AND WHERE IT COSTS MORE THAN IT DOES ELSEWHERE: a value a HUMAN typed. Against a
293
+ * provider token four characters are noise — 16 bits off a 160-bit GitHub secret. Against
294
+ * `Primavera2026!` they are the patterned tail (`026!`), which does not leak entropy but
295
+ * CONFIRMS the mangling rule, and that is the expensive half of a dictionary attack. Of the
296
+ * keys this function masks, `EMAIL_SERVER_PASSWORD` is the one someone types. One rule for
297
+ * all of them is a deliberate call — a name-shaped exception is the very thing this module
298
+ * was rewritten to remove — so the caller carries the knowledge instead: that mailbox
299
+ * usually sends the product's transactional mail AND recovers its accounts, so give it a
300
+ * generated password, not a memorable one.
301
+ *
302
+ * THE SUFFIX, NOT THE PREFIX, and the difference is not cosmetic: provider tokens are
303
+ * front-loaded with a family marker (`ghp_`, `sk-`, `re_`, `npg_`), so a prefix would print
304
+ * what every token of that provider shares and identify nothing.
305
+ *
306
+ * FIXED WIDTH, always {@link MASK_WIDTH}: a display that shrank for short values would say
307
+ * out loud which credentials are under twelve characters — that is, which are weak — and in
308
+ * the `--json` that is a machine-readable string length, not an impression.
309
+ *
310
+ * The rule is the same for the printed table and for the `--json` an agent reads. One rule,
311
+ * not two — the divergence between two ways of deciding what is a secret is the defect this
312
+ * module was rewritten to remove.
313
+ */
229
314
  export function maskValue(key, value) {
230
315
  if (!value)
231
316
  return value;
232
- return isSecretKey(key) ? '••••••••' : value;
317
+ if (isPublicEnvKey(key))
318
+ return value;
319
+ return padMask(value.length >= MASK_MIN_LENGTH ? value.slice(-MASK_VISIBLE_SUFFIX) : '');
233
320
  }
234
321
  /** Override the `defaultValue` of any question whose key appears in `overrides`. */
235
322
  export function applyDefaultOverrides(questions, overrides) {
@@ -310,8 +397,10 @@ export function generateVapidKeys() {
310
397
  }
311
398
  /**
312
399
  * Build a secret-safe review of `.env.local` for the final "here's how your app is
313
- * configured" summary. Secret values are masked; empty values are flagged. Callers
314
- * pass the questions they want shown (typically the ACTIVE ones for the resolved env).
400
+ * configured" summary. Everything the whitelist does not vouch for is masked; empty
401
+ * values are flagged. Callers pass the questions they want shown (typically the ACTIVE
402
+ * ones for the resolved env). The `--json` of `factory env wizard --review` serialises
403
+ * these same rows, so the agent-facing output obeys the identical rule.
315
404
  */
316
405
  export function buildEnvReview(questions, body) {
317
406
  const env = parseEnvBody(body);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
6
6
  * derivative was born with a schema and no migrations while its `vercel-build`
7
- * (`pnpm db:migrate && pnpm build`) migrated an empty database on the first deploy.
7
+ * (`pnpm build && pnpm db:migrate`) migrated an empty database on the first deploy.
8
8
  * The provision step that closes that gap has to decide, from disk alone, whether
9
9
  * generating and committing a `0000` is the right thing to do — and the cost of a
10
10
  * wrong "yes" is asymmetric.