@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.
- package/dist/commands/doctor.js +5 -4
- package/dist/commands/env.js +110 -24
- package/dist/commands/provision.js +384 -90
- package/dist/commands/ticket.js +487 -0
- package/dist/index.js +29 -0
- package/dist/lib/constants.js +133 -0
- package/dist/lib/env-file.js +264 -11
- package/dist/lib/env-wizard.js +110 -21
- package/dist/lib/initial-migration.js +12 -6
- package/dist/lib/neon-api.js +186 -21
- package/dist/lib/package-json.js +66 -2
- package/dist/lib/preflight.js +5 -1
- package/dist/lib/provision-preflight.js +29 -9
- package/dist/lib/provision-state.js +44 -0
- package/dist/lib/public-env-keys.json +27 -0
- package/package.json +1 -1
package/dist/lib/constants.js
CHANGED
|
@@ -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'];
|
package/dist/lib/env-file.js
CHANGED
|
@@ -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.
|
|
39
|
-
//
|
|
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.
|
|
101
|
-
*
|
|
102
|
-
* the
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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 (!
|
|
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
|
-
|
|
129
|
+
if (apiKey)
|
|
130
|
+
body = setEnvLine(body, 'NEON_API_KEY', apiKey);
|
|
116
131
|
writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
117
|
-
chmodSync(localPath, 0o600); //
|
|
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
|
+
}
|
package/dist/lib/env-wizard.js
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 (
|
|
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`
|
|
206
|
-
*
|
|
207
|
-
*
|
|
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
|
|
210
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
314
|
-
* pass the questions they want shown (typically the ACTIVE
|
|
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);
|