@timekast/factory 1.28.0 → 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 +5820 -768
  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 +170 -35
  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
@@ -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');
@@ -15,12 +15,11 @@
15
15
  * --launcher` can render every step with ✅ / ❌ across all platforms (the
16
16
  * cross-platform equivalent of the macOS launcher's `checkPrereqs`).
17
17
  */
18
- import { readFileSync } from 'node:fs';
19
- import { homedir } from 'node:os';
20
- import path from 'node:path';
21
18
  import { execa } from 'execa';
22
19
  import { PreflightError } from './cli-error.js';
23
20
  import { FACTORY_ORG } from './constants.js';
21
+ import { RailError, createRailResolver } from './rail.js';
22
+ import { VAULT_CONFIG } from './vault-config.js';
24
23
  /** Minimum Node major version the kit requires (mirrors `engines.node` + the launcher AppleScript). */
25
24
  export const MIN_NODE_MAJOR = 22;
26
25
  /** Timeout (ms) for the GitHub API calls in the org-membership check — a slow / dead network must not hang. */
@@ -136,9 +135,10 @@ export async function runPreflight() {
136
135
  await assertOrgMembership();
137
136
  }
138
137
  /**
139
- * The methodology-rail keys the launcher expects in `~/.claude/.env` (imported
140
- * via `factory secrets <file>`). Provisioning tokens + their team/org ids + the
141
- * proposal tooling keys. Single source so the check + tests agree.
138
+ * The methodology-rail keys the launcher expects in the vault rail (`rail-timekast`,
139
+ * read with the person's session — `fx-secrets-vault §3`). Provisioning tokens +
140
+ * their team/org ids + the proposal tooling keys. Single source so the check + tests
141
+ * agree.
142
142
  *
143
143
  * `NEON_ORG` is deliberately NOT here: it only names a default organization, and
144
144
  * its absence is a valid configuration (a key that reaches exactly one org needs
@@ -153,14 +153,26 @@ export const REQUIRED_SECRET_KEYS = [
153
153
  'SHORTIO_API_KEY',
154
154
  'GAMMA_API_KEY',
155
155
  'VERCEL_TEAM_ID',
156
+ // RWAY-003: `provision` asks Railway for the connected repo to discover a repo's destination
157
+ // (and to refuse a `--target=vercel` over a repo that lives in Railway) — in Vercel repos too.
158
+ 'RAILWAY_TOKEN',
156
159
  ];
160
+ /**
161
+ * Time budget (ms) of the launcher's vault read. Deliberately SHORTER than the
162
+ * resolver's default (`RAIL_READ_TIMEOUT_MS`, 30 s): the desktop launcher waits 30 s
163
+ * IN TOTAL for the whole `doctor --launcher --json` document
164
+ * (`desktop/renderer/panels/home.tsx`), and `npx @latest` already eats part of that.
165
+ * A vault that does not answer must end as a `warn` "could not be determined" inside
166
+ * the document — not as a launcher-side "connection error" because the JSON never came.
167
+ */
168
+ export const LAUNCHER_RAIL_TIMEOUT_MS = 10_000;
157
169
  /** The ordered launcher labels (single source so render + tests agree). */
158
170
  const LAUNCHER_LABELS = {
159
171
  node: `Node ≥ ${MIN_NODE_MAJOR}`,
160
172
  gh: 'GitHub CLI (gh) instalado',
161
173
  auth: 'Sesión de GitHub activa',
162
174
  org: `Membresía del org ${FACTORY_ORG}`,
163
- secrets: 'Secrets del org en el rail',
175
+ secrets: `Secrets del org en la bóveda (${VAULT_CONFIG.rail.name})`,
164
176
  };
165
177
  /** A confirmation message for a passing check (the `ok` branch). */
166
178
  const OK_MESSAGES = {
@@ -168,43 +180,81 @@ const OK_MESSAGES = {
168
180
  gh: 'GitHub CLI (gh) está instalado.',
169
181
  auth: 'Tu sesión de GitHub CLI está activa.',
170
182
  org: `Eres miembro del org ${FACTORY_ORG}.`,
171
- secrets: `Las ${REQUIRED_SECRET_KEYS.length} keys del rail están presentes.`,
183
+ secrets: `Las ${REQUIRED_SECRET_KEYS.length} claves requeridas están en ` +
184
+ `\`${VAULT_CONFIG.rail.name}\` y tu sesión de la bóveda está activa.`,
172
185
  };
186
+ /** The vault state skeleton — coordinates always present, everything else undetermined. */
187
+ function vaultState(overrides) {
188
+ return {
189
+ domain: VAULT_CONFIG.domain,
190
+ project: VAULT_CONFIG.rail.name,
191
+ session: null,
192
+ access: null,
193
+ missingKeys: null,
194
+ unknownReason: null,
195
+ ...overrides,
196
+ };
197
+ }
198
+ /** Map a rail failure (other than a missing key) to the published vault state. */
199
+ function vaultStateForFailure(kind) {
200
+ switch (kind) {
201
+ case 'no-session':
202
+ return vaultState({ session: false });
203
+ case 'no-access':
204
+ return vaultState({ session: true, access: false });
205
+ case 'not-installed':
206
+ case 'timeout':
207
+ case 'unrecognized':
208
+ return vaultState({ unknownReason: kind });
209
+ case 'key-missing':
210
+ // Never reaches here (`getOptional` turns it into `null`), kept for exhaustiveness.
211
+ return vaultState({ session: true, access: true });
212
+ }
213
+ }
173
214
  /**
174
- * Read the secret KEY NAMES present in the rail (`~/.claude/.env`), never values.
175
- * A missing/unreadable rail yields an empty set (→ the check warns with all keys
176
- * missing). Tests inject `railContent` to avoid touching the real home dir.
215
+ * Build the secrets check against the vault rail: session → access → presence of
216
+ * each {@link REQUIRED_SECRET_KEYS}, in that order (the resolver reads the rail
217
+ * project ONCE; a session/access failure surfaces on the first key). NAMES ONLY —
218
+ * each value is dropped the moment the resolver hands it over.
219
+ *
220
+ * Never `fail`, never throws: every outcome is `ok` or `warn`. The message on a
221
+ * failure is the resolver's own (built from the classification, never from what
222
+ * the vault printed), so it names the login command with the domain, the rail
223
+ * project, or how to install `infisical`.
177
224
  */
178
- function readRailKeyNames(railContent) {
179
- let content = railContent;
180
- if (content === undefined) {
181
- try {
182
- content = readFileSync(path.join(homedir(), '.claude', '.env'), 'utf8');
183
- }
184
- catch {
185
- content = '';
225
+ async function secretsCheck(rail) {
226
+ const base = { id: 'secrets', label: LAUNCHER_LABELS.secrets };
227
+ const missing = [];
228
+ try {
229
+ for (const key of REQUIRED_SECRET_KEYS) {
230
+ // Presence only: the value is compared to null and discarded right here.
231
+ if ((await rail.getOptional(key)) === null)
232
+ missing.push(key);
186
233
  }
187
234
  }
188
- const names = new Set();
189
- for (const line of content.split(/\r?\n/)) {
190
- const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
191
- if (match)
192
- names.add(match[1]);
235
+ catch (error) {
236
+ const kind = error instanceof RailError ? error.kind : 'unrecognized';
237
+ return {
238
+ ...base,
239
+ status: 'warn',
240
+ // A non-RailError is not ours to echo (its text could carry anything).
241
+ message: error instanceof RailError
242
+ ? error.message
243
+ : 'No se pudo determinar el estado de la bóveda (error interno al leerla).',
244
+ vault: vaultStateForFailure(kind),
245
+ };
246
+ }
247
+ const vault = vaultState({ session: true, access: true, missingKeys: missing });
248
+ if (missing.length === 0) {
249
+ return { ...base, status: 'ok', message: OK_MESSAGES.secrets, vault };
193
250
  }
194
- return names;
195
- }
196
- /** Build the secrets check (`ok` if all required keys present, else `warn`). */
197
- function secretsCheck(railContent) {
198
- const present = readRailKeyNames(railContent);
199
- const missing = REQUIRED_SECRET_KEYS.filter((key) => !present.has(key));
200
251
  return {
201
- id: 'secrets',
202
- label: LAUNCHER_LABELS.secrets,
203
- status: missing.length === 0 ? 'ok' : 'warn',
204
- message: missing.length === 0
205
- ? OK_MESSAGES.secrets
206
- : `Faltan ${missing.length} de ${REQUIRED_SECRET_KEYS.length} secrets en el rail: ` +
207
- `${missing.join(', ')}. Impórtalos desde el card Secrets.`,
252
+ ...base,
253
+ status: 'warn',
254
+ message: `Faltan ${missing.length} de ${REQUIRED_SECRET_KEYS.length} claves en ` +
255
+ `\`${VAULT_CONFIG.rail.name}\`: ${missing.join(', ')}.\n` +
256
+ 'Pide a un admin de la bóveda que las cargue.',
257
+ vault,
208
258
  };
209
259
  }
210
260
  /**
@@ -222,6 +272,10 @@ function secretsCheck(railContent) {
222
272
  * depends on the previous — no auth without gh, no org without auth) but still
223
273
  * RETURNS a full ordered report, marking the checks after the failure as
224
274
  * `skipped`. The caller decides exit code (0 = all ok, 1 = any fail).
275
+ *
276
+ * Then, always and independently of 1-4, the `secrets` check (5): session → access
277
+ * → key presence in the vault rail. It is `ok` or `warn`, never `fail`, so it never
278
+ * trips the exit code.
225
279
  */
226
280
  export async function runLauncherPreflight(deps = {}) {
227
281
  const ghInstalled = deps.ghInstalled ?? assertGhInstalled;
@@ -274,6 +328,6 @@ export async function runLauncherPreflight(deps = {}) {
274
328
  }
275
329
  // Secrets check — independent of the gh/auth/org chain (runs even if those
276
330
  // failed) and non-blocking: resolves to `ok` or `warn`, never `fail`/`skipped`.
277
- results.push(secretsCheck(deps.railContent));
331
+ results.push(await secretsCheck(deps.rail ?? createRailResolver({ timeoutMs: LAUNCHER_RAIL_TIMEOUT_MS })));
278
332
  return results;
279
333
  }