@timekast/factory 1.28.1 β†’ 2.0.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.
Files changed (38) hide show
  1. package/dist/commands/backlog.js +52 -15
  2. package/dist/commands/doctor.js +210 -29
  3. package/dist/commands/env.js +146 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5798 -761
  6. package/dist/commands/status.js +12 -2
  7. package/dist/commands/ticket.js +4 -4
  8. package/dist/commands/update.js +97 -12
  9. package/dist/commands/vault.js +2627 -0
  10. package/dist/index.js +97 -33
  11. package/dist/lib/backlog-mapping.js +111 -18
  12. package/dist/lib/backlog-parse.js +1 -1
  13. package/dist/lib/claude-md.js +320 -0
  14. package/dist/lib/cloudflare-api.js +213 -37
  15. package/dist/lib/constants.js +70 -6
  16. package/dist/lib/env-file.js +45 -7
  17. package/dist/lib/env-wizard.js +66 -2
  18. package/dist/lib/initial-migration.js +3 -2
  19. package/dist/lib/neon-api.js +145 -10
  20. package/dist/lib/package-json.js +37 -16
  21. package/dist/lib/preflight.js +92 -38
  22. package/dist/lib/provision-preflight.js +158 -100
  23. package/dist/lib/provision-state.js +652 -20
  24. package/dist/lib/publish-engine.js +1 -1
  25. package/dist/lib/rail.js +400 -0
  26. package/dist/lib/railway-api.js +851 -0
  27. package/dist/lib/vault-adopt.js +1495 -0
  28. package/dist/lib/vault-api.js +1101 -0
  29. package/dist/lib/vault-config.js +21 -0
  30. package/dist/lib/vault-deploys.js +898 -0
  31. package/dist/lib/vercel-env.js +251 -31
  32. package/dist/lib/wrapper-wiring.js +160 -0
  33. package/dist/tui/env-wizard-flow.js +17 -3
  34. package/dist/tui/menu.js +4 -22
  35. package/dist/tui/provision-flow.js +68 -13
  36. package/package.json +1 -1
  37. package/dist/commands/secrets.js +0 -71
  38. package/dist/lib/env-merge.js +0 -238
@@ -7,9 +7,6 @@
7
7
  export const FACTORY_ORG = 'TimeKast';
8
8
  /** The Factory monorepo, source of the distribution releases. */
9
9
  export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
10
- // NOTE: the org secrets gate constants (SECRETS_GATE_URL, GITHUB_DEVICE_CLIENT_ID,
11
- // GITHUB_DEVICE_SCOPE, SECRETS_STATE_FILE) were REMOVED with the `secrets pull`/`sync`
12
- // device-flow gate β€” that droplet effort is parked; the future is Infisical.
13
10
  /** The static hub repo that hosts published proposals/mockups (`factory publish`). */
14
11
  export const PROPOSALS_HUB_REPO = `${FACTORY_ORG}/proposals`;
15
12
  /** HTTPS git URL of the hub (token is injected by the publish command). */
@@ -148,6 +145,66 @@ export const FACTORY_SCRIPTS = {
148
145
  [UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
149
146
  [ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
150
147
  };
148
+ /**
149
+ * The local wrapper that injects the vault's environment into the kit's scripts (VAULT-003).
150
+ * Ships with the FULL brain (`scripts/**`); the `core` profile does not carry it.
151
+ */
152
+ export const WITH_VAULT_SCRIPT_FILE = 'scripts/tools/with-vault.mjs';
153
+ /** The prefix every wrapped script value starts with. */
154
+ export const WITH_VAULT_SCRIPT_CMD = `node ${WITH_VAULT_SCRIPT_FILE}`;
155
+ /**
156
+ * πŸ”΄ THE single source of the kit's script values that go through the wrapper (VAULT-004). The
157
+ * Factory's own `package.json` carries exactly these values (a test in `package-json.test.ts`
158
+ * reads the real file and pins it), the wiring of `provision` (`wrapper-wiring.ts`) rewrites a
159
+ * derivative's scripts to them, and every older constant that names one of these scripts
160
+ * (`SETUP_E2E_VARS_SCRIPT_CMD`) reads it from here β€” nobody repeats the literal.
161
+ *
162
+ * `invite:admin` is NOT here: it reads its `--target` environment straight from the vault
163
+ * (VAULT-003), so its value did not change and there is nothing to wrap or to heal.
164
+ */
165
+ const VAULT_WRAPPED_SCRIPT_SPECS = [
166
+ { name: 'dev', inner: 'node scripts/tools/dev.mjs' },
167
+ { name: 'build', inner: 'next build' },
168
+ // `analyze` runs its own `next build` (with ANALYZE on): the build needs the same env as `build`.
169
+ // The wrapper wraps only that first command β€” the bundle report after `&&` reads `.next/`, no secret.
170
+ { name: 'analyze', inner: 'next build && node scripts/tools/analyze-bundle.mjs' },
171
+ { name: 'start', inner: 'next start' },
172
+ { name: 'test:e2e', inner: 'tsx scripts/tools/e2e-runner.ts' },
173
+ { name: 'env:check', inner: 'tsx scripts/tools/env-check.ts' },
174
+ { name: 'db:generate', inner: 'drizzle-kit generate' },
175
+ { name: 'db:migrate', inner: 'drizzle-kit migrate' },
176
+ { name: 'db:push', inner: 'drizzle-kit push' },
177
+ { name: 'db:studio', inner: 'drizzle-kit studio' },
178
+ { name: 'db:harden-enum', inner: 'tsx scripts/tools/harden-enum.ts' },
179
+ { name: 'db:query', inner: 'tsx scripts/tools/db-query.ts' },
180
+ { name: 'db:query:dev', inner: 'tsx scripts/tools/db-query.ts' },
181
+ { name: 'db:query:main', inner: 'tsx scripts/tools/db-query.ts --main', vaultEnv: 'main' },
182
+ {
183
+ name: 'db:seed',
184
+ inner: 'tsx --require dotenv/config src/lib/db/seed.ts dotenv_config_path=.env.local',
185
+ },
186
+ { name: 'setup:e2e', inner: 'tsx scripts/tools/setup-e2e.ts', vaultEnv: 'develop' },
187
+ {
188
+ name: 'setup:e2e:vars',
189
+ inner: 'tsx scripts/tools/setup-e2e.ts --publish-vars',
190
+ vaultEnv: 'develop',
191
+ },
192
+ { name: 'generate:email-logo', inner: 'tsx scripts/tools/generate-email-logo.ts' },
193
+ ];
194
+ /** Put the wrapper in front of `inner`, with its environment flag when it is not `local`. */
195
+ function wrapWithVault(inner, vaultEnv) {
196
+ return vaultEnv
197
+ ? `${WITH_VAULT_SCRIPT_CMD} --vault-env=${vaultEnv} ${inner}`
198
+ : `${WITH_VAULT_SCRIPT_CMD} ${inner}`;
199
+ }
200
+ /** The registry, keyed by script name (see {@link VAULT_WRAPPED_SCRIPT_SPECS}). */
201
+ export const VAULT_WRAPPED_SCRIPTS = Object.freeze(Object.fromEntries(VAULT_WRAPPED_SCRIPT_SPECS.map((spec) => [
202
+ spec.name,
203
+ {
204
+ cmd: wrapWithVault(spec.inner, spec.vaultEnv),
205
+ unwrapped: [spec.inner, ...(spec.historic ?? [])],
206
+ },
207
+ ])));
151
208
  /**
152
209
  * Kit-local convenience script: the readiness sweep (`pnpm preflight`, consumed
153
210
  * by the /deploy gate and /preflight). Unlike the npx-based `factory:*` scripts
@@ -175,11 +232,15 @@ export const INVITE_ADMIN_SCRIPT_FILE = 'scripts/tools/invite-admin.ts';
175
232
  * tsx-based (not npx) and FILE-GATED, same contract as `preflight`: a
176
233
  * core/non-Factory derivative without `scripts/tools/setup-e2e.ts` never gets
177
234
  * 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.
235
+ * once the script lands.
236
+ *
237
+ * Runs through the vault wrapper (VAULT-004): the value comes from `VAULT_WRAPPED_SCRIPTS`,
238
+ * and the unwrapped value the kit shipped before is registered in `OBSOLETE_SCRIPT_CMDS` so
239
+ * `factory update` heals it. Both the insertion and the healing are ALSO gated on the wrapper
240
+ * file (`kitLocalScripts`): a repo without it never gets a script pointing at a missing file.
180
241
  */
181
242
  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';
243
+ export const SETUP_E2E_VARS_SCRIPT_CMD = VAULT_WRAPPED_SCRIPTS[SETUP_E2E_VARS_SCRIPT_NAME].cmd;
183
244
  export const SETUP_E2E_VARS_SCRIPT_FILE = 'scripts/tools/setup-e2e.ts';
184
245
  /**
185
246
  * Kit-local Playwright browser install alias (`pnpm e2e:setup`).
@@ -405,6 +466,9 @@ export const OBSOLETE_SCRIPT_CMDS = {
405
466
  // whole COMMANDS (not just the number) so healing stays exact-match: a derivative that
406
467
  // appended a flag of its own, or picked a ceiling the kit never shipped, is left alone.
407
468
  [VERIFY_SCRIPT_NAME]: VERIFY_OBSOLETE_CMDS,
469
+ // VAULT-004: the value the kit shipped before the vault wrapper. `invite:admin` has no entry:
470
+ // its value did not change in the release that brought the wrapper (VAULT-003).
471
+ [SETUP_E2E_VARS_SCRIPT_NAME]: VAULT_WRAPPED_SCRIPTS[SETUP_E2E_VARS_SCRIPT_NAME].unwrapped,
408
472
  [DEV_NEXT_SCRIPT_NAME]: DEV_NEXT_OBSOLETE_HEAPS_MB.map((mb) => `NODE_OPTIONS='--max-old-space-size=${mb}' next dev --turbopack`),
409
473
  };
410
474
  /**
@@ -10,6 +10,7 @@
10
10
  import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { CLIError } from './cli-error.js';
13
+ import { hasVaultLink } from './provision-state.js';
13
14
  import { generateAuthSecret } from './vercel-env.js';
14
15
  /**
15
16
  * Vars that provision owns and sets PER-ENVIRONMENT (or keeps local-only). The
@@ -35,6 +36,9 @@ export const PROVISION_OWNED = new Set([
35
36
  // (rpID must match the env's host) or the at-rest TOTP crypto.
36
37
  'MFA_ENCRYPTION_KEY',
37
38
  'WEBAUTHN_RP_ID',
39
+ // The origins allowed to run a ceremony for that rpID (Related Origin Requests): provision
40
+ // sets the develop origin so ONE rpID (the production host) serves both deploys.
41
+ 'WEBAUTHN_RELATED_ORIGINS',
38
42
  // LOCAL-ONLY E2E credentials (like DATABASE_URL_MAIN): the E2E runner reads them
39
43
  // from `.env.local` to spin an isolated Neon branch per run. Both local and CI hold
40
44
  // the SAME project-scoped key (see `writeE2eLocalCreds`); they must NEVER reach
@@ -48,6 +52,29 @@ export const PROVISION_OWNED = new Set([
48
52
  'BACKLOG_API_KEY',
49
53
  'BACKLOG_PROJECT_ID',
50
54
  ]);
55
+ /**
56
+ * The LOCAL-ONLY subset of {@link PROVISION_OWNED}: keys that must NEVER land in a vault folder
57
+ * that syncs to a deploy (Vercel production/preview). The vault client's write guard
58
+ * (`vault-api.ts`) reads THIS set β€” the per-key allowed destinations live there; this is only the
59
+ * membership. MUST stay a subset of `PROVISION_OWNED` (pinned by `tests/unit/env-file.test.ts`).
60
+ */
61
+ export const LOCAL_ONLY_KEYS = new Set([
62
+ 'NEON_API_KEY',
63
+ 'NEON_PROJECT_ID',
64
+ 'DATABASE_URL_MAIN',
65
+ 'BACKLOG_API_KEY',
66
+ 'BACKLOG_PROJECT_ID',
67
+ ]);
68
+ /**
69
+ * πŸ”΄ VAULT GUARD (VAULT-002) β€” every writer below refuses to touch `.env.local` in a repo linked
70
+ * to the vault (`vault` block in `.timekast/provision.json`). There the values live in the vault
71
+ * and each writer's caller routes them there; this check is the defense in depth that keeps a
72
+ * forgotten path from re-creating a file Next.js would load on top of the vault's values
73
+ * (`fx-secrets-vault Β§7`). Fail-closed: an unreadable state file throws (see `readVaultLink`).
74
+ */
75
+ function linkedToVault(rootDir) {
76
+ return hasVaultLink(rootDir);
77
+ }
51
78
  /**
52
79
  * Replace the value of `KEY=...` in an env-file body, preserving every other line.
53
80
  * Appends `KEY="value"` when the key is absent (so an older `.env.example` without
@@ -74,8 +101,12 @@ export function setEnvLine(body, key, value) {
74
101
  * β†’ the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
75
102
  * `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
76
103
  * the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
104
+ *
105
+ * Returns `'vault'` (and writes nothing) in a repo linked to the vault β€” see {@link linkedToVault}.
77
106
  */
78
107
  export function writeEnvLocal(rootDir, neon, opts = {}) {
108
+ if (linkedToVault(rootDir))
109
+ return 'vault';
79
110
  const examplePath = join(rootDir, '.env.example');
80
111
  if (!existsSync(examplePath))
81
112
  return 'no-example';
@@ -88,7 +119,7 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
88
119
  body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
89
120
  // `.env.local` holds DB strings + AUTH_SECRET β†’ owner-only (0600) from birth.
90
121
  // The `mode: 0o600` makes the file created 0600 with no world-readable window
91
- // between create and chmod β€” critical on the headless multi-tenant Agent Server
122
+ // between create and chmod β€” critical on a shared headless host
92
123
  // where the default umask would otherwise leave one tenant's secrets briefly
93
124
  // readable by others. `mode` only applies when the file is CREATED, so the
94
125
  // chmod below still covers the `--force` overwrite path (file already exists).
@@ -121,6 +152,8 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
121
152
  export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
122
153
  if (!projectId)
123
154
  return false;
155
+ if (linkedToVault(rootDir))
156
+ return false;
124
157
  const localPath = join(rootDir, '.env.local');
125
158
  if (!existsSync(localPath))
126
159
  return false;
@@ -133,23 +166,26 @@ export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
133
166
  return true;
134
167
  }
135
168
  /**
136
- * Write the org email config into `.env.local` so local sends (`invite:admin`, magic-link,
169
+ * Write the project's email config into `.env.local` so local sends (`invite:admin`, magic-link,
137
170
  * `db:seed`) actually DELIVER instead of falling back to the console. Sets `EMAIL_PROVIDER=resend`
138
- * + `RESEND_API_KEY` (the org key from the rail β€” already on the dev's host; global, not per-env)
139
- * + `EMAIL_FROM` (the fixed org sender). Non-production sends are auto-tagged `[Dev]` by the
140
- * dispatcher (`src/lib/email/index.ts`), so local delivery is safe AND distinguishable.
171
+ * + `RESEND_API_KEY` (the PROJECT's own send-only key that provision minted β€” never the rail's
172
+ * team key, which only mints) + `EMAIL_FROM` (the sender on the domain that key is limited to).
173
+ * Non-production sends are auto-tagged `[Dev]` by the dispatcher (`src/lib/email/index.ts`), so
174
+ * local delivery is safe AND distinguishable.
141
175
  *
142
176
  * This closes the source-inconsistency bug: `invite-admin --target main` writes to the prod DB
143
177
  * but the script always reads email config from `.env.local` β€” which provision otherwise never
144
178
  * populated (it only synced Resend to Vercel), so the invite email silently fell back to console.
145
179
  *
146
- * Returns false (no-op) when the rail key is missing or `.env.local` does not exist yet
147
- * (`writeEnvLocal` creates it first). Safe on an existing file: `setEnvLine` only touches these
180
+ * Returns false (no-op) when there is no project key, the repo lives in the vault, or
181
+ * `.env.local` does not exist yet (`writeEnvLocal` creates it first). Safe on an existing file: `setEnvLine` only touches these
148
182
  * three keys, preserving the rest.
149
183
  */
150
184
  export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
151
185
  if (!resendKey)
152
186
  return false;
187
+ if (linkedToVault(rootDir))
188
+ return false;
153
189
  const localPath = join(rootDir, '.env.local');
154
190
  if (!existsSync(localPath))
155
191
  return false;
@@ -179,6 +215,8 @@ export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
179
215
  export function writeBacklogLocalCreds(rootDir, projectId, apiKey) {
180
216
  if (!apiKey || !projectId)
181
217
  return false;
218
+ if (linkedToVault(rootDir))
219
+ return false;
182
220
  const localPath = join(rootDir, '.env.local');
183
221
  let body = '';
184
222
  if (existsSync(localPath)) {
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { CLIError } from './cli-error.js';
17
17
  import { PROVISION_OWNED, setEnvLine } from './env-file.js';
18
18
  import PUBLIC_ENV_KEYS from './public-env-keys.json' with { type: 'json' };
19
+ import { VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_ROOT_PATH, } from './vault-api.js';
19
20
  /**
20
21
  * The real conditionals `.env.example` can't express. Keyed by the DEPENDENT var
21
22
  * β†’ the driver answer that reveals it. Kept deliberately small β€” adding a new
@@ -130,7 +131,8 @@ export function questionKind(key, defaultValue) {
130
131
  * - the GATED dependents (email creds, OAuth creds), active or commented.
131
132
  * Excludes provision-owned vars and advanced commented vars (rate-limit, VAPID,
132
133
  * Upstash, Sentry, NextAuth aliases) that aren't gated β€” those stay manual +
133
- * `pnpm env:push`.
134
+ * `pnpm env:push` (only without a vault: in a repo with a `vault` block they are set in the
135
+ * vault, and `env:push` does not write).
134
136
  */
135
137
  export function buildQuestionPlan(entries) {
136
138
  const questions = [];
@@ -403,7 +405,13 @@ export function generateVapidKeys() {
403
405
  * these same rows, so the agent-facing output obeys the identical rule.
404
406
  */
405
407
  export function buildEnvReview(questions, body) {
406
- const env = parseEnvBody(body);
408
+ return buildEnvReviewFromEnv(questions, parseEnvBody(body));
409
+ }
410
+ /**
411
+ * {@link buildEnvReview} over an already-resolved `{ KEY: value }` map β€” the vault path
412
+ * (VAULT-002) resolves the `local` environment instead of reading `.env.local`. Same masking rule.
413
+ */
414
+ export function buildEnvReviewFromEnv(questions, env) {
407
415
  return questions.map((q) => {
408
416
  const value = env[q.key] ?? '';
409
417
  const status = value ? 'set' : 'empty';
@@ -416,3 +424,59 @@ export function buildEnvReview(questions, body) {
416
424
  };
417
425
  });
418
426
  }
427
+ const WIZARD_MAIN = { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH };
428
+ const WIZARD_DEVELOP = { env: VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH };
429
+ const WIZARD_LOCAL = { env: VAULT_ENV_LOCAL, path: VAULT_ROOT_PATH };
430
+ /**
431
+ * Write `entries` once in `main:/` (with `mode`) and reference each from `develop:/` and `local`.
432
+ * Returns the key names written. Never prints (the caller narrates, by name).
433
+ */
434
+ export async function writeSharedToVault(target, entries, mode) {
435
+ const keys = Object.keys(entries);
436
+ if (keys.length === 0)
437
+ return [];
438
+ await target.client.writeSecrets(target.projectId, WIZARD_MAIN, entries, mode);
439
+ for (const key of keys) {
440
+ for (const location of [WIZARD_DEVELOP, WIZARD_LOCAL]) {
441
+ await target.client.writeReference(target.projectId, location, key, { ...WIZARD_MAIN, key }, 'upsert');
442
+ }
443
+ }
444
+ return keys;
445
+ }
446
+ /**
447
+ * The vault counterpart of {@link applyWizardAnswers}: the answers go to the vault (one copy in
448
+ * `main:/`, referenced from `develop:/` and `local`), never to a file. Provision-owned keys are
449
+ * skipped exactly as on the file path; EMPTY answers are not stored (the vault keeps answered
450
+ * values) and come back in `empty` so the caller can name them.
451
+ */
452
+ export async function applyWizardAnswersToVault(target, answers) {
453
+ const entries = {};
454
+ const empty = [];
455
+ for (const [key, value] of Object.entries(answers)) {
456
+ if (PROVISION_OWNED.has(key))
457
+ continue; // provision owns these per-env
458
+ if (value === '') {
459
+ empty.push(key);
460
+ continue;
461
+ }
462
+ entries[key] = value;
463
+ }
464
+ const written = await writeSharedToVault(target, entries, 'upsert');
465
+ return { written, empty };
466
+ }
467
+ /**
468
+ * The `local` environment as the app would see it: its own values, the `develop:/ci` import and
469
+ * the references resolved (own values win over imported ones β€” ADR-005, check (c)). For the
470
+ * completeness warnings and the masked review; values never leave this process unmasked.
471
+ */
472
+ export async function readVaultLocalEnv(target) {
473
+ const secrets = await target.client.readSecrets(target.projectId, WIZARD_LOCAL, {
474
+ expandReferences: true,
475
+ includeImports: true,
476
+ });
477
+ return Object.fromEntries(secrets);
478
+ }
479
+ /** Read `main:/` (unexpanded) β€” e.g. to seed `VAPID_SUBJECT` from `EMAIL_FROM`. */
480
+ export async function readVaultMain(target) {
481
+ return Object.fromEntries(await target.client.readSecrets(target.projectId, WIZARD_MAIN));
482
+ }
@@ -3,8 +3,9 @@
3
3
  * signal (GAP-001, fixed by `project/planning/decisions/ADR-003-factory-signal.md`).
4
4
  *
5
5
  * WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
6
- * derivative was born with a schema and no migrations while its `vercel-build`
7
- * (`pnpm build && pnpm db:migrate`) migrated an empty database on the first deploy.
6
+ * derivative was born with a schema and no migrations while its deploy migration β€”
7
+ * `vercel-build` (`pnpm build && pnpm db:migrate`) on Vercel, the service's pre-deploy command
8
+ * (`pnpm db:migrate`) on Railway β€” migrated an empty database on the first deploy.
8
9
  * The provision step that closes that gap has to decide, from disk alone, whether
9
10
  * generating and committing a `0000` is the right thing to do β€” and the cost of a
10
11
  * wrong "yes" is asymmetric.
@@ -12,7 +12,8 @@
12
12
  * Every call surfaces a typed {@link NeonApiError} on a non-2xx response that
13
13
  * carries the HTTP status, so the caller (`provision.ts`) can map the Neon edge
14
14
  * cases the issue calls out (Β§8) to actionable, es-MX `CLIError` messages:
15
- * - 401 β†’ `NEON_API_KEY` invalid/rotated β†’ re-import the rail (`factory secrets <archivo>`)
15
+ * - 401 β†’ `NEON_API_KEY` invalid/rotated β†’ check the vault session, then a rotation
16
+ * in `rail-timekast` (`fx-secrets-vault Β§9`)
16
17
  * - 402 / 429 β†’ quota / billing β†’ billing message
17
18
  * - 409 β†’ project name already exists β†’ suggest `--force` or another name
18
19
  *
@@ -134,7 +135,7 @@ export async function listNeonOrgs(apiKey, deps = {}) {
134
135
  * 3. Neither: a key that sees exactly ONE org uses it; a key that sees several
135
136
  * THROWS, naming them. Fail-closed on purpose β€” guessing here would create a
136
137
  * client's database in the wrong organization, and there is no prompt to fall
137
- * back on (headless Agent Server runs have nobody to answer it).
138
+ * back on (headless runs have nobody to answer it).
138
139
  *
139
140
  * `selector` matches the `org-...` id verbatim, or the name/handle
140
141
  * case-insensitively, so both `--neon-org TimeKast` and `--neon-org org-abc123`
@@ -148,7 +149,7 @@ export async function listNeonOrgs(apiKey, deps = {}) {
148
149
  export async function resolveNeonOrg(apiKey, selector, deps = {}) {
149
150
  const orgs = await listNeonOrgs(apiKey, deps);
150
151
  if (orgs.length === 0) {
151
- throw new Error('La `NEON_API_KEY` del archivo de secretos no ve ninguna organizaciΓ³n de Neon.\n' +
152
+ throw new Error('La `NEON_API_KEY` del rail no ve ninguna organizaciΓ³n de Neon.\n' +
152
153
  ' Debe ser la key PERSONAL de la cuenta de servicio, y esa cuenta tiene que ser ' +
153
154
  'Admin de la organizaciΓ³n (solo un Admin puede acuΓ±ar API keys project-scoped).');
154
155
  }
@@ -167,8 +168,8 @@ export async function resolveNeonOrg(apiKey, selector, deps = {}) {
167
168
  return orgs[0];
168
169
  throw new Error(`La key ve ${orgs.length} organizaciones de Neon y no dijiste en cuΓ‘l crear el proyecto.\n` +
169
170
  ` Visibles: ${orgs.map((o) => `${o.name} (${o.id})`).join(', ')}.\n` +
170
- ' Elige una con `--neon-org "<nombre>"`, o fija el default del rail con ' +
171
- '`NEON_ORG=<nombre>` en `~/.claude/.env`.');
171
+ ' Elige una con `--neon-org "<nombre>"`, o pide a un admin de la bΓ³veda que fije el ' +
172
+ 'default cargando `NEON_ORG` (el nombre de la organizaciΓ³n) en `rail-timekast`.');
172
173
  }
173
174
  /**
174
175
  * The organization that owns a project, asked BY ID β€” `GET /projects/{id}`.
@@ -482,7 +483,7 @@ export async function createProjectApiKey(apiKey, orgId, projectId, keyName, dep
482
483
  // The value can't be reused, so delete the stale key(s) by name and re-mint β€” a
483
484
  // resume must converge, not 409. (Only the secret-pending path re-enters here;
484
485
  // the happy path sets apiKeySecretDone and short-circuits before minting again.)
485
- if (response.status === 409) {
486
+ if (response.status === 409 && deps.onConflict !== 'fail') {
486
487
  await deleteNeonApiKeysByName(apiKey, orgId, keyName, { fetchImpl });
487
488
  response = await mint();
488
489
  }
@@ -495,15 +496,147 @@ export async function createProjectApiKey(apiKey, orgId, projectId, keyName, dep
495
496
  }
496
497
  return data.key;
497
498
  }
499
+ /**
500
+ * The suffix of a ROTATED key name: a compact UTC timestamp `YYYYMMDDHHmmss`. Neon rejects a
501
+ * repeated `key_name` in the org with `409 already exists`, and a rotation mints the new key while
502
+ * the old one is still alive β€” so each rotation gets its own name, `<base>-<suffix>`.
503
+ */
504
+ export const NEON_ROTATION_SUFFIX_RE = /^\d{14}$/;
505
+ /** `<base>-YYYYMMDDHHmmss` for the rotation minted at `at` (UTC). */
506
+ export function rotatedNeonKeyName(base, at) {
507
+ return `${base}-${at.toISOString().replace(/\D/g, '').slice(0, 14)}`;
508
+ }
509
+ /**
510
+ * Whether `name` belongs to the key FAMILY of `base`: the base name itself, or `<base>-<suffix>`
511
+ * with the rotation suffix EXACTLY ({@link NEON_ROTATION_SUFFIX_RE}). A bare `startsWith` would
512
+ * catch another project's keys β€” the base of a project called `foo-e2e-x` is `foo-e2e-x-e2e`,
513
+ * which starts with `foo-e2e-` β€” so the rest after the separator must be the timestamp and
514
+ * nothing else.
515
+ */
516
+ export function isNeonKeyFamily(name, base) {
517
+ if (name === base)
518
+ return true;
519
+ if (!name.startsWith(`${base}-`))
520
+ return false;
521
+ return NEON_ROTATION_SUFFIX_RE.test(name.slice(base.length + 1));
522
+ }
523
+ /**
524
+ * The CURRENT key of a family β€” the newest one β€” which a settle must never revoke. By `createdAt`
525
+ * when EVERY key of the family carries a parseable one; otherwise by the rotation suffix of the
526
+ * name (`<base>-YYYYMMDDHHmmss`), where the bare base name counts as the oldest. Ties break by id.
527
+ * `null` when the family is empty.
528
+ */
529
+ export function newestNeonFamilyKey(keys, base) {
530
+ const family = keys.filter((key) => isNeonKeyFamily(key.name, base));
531
+ if (family.length === 0)
532
+ return null;
533
+ const byDate = family.every((key) => key.createdAt && !Number.isNaN(Date.parse(key.createdAt)));
534
+ const rank = (key) => byDate
535
+ ? String(Date.parse(key.createdAt)).padStart(16, '0')
536
+ : key.name === base
537
+ ? ''
538
+ : key.name.slice(base.length + 1);
539
+ return family.reduce((best, key) => {
540
+ const a = rank(key);
541
+ const b = rank(best);
542
+ return a > b || (a === b && key.id > best.id) ? key : best;
543
+ });
544
+ }
545
+ /**
546
+ * The org API keys could not be LISTED (non-2xx, network, unexpected body). Distinct from an empty
547
+ * list on purpose: "I could not list" must never read as "there are none" β€” a rotation that took
548
+ * the failure for an empty list would mint a new key and leave the old one alive and unrecorded.
549
+ */
550
+ export class NeonApiKeyListError extends Error {
551
+ /** The HTTP status when Neon answered; `null` for a network failure or an unreadable body. */
552
+ status;
553
+ constructor(orgId, status, detail) {
554
+ super(`No pude listar las API keys de la organizaciΓ³n de Neon \`${orgId}\` ` +
555
+ `(${status === null ? detail : `Neon respondiΓ³ ${status}`}).`);
556
+ this.name = 'NeonApiKeyListError';
557
+ this.status = status;
558
+ Object.setPrototypeOf(this, new.target.prototype);
559
+ }
560
+ }
561
+ /**
562
+ * List the org's API keys WITH their id β€” `GET /organizations/{org_id}/api_keys`. Read-only. The
563
+ * `--remint` rotation reads this BEFORE minting, so the keys it will revoke are known by id and
564
+ * the key it is about to mint (same name) is never among them.
565
+ *
566
+ * @returns every key that carries both an id and a name; `[]` when the org has none.
567
+ * @throws {NeonApiKeyListError} on a non-2xx answer, a network failure or a body of another shape.
568
+ */
569
+ export async function listNeonApiKeys(apiKey, orgId, deps = {}) {
570
+ const fetchImpl = deps.fetchImpl ?? fetch;
571
+ let response;
572
+ try {
573
+ response = await fetchImpl(`${NEON_API_BASE}/organizations/${encodeURIComponent(orgId)}/api_keys`, {
574
+ method: 'GET',
575
+ headers: neonHeaders(apiKey),
576
+ signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
577
+ });
578
+ }
579
+ catch {
580
+ throw new NeonApiKeyListError(orgId, null, 'sin respuesta de Neon');
581
+ }
582
+ if (!response.ok)
583
+ throw new NeonApiKeyListError(orgId, response.status, '');
584
+ let data;
585
+ try {
586
+ data = await response.json();
587
+ }
588
+ catch {
589
+ throw new NeonApiKeyListError(orgId, null, 'respuesta ilegible');
590
+ }
591
+ // Neon returns a bare array; a `{ api_keys: [...] }` wrapper is tolerated too.
592
+ const list = Array.isArray(data)
593
+ ? data
594
+ : data !== null &&
595
+ typeof data === 'object' &&
596
+ Array.isArray(data.api_keys)
597
+ ? data.api_keys
598
+ : null;
599
+ if (list === null)
600
+ throw new NeonApiKeyListError(orgId, null, 'respuesta con otra forma');
601
+ return list
602
+ .filter((k) => k !== null && k?.id != null && typeof k?.name === 'string')
603
+ .map((k) => ({
604
+ id: String(k.id),
605
+ name: String(k.name),
606
+ ...(typeof k.created_at === 'string' ? { createdAt: k.created_at } : {}),
607
+ }));
608
+ }
609
+ /**
610
+ * Revoke ONE org API key by id β€” `DELETE /organizations/{org_id}/api_keys/{id}`. A 404 is "it no
611
+ * longer existed" and counts as revoked. Never throws: the answer is whether Neon CONFIRMED it, so
612
+ * a caller keeps an unconfirmed id pending instead of forgetting it.
613
+ */
614
+ export async function revokeNeonApiKey(apiKey, orgId, keyId, deps = {}) {
615
+ const fetchImpl = deps.fetchImpl ?? fetch;
616
+ try {
617
+ const response = await fetchImpl(`${NEON_API_BASE}/organizations/${encodeURIComponent(orgId)}/api_keys/${encodeURIComponent(keyId)}`, {
618
+ method: 'DELETE',
619
+ headers: neonHeaders(apiKey),
620
+ signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
621
+ });
622
+ return response.ok || response.status === 404;
623
+ }
624
+ catch {
625
+ return false;
626
+ }
627
+ }
498
628
  /**
499
629
  * Best-effort delete of every ORG API key whose name matches `keyName`
500
- * (`GET /organizations/{org_id}/api_keys` β†’ `DELETE .../api_keys/{id}`). Three
630
+ * (`GET /organizations/{org_id}/api_keys` β†’ `DELETE .../api_keys/{id}`). Two
501
631
  * callers:
502
- * - the idempotent re-mint (clear a stale key before re-minting on resume),
503
- * - `--remint` (revoke the live key before issuing its replacement), and
632
+ * - the idempotent re-mint of a NON-rotation run (clear a stale key that was never
633
+ * delivered before re-minting on resume β€” see {@link createProjectApiKey}), and
504
634
  * - the ephemeral `--destroy` nuke (delete the project-scoped key it minted so
505
635
  * the org does not accumulate dead keys across provisions/tests).
506
636
  *
637
+ * `--remint` does NOT use it: a rotation lists ids first ({@link listNeonApiKeys}) and revokes
638
+ * them by id ({@link revokeNeonApiKey}) only once the new key was delivered.
639
+ *
507
640
  * Org-scoped for the same reason the mint is (see {@link createProjectApiKey}):
508
641
  * the keys provision issues are owned by the org, so the personal `/api_keys`
509
642
  * listing never saw them β€” and with a service-account key it 404s outright.
@@ -528,7 +661,9 @@ export async function deleteNeonApiKeysByName(apiKey, orgId, keyName, deps = {})
528
661
  // Neon returns a bare array; tolerate a `{ api_keys: [...] }` wrapper too.
529
662
  const list = Array.isArray(data) ? data : (data?.api_keys ?? []);
530
663
  const ids = list
531
- .filter((k) => k?.name === keyName && k?.id != null)
664
+ .filter((k) => k?.id != null &&
665
+ typeof k?.name === 'string' &&
666
+ (deps.family ? isNeonKeyFamily(k.name, keyName) : k.name === keyName))
532
667
  .map((k) => String(k.id));
533
668
  for (const id of ids) {
534
669
  const del = await fetchImpl(`${keysBase}/${encodeURIComponent(id)}`, {
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_SETUP_SCRIPT_CMD, E2E_SETUP_SCRIPT_FILE, E2E_SETUP_SCRIPT_NAME, EVIDENCE_VISUAL_SCRIPT_CMD, EVIDENCE_VISUAL_SCRIPT_FILE, EVIDENCE_VISUAL_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SETUP_E2E_VARS_SCRIPT_CMD, SETUP_E2E_VARS_SCRIPT_FILE, SETUP_E2E_VARS_SCRIPT_NAME, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, VERCEL_BUILD_OBSOLETE_CMDS, VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_SCRIPT_NAME, } from './constants.js';
11
+ import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_SETUP_SCRIPT_CMD, E2E_SETUP_SCRIPT_FILE, E2E_SETUP_SCRIPT_NAME, EVIDENCE_VISUAL_SCRIPT_CMD, EVIDENCE_VISUAL_SCRIPT_FILE, EVIDENCE_VISUAL_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SETUP_E2E_VARS_SCRIPT_CMD, SETUP_E2E_VARS_SCRIPT_FILE, SETUP_E2E_VARS_SCRIPT_NAME, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, VERCEL_BUILD_OBSOLETE_CMDS, VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_SCRIPT_NAME, WITH_VAULT_SCRIPT_FILE, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -35,7 +35,7 @@ export function detectProfile(rootDir) {
35
35
  }
36
36
  }
37
37
  /** Detect the indentation used by an existing JSON document (defaults to 2 spaces). */
38
- function detectIndent(raw) {
38
+ export function detectIndent(raw) {
39
39
  const match = raw.match(/^(\s+)"/m);
40
40
  if (!match)
41
41
  return 2;
@@ -57,6 +57,22 @@ export function parsePackageJson(raw) {
57
57
  throw new CLIError('El `package.json` desempacado no es JSON vΓ‘lido; no se puede continuar de forma segura.');
58
58
  }
59
59
  }
60
+ /**
61
+ * THE one comparison every heal of a kit-owned script uses (`ensureFactoryScripts`,
62
+ * `healVercelBuildOrder`, the vault wiring of `wrapper-wiring.ts`): exact match, byte for byte,
63
+ * against the current command and the registered values the kit shipped. A variant nobody
64
+ * registered β€” a flag appended, different spacing, a step chained in front β€” is `own`: erring
65
+ * toward "leave it alone" is the safe side of this mistake.
66
+ */
67
+ export function classifyKitScript(existing, current, shipped) {
68
+ if (existing === undefined)
69
+ return 'absent';
70
+ if (existing === current)
71
+ return 'current';
72
+ if (shipped.includes(existing))
73
+ return 'shipped';
74
+ return 'own';
75
+ }
60
76
  /**
61
77
  * Pure core of the script insertion: mutate `pkg.scripts` in place to ensure ALL
62
78
  * `factory:*` convenience scripts exist (`update` / `doctor` / `status`), adding
@@ -79,18 +95,19 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
79
95
  for (const [name, cmd] of Object.entries({ ...FACTORY_SCRIPTS, ...extraScripts })) {
80
96
  const existing = scripts[name];
81
97
  let result;
82
- if (existing === undefined) {
98
+ // The value is a KNOWN-OBSOLETE kit command (e.g. the old `node …mjs` that the .ts
99
+ // migration retired) β†’ heal it to the current command. A genuinely dev-divergent value
100
+ // is a `conflict` and is left intact.
101
+ const kind = classifyKitScript(existing, cmd, OBSOLETE_SCRIPT_CMDS[name] ?? []);
102
+ if (kind === 'absent') {
83
103
  scripts[name] = cmd;
84
104
  changed = true;
85
105
  result = { action: 'added' };
86
106
  }
87
- else if (existing === cmd) {
107
+ else if (kind === 'current') {
88
108
  result = { action: 'already-correct' };
89
109
  }
90
- else if ((OBSOLETE_SCRIPT_CMDS[name] ?? []).includes(existing)) {
91
- // The value is a KNOWN-OBSOLETE kit command (e.g. the old `node …mjs` that
92
- // the .ts migration retired) β€” heal it to the current command. A genuinely
93
- // dev-divergent value falls through to `conflict` and is left intact.
110
+ else if (kind === 'shipped') {
94
111
  scripts[name] = cmd;
95
112
  changed = true;
96
113
  result = { action: 'healed' };
@@ -112,7 +129,8 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
112
129
  *
113
130
  * - `preflight` β€” when its sweep script ships (full brain).
114
131
  * - `setup:e2e:vars` β€” publishes the CI E2E auth env vars (Fase 1 of the
115
- * `e2e-ci-auth-env` retrofit), when `scripts/tools/setup-e2e.ts` ships (full brain).
132
+ * `e2e-ci-auth-env` retrofit), when `scripts/tools/setup-e2e.ts` ships (full brain) AND the
133
+ * vault wrapper `scripts/tools/with-vault.mjs` does too β€” its value runs through it (VAULT-004).
116
134
  * - `evidence:visual` β€” the visual-evidence harness (`fx-visual-evidence`), gated on the
117
135
  * harness itself (`scripts/tools/visual-evidence/`) rather than on the runner: it is what
118
136
  * this alias needs, and a `core` derivative receives neither. Forwards to `test:e2e`, so the
@@ -152,7 +170,10 @@ export function kitLocalScripts(rootDir) {
152
170
  scripts[PREFLIGHT_SCRIPT_NAME] = PREFLIGHT_SCRIPT_CMD;
153
171
  if (has(INVITE_ADMIN_SCRIPT_FILE))
154
172
  scripts[INVITE_ADMIN_SCRIPT_NAME] = INVITE_ADMIN_SCRIPT_CMD;
155
- if (has(SETUP_E2E_VARS_SCRIPT_FILE))
173
+ // Its value runs through the vault wrapper, so it is ALSO gated on the wrapper: without it
174
+ // the alias is neither inserted nor healed (a script pointing at a missing file is worse than
175
+ // an old one that still runs).
176
+ if (has(SETUP_E2E_VARS_SCRIPT_FILE) && has(WITH_VAULT_SCRIPT_FILE))
156
177
  scripts[SETUP_E2E_VARS_SCRIPT_NAME] = SETUP_E2E_VARS_SCRIPT_CMD;
157
178
  if (has(E2E_SETUP_SCRIPT_FILE))
158
179
  scripts[E2E_SETUP_SCRIPT_NAME] = E2E_SETUP_SCRIPT_CMD;
@@ -401,15 +422,15 @@ export function healVercelBuildOrder(pkgPath) {
401
422
  const scripts = pkg.scripts;
402
423
  if (typeof scripts !== 'object' || scripts === null || Array.isArray(scripts))
403
424
  return 'absent';
404
- const current = scripts[VERCEL_BUILD_SCRIPT_NAME];
405
- if (current === undefined)
406
- return 'absent';
407
- if (current === VERCEL_BUILD_SCRIPT_CMD)
408
- return 'already-correct';
409
425
  // Exact match against what the kit actually shipped, byte for byte. A dev who appended a flag
410
426
  // β€” or wrote the same two halves with different spacing β€” keeps their command through every
411
427
  // future update: an unlisted variant erring toward "leave it alone" is the safe side here.
412
- if (!VERCEL_BUILD_OBSOLETE_CMDS.includes(current))
428
+ const kind = classifyKitScript(scripts[VERCEL_BUILD_SCRIPT_NAME], VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_OBSOLETE_CMDS);
429
+ if (kind === 'absent')
430
+ return 'absent';
431
+ if (kind === 'current')
432
+ return 'already-correct';
433
+ if (kind === 'own')
413
434
  return 'preserved';
414
435
  scripts[VERCEL_BUILD_SCRIPT_NAME] = VERCEL_BUILD_SCRIPT_CMD;
415
436
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');