@timekast/factory 1.26.0 → 1.28.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
@@ -35,8 +36,9 @@ export const PROVISION_OWNED = new Set([
35
36
  'MFA_ENCRYPTION_KEY',
36
37
  'WEBAUTHN_RP_ID',
37
38
  // LOCAL-ONLY E2E credentials (like DATABASE_URL_MAIN): the E2E runner reads them
38
- // from `.env.local` to spin an isolated Neon branch per run. CI gets them as GitHub
39
- // Secrets; they must NEVER reach Vercel (a day-2 push would leak the Neon key).
39
+ // from `.env.local` to spin an isolated Neon branch per run. Both local and CI hold
40
+ // the SAME project-scoped key (see `writeE2eLocalCreds`); they must NEVER reach
41
+ // Vercel (a day-2 push would leak it).
40
42
  'NEON_API_KEY',
41
43
  'NEON_PROJECT_ID',
42
44
  // LOCAL-ONLY backlog-central credentials (BSYNC-003): the derivative's `backlog
@@ -97,24 +99,37 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
97
99
  /**
98
100
  * Write the LOCAL E2E Neon credentials into `.env.local` so `pnpm test:e2e` runs on
99
101
  * the dev's machine without a manual `setup:e2e`. The E2E runner reads NEON_API_KEY +
100
- * NEON_PROJECT_ID from `.env.local` to spin an isolated branch per run. CI gets these
101
- * as GitHub Secrets (a project-scoped key); locally we reuse the dev's ORG key from
102
- * the rail (already on their host). Both stay LOCAL-ONLY (PROVISION_OWNED → never
103
- * pushed to Vercel). Returns false (no-op) when the key/project id is missing or
104
- * `.env.local` does not exist yet (`writeEnvLocal` creates it first). Safe on an
105
- * existing file: `setEnvLine` only touches these two keys, preserving the rest.
102
+ * NEON_PROJECT_ID from `.env.local` to spin an isolated branch per run.
103
+ *
104
+ * 🔴 `apiKey` is the PROJECT-SCOPED key provision just minted — the same value CI
105
+ * gets as a GitHub Secret — and never the rail key. This used to write the rail key
106
+ * ("locally we reuse the dev's ORG key, already on their host"), which is how a
107
+ * credential reaching every organization ended up in the `.env.local` of every
108
+ * derivative. A scoped key 404s on any other project, so it is the only value that
109
+ * belongs in a per-project file.
110
+ *
111
+ * `apiKey` of `null` means "no key was minted in this run" (a resume over an already
112
+ * complete Neon step, where the value is unrecoverable because it was never
113
+ * persisted). Then only `NEON_PROJECT_ID` is written — never a fallback to the rail —
114
+ * and the caller tells the dev the local key was not refreshed.
115
+ *
116
+ * Both keys stay LOCAL-ONLY (PROVISION_OWNED → never pushed to Vercel). Returns
117
+ * false (no-op) when the project id is missing or `.env.local` does not exist yet
118
+ * (`writeEnvLocal` creates it first). Safe on an existing file: `setEnvLine` only
119
+ * touches these two keys, preserving the rest.
106
120
  */
107
121
  export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
108
- if (!apiKey || !projectId)
122
+ if (!projectId)
109
123
  return false;
110
124
  const localPath = join(rootDir, '.env.local');
111
125
  if (!existsSync(localPath))
112
126
  return false;
113
127
  let body = readFileSync(localPath, 'utf8');
114
128
  body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
115
- body = setEnvLine(body, 'NEON_API_KEY', apiKey);
129
+ if (apiKey)
130
+ body = setEnvLine(body, 'NEON_API_KEY', apiKey);
116
131
  writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
117
- chmodSync(localPath, 0o600); // holds NEON_API_KEY → owner-only.
132
+ chmodSync(localPath, 0o600); // may hold NEON_API_KEY → owner-only.
118
133
  return true;
119
134
  }
120
135
  /**
@@ -182,3 +197,241 @@ export function writeBacklogLocalCreds(rootDir, projectId, apiKey) {
182
197
  chmodSync(localPath, 0o600);
183
198
  return true;
184
199
  }
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+ // EXTPT-004 — the derivative declares ITS OWN per-environment variables.
202
+ //
203
+ // `PROVISION_OWNED` above is the kit's closed list, and by construction it never
204
+ // sees the derivative's vocabulary: a storage bucket, a queue, any service whose
205
+ // credentials differ between production and preview is born in the project, not in
206
+ // the kit. Without a way to declare those, `env push` uploads one value to BOTH
207
+ // targets and production ends up writing to the development bucket.
208
+ //
209
+ // TWO CHANNELS THAT RECONCILE, not one:
210
+ //
211
+ // 1. {@link PER_ENV_REGISTRY_FILE} at the repo root — the DURABLE registry. It is
212
+ // DATA (parsed), never a module (imported): the CLI publishes COMPILED and runs
213
+ // under plain `node` via `npx`, with `tsx` only as a devDependency, so a
214
+ // `*.project.ts` sibling — the shape the kit uses for `preflight.project.ts` and
215
+ // `e2e.project.ts`, both loaded by scripts that DO run under tsx — would throw
216
+ // ERR_UNKNOWN_FILE_EXTENSION in every derivative. It is COMMITTED on purpose:
217
+ // the policy of what must not reach production belongs in the PR and in every
218
+ // teammate's clone.
219
+ // 2. {@link PER_ENV_MARKER} in a comment right above the variable in `.env.local` —
220
+ // the ergonomic channel, declared where the value lives.
221
+ //
222
+ // `env push` RECONCILES: every marker it finds and the registry lacks is appended to
223
+ // the registry (idempotently). See {@link addToPerEnvRegistry} for the add-only rule.
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ /**
226
+ * Repo-root file where the derivative declares its per-environment variables.
227
+ * Dev-owned (excluded from both distribution profiles → `factory update` never
228
+ * writes or deletes it) and COMMITTED (it is team policy, not a local preference).
229
+ */
230
+ export const PER_ENV_REGISTRY_FILE = 'env.project.json';
231
+ /** The JSON key inside {@link PER_ENV_REGISTRY_FILE} holding the declared names. */
232
+ export const PER_ENV_REGISTRY_KEY = 'perEnv';
233
+ /**
234
+ * Ergonomic marker: a `.env.local` COMMENT carrying this token protects the next
235
+ * assignment below it (`# @per-env` on the line above `STORAGE_BUCKET_URL=...`).
236
+ */
237
+ export const PER_ENV_MARKER = '@per-env';
238
+ /** The shape hint appended to every parse/shape failure, so the fix is obvious. */
239
+ const PER_ENV_SHAPE_HINT = `Forma esperada: \`{ "${PER_ENV_REGISTRY_KEY}": ["STORAGE_BUCKET_URL"] }\` ` +
240
+ '— un objeto con una lista de NOMBRES de variable (nunca valores).';
241
+ /**
242
+ * Read the derivative's per-environment declaration.
243
+ *
244
+ * 🔴 ERROR POLARITY — this is the whole point of the function. `readProductVars` is
245
+ * fail-safe by ACCIDENT OF PURPOSE: it reads VALUES, so returning `{}` on a read
246
+ * error means "push nothing". This reads EXCLUSIONS, where an empty result means
247
+ * "push EVERYTHING" — the dev would believe the lock is shut while the development
248
+ * bucket credentials land in production. So three conditions, kept apart on purpose:
249
+ *
250
+ * - **Absent** → a legitimately empty set, with NO message. This is the common case
251
+ * today and must stay perfectly silent.
252
+ * - **Present but unreadable** (EACCES, EISDIR, …) → throws. Never degrade to empty.
253
+ * - **Present but unparseable or malformed** → throws, naming the file and the
254
+ * problem. Partial exclusions are not a thing: either we know the policy or we stop.
255
+ *
256
+ * The caller must run this BEFORE anything reaches Vercel (a throw here aborts the
257
+ * whole push, `--dry-run` included).
258
+ */
259
+ export function readPerEnvRegistry(rootDir) {
260
+ const file = join(rootDir, PER_ENV_REGISTRY_FILE);
261
+ if (!existsSync(file))
262
+ return { present: false, keys: [], raw: {} };
263
+ let text;
264
+ try {
265
+ text = readFileSync(file, 'utf8');
266
+ }
267
+ catch (error) {
268
+ throw new CLIError(`No pude leer \`${PER_ENV_REGISTRY_FILE}\` (${error.message}). ` +
269
+ 'Ahí están las variables que NO deben subir a Vercel: sin leerlo no puedo ' +
270
+ 'garantizar que se respeten, así que aborto el push en vez de subir de más.');
271
+ }
272
+ let parsed;
273
+ try {
274
+ parsed = JSON.parse(text);
275
+ }
276
+ catch (error) {
277
+ throw new CLIError(`\`${PER_ENV_REGISTRY_FILE}\` no es JSON válido (${error.message}). ` +
278
+ `${PER_ENV_SHAPE_HINT} Aborté el push: un registro que no puedo leer no protege nada.`);
279
+ }
280
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
281
+ throw new CLIError(`\`${PER_ENV_REGISTRY_FILE}\` debe ser un objeto JSON. ${PER_ENV_SHAPE_HINT}`);
282
+ }
283
+ const raw = parsed;
284
+ const declared = raw[PER_ENV_REGISTRY_KEY];
285
+ if (!Array.isArray(declared) ||
286
+ declared.some((key) => typeof key !== 'string' || key.trim() === '')) {
287
+ throw new CLIError(`\`${PER_ENV_REGISTRY_FILE}\` no declara \`${PER_ENV_REGISTRY_KEY}\` como una lista ` +
288
+ `de nombres de variable. ${PER_ENV_SHAPE_HINT}`);
289
+ }
290
+ const keys = [];
291
+ for (const key of declared) {
292
+ const name = key.trim();
293
+ if (!keys.includes(name))
294
+ keys.push(name);
295
+ }
296
+ return { present: true, keys, raw };
297
+ }
298
+ /**
299
+ * Append names to the registry, preserving every other field of the file.
300
+ *
301
+ * 🔴 ADD-ONLY, and this is a guarantee, not an implementation detail. Removing the
302
+ * `# @per-env` marker from `.env.local` does NOT unprotect the variable: it stays in
303
+ * the registry until someone edits that file by hand, in a diff a reviewer can see.
304
+ * Silently unprotecting is the one failure this mechanism cannot afford — a variable
305
+ * that quietly stops being excluded pushes the development credential to production
306
+ * on the very next run, with nothing on screen to notice.
307
+ *
308
+ * Idempotent: with nothing new to add, the file is not touched at all (no rewrite,
309
+ * no reformat, no mtime bump). Returns the names actually added.
310
+ *
311
+ * 🔴 THE GUARANTEE IS AGAINST THE DISK, NOT AGAINST THE ARGUMENT. Writing
312
+ * `[...registry.keys, ...added]` from the caller's snapshot cannot drop a key of the
313
+ * ARGUMENT — and silently drops any key that is on DISK and not in it (a teammate's
314
+ * commit pulled in between, a hand edit, a second process). A guarantee that holds
315
+ * only while the caller behaves is not a guarantee, and this module ships to the whole
316
+ * fleet: so the file is RE-READ here and the union is taken against what is actually
317
+ * there. A registry unreadable at this point throws, exactly as it does on the way in —
318
+ * never degrade to overwriting it with a partial list.
319
+ */
320
+ export function addToPerEnvRegistry(rootDir, registry, keysToAdd) {
321
+ const onDisk = readPerEnvRegistry(rootDir);
322
+ const base = [...onDisk.keys];
323
+ for (const key of registry.keys)
324
+ if (!base.includes(key))
325
+ base.push(key);
326
+ const added = [];
327
+ for (const key of keysToAdd) {
328
+ if (!base.includes(key) && !added.includes(key))
329
+ added.push(key);
330
+ }
331
+ if (added.length === 0)
332
+ return [];
333
+ // The disk's other fields win: it is the version that may have grown since the read.
334
+ const body = { ...registry.raw, ...onDisk.raw, [PER_ENV_REGISTRY_KEY]: [...base, ...added] };
335
+ writeFileSync(join(rootDir, PER_ENV_REGISTRY_FILE), `${JSON.stringify(body, null, 2)}\n`, 'utf8');
336
+ return added;
337
+ }
338
+ /** Whether an env-file COMMENT line carries the {@link PER_ENV_MARKER} token. */
339
+ function isPerEnvMarkerComment(trimmed) {
340
+ if (!trimmed.startsWith('#'))
341
+ return false;
342
+ return trimmed.slice(1).trim().split(/\s+/).includes(PER_ENV_MARKER);
343
+ }
344
+ /**
345
+ * Scan an env-file body for the variables marked `# @per-env`.
346
+ *
347
+ * A marker comment protects the assignment on the NEXT non-empty line. Blank lines in
348
+ * between do not break the pairing; ANY other line does — another comment, a
349
+ * commented-out assignment, a stray word — and the marker is consumed by that one
350
+ * assignment (it never spills onto the following variables).
351
+ *
352
+ * 🔴 THE STRICTNESS IS THE FIX, and the cost is deliberate: an explanatory comment
353
+ * BETWEEN the marker and its variable no longer protects it (put the explanation on
354
+ * the marker line itself — `# @per-env — bucket por ambiente`). It used to, because
355
+ * `armed` only cleared on an assignment, and that over-capture reached the variable
356
+ * two lines down:
357
+ *
358
+ * # @per-env — bucket por ambiente
359
+ * # STORAGE_BUCKET_URL="…" ← commented out
360
+ * NEXT_PUBLIC_APP_NAME="Foo" ← captured, and nobody asked for it
361
+ *
362
+ * That over-capture was once "visible and harmless" — it lived only in memory, for the
363
+ * duration of one push. It is neither now: `env push` RECONCILES the markers into the
364
+ * COMMITTED registry, and {@link addToPerEnvRegistry} is add-only by guarantee, so
365
+ * removing the marker does not undo it. A product variable would stop reaching Vercel
366
+ * permanently, with the "declared but absent" warning silent (the variable does exist),
367
+ * and the only remedy would be hand-editing the JSON.
368
+ */
369
+ export function findPerEnvMarkedKeys(body) {
370
+ const marked = [];
371
+ let armed = false;
372
+ for (const line of body.split('\n')) {
373
+ const trimmed = line.trim();
374
+ if (trimmed === '')
375
+ continue;
376
+ if (trimmed.startsWith('#')) {
377
+ // A marker arms; any OTHER comment disarms (it is a non-empty line like any other).
378
+ armed = isPerEnvMarkerComment(trimmed);
379
+ continue;
380
+ }
381
+ const eq = line.indexOf('=');
382
+ const key = eq === -1 ? '' : line.slice(0, eq).trim();
383
+ if (armed && key !== '' && !marked.includes(key))
384
+ marked.push(key);
385
+ armed = false;
386
+ }
387
+ return marked;
388
+ }
389
+ /**
390
+ * Keys whose ASSIGNMENT line carries the {@link PER_ENV_MARKER} inline
391
+ * (`STORAGE_BUCKET_URL="x" # @per-env`). That form protects NOTHING — the marker is
392
+ * only read from a comment line ABOVE the assignment — and it also CORRUPTS the value:
393
+ * `parseProductVars` splits on the first `=` and does not strip inline comments, and
394
+ * the quote-stripping no longer applies because the value stops ending in a quote, so
395
+ * Vercel receives `"x" # @per-env` verbatim.
396
+ *
397
+ * Returned so the caller can WARN. Detecting it and protecting silently would be the
398
+ * worse fix: the dev would keep writing a form that the next reader cannot tell from
399
+ * the working one.
400
+ */
401
+ export function findInlinePerEnvMarkers(body) {
402
+ const keys = [];
403
+ for (const line of body.split('\n')) {
404
+ const trimmed = line.trimStart();
405
+ if (trimmed === '' || trimmed.startsWith('#'))
406
+ continue;
407
+ const eq = line.indexOf('=');
408
+ if (eq === -1)
409
+ continue;
410
+ const key = line.slice(0, eq).trim();
411
+ if (key === '' || keys.includes(key))
412
+ continue;
413
+ if (line.slice(eq + 1).includes(PER_ENV_MARKER))
414
+ keys.push(key);
415
+ }
416
+ return keys;
417
+ }
418
+ /**
419
+ * Every key assigned in an env-file body, empty values included. Used to tell a
420
+ * declared-and-present variable (omitted from the push) from a declared-but-absent
421
+ * one (almost always a typo that protects nothing).
422
+ */
423
+ export function listEnvKeys(body) {
424
+ const keys = [];
425
+ for (const line of body.split('\n')) {
426
+ const trimmed = line.trimStart();
427
+ if (trimmed === '' || trimmed.startsWith('#'))
428
+ continue;
429
+ const eq = line.indexOf('=');
430
+ if (eq === -1)
431
+ continue;
432
+ const key = line.slice(0, eq).trim();
433
+ if (key !== '' && !keys.includes(key))
434
+ keys.push(key);
435
+ }
436
+ return keys;
437
+ }
@@ -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);