@timekast/factory 1.28.1 → 2.0.1

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 +154 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5815 -763
  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 +1106 -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
@@ -17,7 +17,7 @@ export const realGit = async (args, opts) => {
17
17
  };
18
18
  /**
19
19
  * Resolve a token with push access to the hub. Dual auth (plan B-1):
20
- * - `TIMEKAST_PUBLISH_TOKEN` in env → headless (Agent Server, infra TimeKast).
20
+ * - `TIMEKAST_PUBLISH_TOKEN` in env → headless (no TTY).
21
21
  * - else → dev local: full preflight (gh + org member) then `gh auth token`.
22
22
  * Never reads a token from a derived repo.
23
23
  */
@@ -0,0 +1,400 @@
1
+ /**
2
+ * The single rail resolver (RAIL-001) — the ONLY place where the CLI asks the org secrets vault
3
+ * for a token of a rail project (`rail-timekast` by default, or a `rail-<client>` the caller
4
+ * names). Contract: `fx-secrets-vault §3` + ADR-006.
5
+ *
6
+ * HOW IT READS: one `infisical export --format=json` per rail project per process, with the
7
+ * PERSON's session (`infisical login`). The parsed map lives only in this process's memory:
8
+ * nothing is written to disk, nothing is copied into `process.env`, nothing survives the run.
9
+ *
10
+ * FAILURES ARE TYPED, never parsed by the caller (`RailError.kind`):
11
+ * - `no-session` — no session or an expired one → names `infisical login --domain=…`
12
+ * - `no-access` — live session, no access to the project — or the configured project id
13
+ * does not exist (infisical answers both alike) → names the project + its id
14
+ * - `key-missing` — the export worked and the key is not there → names the key.
15
+ * The ONLY failure a caller may treat as optional (`getOptional`).
16
+ * - `unrecognized` — anything infisical said that we do not recognise (unknown stderr, invalid
17
+ * JSON, odd exit). NEVER mapped to `key-missing` nor to `no-access`: guessing
18
+ * there sends the person to the wrong fix, or silently skips a required token.
19
+ * - `not-installed`— the `infisical` binary is not on PATH (ENOENT) — distinct from no-session,
20
+ * or the person is sent to a `login` command that does not exist.
21
+ * - `timeout` — the vault did not answer in time.
22
+ *
23
+ * 🔴 NO-ECHO — why every error here is built from the classification ONLY. The child's stdout IS
24
+ * the JSON with every token of the rail. execa 9 folds the child's stdout into `ExecaError.message`,
25
+ * `JSON.parse` quotes a snippet of its input in its `SyntaxError.message`, and `cli/src/index.ts`
26
+ * prints `console.error(error)` for any non-`CLIError`. So: `reject: false` (no ExecaError is ever
27
+ * built), `verbose: 'none'` (execa defaults to `'full'` under `NODE_DEBUG=execa`, which echoes the
28
+ * child's stdout), every error is a `RailError` built from exit code / timedOut / ENOENT, no
29
+ * `cause`, and a total catch converts anything else to `unrecognized` without its message.
30
+ *
31
+ * 🔴 stdin is ALWAYS closed (`stdin: 'ignore'`). Without a session, `infisical` does not fail
32
+ * cleanly: it starts an interactive login (a `login --silent` child), writes an ANSI prompt to
33
+ * STDOUT and blocks reading stdin until EOF. That child does NOT receive our `--domain`: it
34
+ * resolves the domain from `INFISICAL_DOMAIN`, then from a `.infisical.json` found walking up
35
+ * from the cwd, then its default — and it may open a BROWSER at that domain before it ever
36
+ * reads stdin. A `.infisical.json` planted in a repo would open an attacker's login page. So the
37
+ * child's environment ALWAYS pins `INFISICAL_DOMAIN` to the vault's domain (the first source it
38
+ * consults). Only after that does the closed stdin end the prompt; the timeout is the last belt.
39
+ *
40
+ * A rail token exported in the person's shell is IGNORED (the vault wins) and named on STDERR —
41
+ * never stdout (some commands' stdout is a machine contract) and never with its value.
42
+ *
43
+ * Tests: the runner is an injectable seam. Under Vitest a setup file (`cli/tests/setup/`)
44
+ * replaces the DEFAULT runner with one that refuses loudly, so a test that forgets to inject
45
+ * never reaches the real `infisical`. That override lives on `globalThis` of the Vitest process
46
+ * only — deliberately NOT an environment variable, which a CLI spawned as a subprocess would
47
+ * inherit.
48
+ */
49
+ import { execa } from 'execa';
50
+ import { CLIError } from './cli-error.js';
51
+ import { VAULT_CONFIG } from './vault-config.js';
52
+ /** The vault environment every rail is read from (`fx-secrets-vault §3`). */
53
+ export const RAIL_ENV = 'main';
54
+ /** Name of the vault CLI binary on PATH. */
55
+ export const INFISICAL_BIN = 'infisical';
56
+ /** How to install the vault CLI (`fx-secrets-vault §3`). */
57
+ export const INFISICAL_INSTALL_HINT = 'brew install infisical';
58
+ /** Default time budget (ms) for one rail read — a dead network or a stuck prompt must not hang. */
59
+ export const RAIL_READ_TIMEOUT_MS = 30_000;
60
+ /** Grace (ms) between SIGTERM and SIGKILL once the timeout fires. */
61
+ const FORCE_KILL_AFTER_MS = 2_000;
62
+ /**
63
+ * Environment variable the vault CLI would use INSTEAD of the person's session. Stripped from
64
+ * the child's environment so the rail is always read with `infisical login`'s session (a stale
65
+ * token or a machine identity exported in the shell must not decide who reads the rail).
66
+ */
67
+ const INFISICAL_TOKEN_ENV = 'INFISICAL_TOKEN';
68
+ /**
69
+ * Environment variable the vault CLI's auto-login consults FIRST to pick the domain it logs into
70
+ * (it does not inherit `--domain`). Always set to the vault's domain in the child's environment,
71
+ * so a `.infisical.json` in the cwd's ancestry can never choose where a login page opens.
72
+ */
73
+ const INFISICAL_DOMAIN_ENV = 'INFISICAL_DOMAIN';
74
+ /** The command that opens a session on the org vault. */
75
+ export function loginCommand(config = VAULT_CONFIG) {
76
+ return `infisical login --domain=${config.domain}`;
77
+ }
78
+ /** The org rail, from the embedded copy of `.claude/policy/vault.json`. */
79
+ export const RAIL_TIMEKAST = VAULT_CONFIG.rail;
80
+ /** Base of every rail failure. A `CLIError`, so the top-level handler prints only its message. */
81
+ export class RailError extends CLIError {
82
+ kind;
83
+ constructor(kind, message) {
84
+ super(message);
85
+ this.name = 'RailError';
86
+ this.kind = kind;
87
+ Object.setPrototypeOf(this, new.target.prototype);
88
+ }
89
+ }
90
+ /** No session on the vault, or it expired. */
91
+ export class RailNoSessionError extends RailError {
92
+ constructor(config = VAULT_CONFIG) {
93
+ super('no-session', 'No hay una sesión activa en la bóveda de secretos, o ya caducó. Entra con:\n' +
94
+ ` ${loginCommand(config)}\n` +
95
+ 'y vuelve a intentar.');
96
+ this.name = 'RailNoSessionError';
97
+ Object.setPrototypeOf(this, new.target.prototype);
98
+ }
99
+ }
100
+ /** Live session without access to the rail project (access is per project). */
101
+ export class RailNoAccessError extends RailError {
102
+ project;
103
+ constructor(project) {
104
+ super('no-access', `Tu sesión de la bóveda está activa, pero no tienes acceso al proyecto \`${project.name}\` ` +
105
+ `— o el proyecto configurado (id \`${project.projectId}\`) no existe.\n` +
106
+ 'El acceso es por proyecto (estar en la organización no lo da): pide acceso a un admin de la ' +
107
+ 'bóveda, o revisa el id.');
108
+ this.name = 'RailNoAccessError';
109
+ this.project = project.name;
110
+ Object.setPrototypeOf(this, new.target.prototype);
111
+ }
112
+ }
113
+ /** The read worked and the key is not in the rail (or is empty). */
114
+ export class RailKeyMissingError extends RailError {
115
+ key;
116
+ constructor(key, project) {
117
+ super('key-missing', `La clave \`${key}\` no existe en \`${project.name}\` (entorno ${RAIL_ENV}) o está vacía.\n` +
118
+ 'Pide a un admin de la bóveda que la cargue.');
119
+ this.name = 'RailKeyMissingError';
120
+ this.key = key;
121
+ Object.setPrototypeOf(this, new.target.prototype);
122
+ }
123
+ }
124
+ /** What infisical answered is not something this module recognises. */
125
+ export class RailUnrecognizedError extends RailError {
126
+ constructor(project, detail, config = VAULT_CONFIG) {
127
+ super('unrecognized', `La bóveda respondió algo que no reconozco al leer \`${project.name}\` (${detail}).\n` +
128
+ 'No lo trato como clave ausente ni como falta de acceso. Revisa que tu sesión funcione ' +
129
+ `(\`${loginCommand(config)}\`) y que \`infisical\` esté al día; si persiste, repórtalo al equipo del Factory.`);
130
+ this.name = 'RailUnrecognizedError';
131
+ Object.setPrototypeOf(this, new.target.prototype);
132
+ }
133
+ }
134
+ /** The `infisical` binary is not installed / not on PATH. */
135
+ export class RailNotInstalledError extends RailError {
136
+ constructor(config = VAULT_CONFIG) {
137
+ super('not-installed', 'El CLI de la bóveda (`infisical`) no está instalado o no está en el PATH.\n' +
138
+ `Instálalo con \`${INFISICAL_INSTALL_HINT}\` y entra con \`${loginCommand(config)}\`.`);
139
+ this.name = 'RailNotInstalledError';
140
+ Object.setPrototypeOf(this, new.target.prototype);
141
+ }
142
+ }
143
+ /** The vault did not answer within the time budget. */
144
+ export class RailTimeoutError extends RailError {
145
+ constructor(project, timeoutMs, config = VAULT_CONFIG) {
146
+ super('timeout', `La bóveda no respondió en ${Math.round(timeoutMs / 1000)} s al leer \`${project.name}\`.\n` +
147
+ `Revisa tu conexión a ${config.domain}; si tu sesión caducó, entra de nuevo con ` +
148
+ `\`${loginCommand(config)}\`.`);
149
+ this.name = 'RailTimeoutError';
150
+ Object.setPrototypeOf(this, new.target.prototype);
151
+ }
152
+ }
153
+ /** A spawn that never produced an outcome we can read. */
154
+ const NO_OUTCOME = {
155
+ exitCode: null,
156
+ stdout: '',
157
+ stderr: '',
158
+ timedOut: false,
159
+ notFound: false,
160
+ };
161
+ /** The real runner: execa with stdin closed, no rejection, no verbose echo, a hard timeout. */
162
+ export function createExecaInfisicalRunner(options = {}) {
163
+ const bin = options.bin ?? INFISICAL_BIN;
164
+ return async (args, { timeoutMs, domain }) => {
165
+ const childEnv = { ...(options.env ?? process.env) };
166
+ delete childEnv[INFISICAL_TOKEN_ENV];
167
+ childEnv[INFISICAL_DOMAIN_ENV] = domain;
168
+ try {
169
+ const result = await execa(bin, [...args], {
170
+ stdin: 'ignore',
171
+ reject: false,
172
+ verbose: 'none',
173
+ timeout: timeoutMs,
174
+ forceKillAfterDelay: FORCE_KILL_AFTER_MS,
175
+ env: childEnv,
176
+ extendEnv: false,
177
+ });
178
+ return {
179
+ exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
180
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
181
+ stderr: typeof result.stderr === 'string' ? result.stderr : '',
182
+ timedOut: result.timedOut === true,
183
+ notFound: result.code === 'ENOENT',
184
+ };
185
+ }
186
+ catch (error) {
187
+ // `reject: false` should keep execa from throwing; if it still does (bad options, an
188
+ // internal failure), keep only the ENOENT bit — never the error itself.
189
+ return { ...NO_OUTCOME, notFound: error?.code === 'ENOENT' };
190
+ }
191
+ };
192
+ }
193
+ /**
194
+ * Key on `globalThis` where the Vitest setup installs its refusing default runner. Test-only
195
+ * seam: production code never sets it, and — unlike an environment variable — a CLI spawned as a
196
+ * subprocess does not inherit it. The string is duplicated in `cli/tests/setup/rail-fail-closed.ts`
197
+ * (which must not import this module: it would preload `execa` before test files mock it); the
198
+ * rail tests pin the link.
199
+ */
200
+ export const DEFAULT_RUNNER_OVERRIDE_KEY = Symbol.for('@timekast/factory:rail.default-runner');
201
+ /** The default runner: the test override when present, else the real execa runner. */
202
+ function resolveDefaultRunner() {
203
+ const override = globalThis[DEFAULT_RUNNER_OVERRIDE_KEY];
204
+ return typeof override === 'function'
205
+ ? override
206
+ : createExecaInfisicalRunner();
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Classification (stderr patterns — patterns, not equality: stderr may carry unrelated notices)
210
+ //
211
+ // 🔴 Replayed by hand in `scripts/tools/lib/rail.sh` (`RAIL_NO_SESSION_RE` / `RAIL_NO_ACCESS_RE`),
212
+ // which travels to `core` derivatives where this source does not exist. Edit both together:
213
+ // `cli/tests/unit/rail-classification-parity.test.ts` runs ONE corpus of transcripts
214
+ // through this resolver and through `rail.sh`, and fails the moment the two classify apart.
215
+ // ---------------------------------------------------------------------------
216
+ /**
217
+ * No session / expired session. The first two are REAL infisical 0.43.96 texts, captured live
218
+ * with an empty HOME (RAIL-008; the transcript is pinned in the shared corpus
219
+ * `cli/tests/fixtures/infisical-transcripts.ts`). "login session has expired" was verified in CP-A but not reproduced live (it needs an expired session);
220
+ * `status-code=401` is an assumption.
221
+ */
222
+ const NO_SESSION_PATTERNS = [
223
+ /no valid login session found/i,
224
+ /failed to automatically trigger login flow/i,
225
+ /login session has expired/i,
226
+ /status-code=401\b/i,
227
+ ];
228
+ /**
229
+ * Live session without access. The LAST pattern is a REAL infisical 0.43.96 text, captured live
230
+ * against a project id that does not exist (RAIL-008: `404 … Project with ID '<id>' not found
231
+ * during bot lookup`; pinned in the shared corpus `cli/tests/fixtures/infisical-transcripts.ts`).
232
+ * It stays `no-access` on purpose, and the message says so ("— o el proyecto configurado no
233
+ * existe"): from outside, a missing project and a project you cannot see are the same answer.
234
+ * ⚠️ The others are ASSUMED texts for an EXISTING project the session is not a member of —
235
+ * reproducing that live means touching memberships of a real project, so it stays unverified.
236
+ */
237
+ const NO_ACCESS_PATTERNS = [
238
+ /unable to access project/i,
239
+ /status-code=403\b/i,
240
+ /permission ?denied/i,
241
+ /\bforbidden\b/i,
242
+ /not allowed to/i,
243
+ /do(?:es)? not have (?:access|permission)/i,
244
+ /not (?:a )?(?:part|member) of/i,
245
+ /project with id \S+ (?:was )?not found/i,
246
+ ];
247
+ /** Map a failed read to its typed error. Session is checked first: fix the session, then access. */
248
+ function classifyFailure(result, project, config, timeoutMs, fallbackDetail) {
249
+ if (result.notFound)
250
+ return new RailNotInstalledError(config);
251
+ if (result.timedOut)
252
+ return new RailTimeoutError(project, timeoutMs, config);
253
+ if (NO_SESSION_PATTERNS.some((pattern) => pattern.test(result.stderr))) {
254
+ return new RailNoSessionError(config);
255
+ }
256
+ if (NO_ACCESS_PATTERNS.some((pattern) => pattern.test(result.stderr))) {
257
+ return new RailNoAccessError(project);
258
+ }
259
+ return new RailUnrecognizedError(project, fallbackDetail, config);
260
+ }
261
+ /**
262
+ * Parse the export into a key → value map, or `null` when the shape is not one we recognise.
263
+ * Accepted: an array of `{ key, value }` objects (infisical's `--format=json`), or a flat object
264
+ * of string values. Never throws — `JSON.parse`'s own error quotes the input (the secrets).
265
+ */
266
+ function parseExport(stdout) {
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(stdout);
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ const secrets = new Map();
275
+ if (Array.isArray(parsed)) {
276
+ for (const entry of parsed) {
277
+ if (entry === null || typeof entry !== 'object')
278
+ return null;
279
+ const { key, value } = entry;
280
+ if (typeof key !== 'string' || typeof value !== 'string')
281
+ return null;
282
+ // First occurrence wins (the project's own secret before an imported one).
283
+ if (!secrets.has(key))
284
+ secrets.set(key, value);
285
+ }
286
+ return secrets;
287
+ }
288
+ if (parsed !== null && typeof parsed === 'object') {
289
+ for (const [key, value] of Object.entries(parsed)) {
290
+ if (typeof value !== 'string')
291
+ return null;
292
+ secrets.set(key, value);
293
+ }
294
+ return secrets;
295
+ }
296
+ return null;
297
+ }
298
+ /** Build a resolver. Its cache is in memory and dies with the resolver (and the process). */
299
+ export function createRailResolver(deps = {}) {
300
+ const config = deps.config ?? VAULT_CONFIG;
301
+ const timeoutMs = deps.timeoutMs ?? RAIL_READ_TIMEOUT_MS;
302
+ const cache = new Map();
303
+ const warned = new Set();
304
+ async function readProject(project) {
305
+ const runner = deps.runner ?? resolveDefaultRunner();
306
+ const args = [
307
+ 'export',
308
+ '--format=json',
309
+ '--silent',
310
+ '--secret-overriding=false',
311
+ `--projectId=${project.projectId}`,
312
+ `--env=${RAIL_ENV}`,
313
+ `--domain=${config.domain}`,
314
+ ];
315
+ let result;
316
+ try {
317
+ result = await runner(args, { timeoutMs, domain: config.domain });
318
+ }
319
+ catch {
320
+ // A runner is not supposed to throw; if one does, its error may carry anything.
321
+ throw new RailUnrecognizedError(project, 'el lector de la bóveda falló sin respuesta', config);
322
+ }
323
+ if (result.notFound || result.timedOut) {
324
+ throw classifyFailure(result, project, config, timeoutMs, 'sin respuesta');
325
+ }
326
+ if (result.exitCode !== 0) {
327
+ const detail = result.exitCode === null
328
+ ? 'el proceso terminó sin código de salida'
329
+ : `código de salida ${result.exitCode}`;
330
+ throw classifyFailure(result, project, config, timeoutMs, detail);
331
+ }
332
+ const secrets = parseExport(result.stdout);
333
+ if (secrets === null) {
334
+ // Exit 0 with output we cannot read is NEVER classified by stderr: a successful exit says
335
+ // the session and the access worked, so a stray "login"/"forbidden" text there would send
336
+ // the person to the wrong fix. Same rule as `scripts/tools/lib/rail.sh` (exit 43) — the
337
+ // shared corpus test pins that the two readers agree.
338
+ throw new RailUnrecognizedError(project, 'salida que no es el JSON esperado', config);
339
+ }
340
+ return secrets;
341
+ }
342
+ function secretsOf(project) {
343
+ const cached = cache.get(project.projectId);
344
+ if (cached)
345
+ return cached;
346
+ const pending = readProject(project).catch((error) => {
347
+ // A failure is not cached: the next caller retries (e.g. after `infisical login`).
348
+ cache.delete(project.projectId);
349
+ throw error instanceof RailError
350
+ ? error
351
+ : new RailUnrecognizedError(project, 'error interno al leer la bóveda', config);
352
+ });
353
+ cache.set(project.projectId, pending);
354
+ return pending;
355
+ }
356
+ function noticeStaleEnv(key, project) {
357
+ const env = deps.env ?? process.env;
358
+ const stale = env[key];
359
+ // An EMPTY export (`KEY=`) is no copy of a secret — nothing to warn about, and it never wins.
360
+ if (typeof stale !== 'string' || stale === '' || warned.has(key))
361
+ return;
362
+ warned.add(key);
363
+ (deps.stderr ?? process.stderr).write(`⚠ Ignoro ${key} de tu entorno: el rail se lee de la bóveda (${project.name}). ` +
364
+ 'Borra esa variable de tu shell (p. ej. un export en ~/.zshrc): es una copia que ya nadie rota.\n');
365
+ }
366
+ async function get(key, options = {}) {
367
+ const project = options.project ?? config.rail;
368
+ noticeStaleEnv(key, project);
369
+ const secrets = await secretsOf(project);
370
+ const value = secrets.get(key);
371
+ if (value === undefined || value === '')
372
+ throw new RailKeyMissingError(key, project);
373
+ return value;
374
+ }
375
+ async function getOptional(key, options = {}) {
376
+ try {
377
+ return await get(key, options);
378
+ }
379
+ catch (error) {
380
+ if (error instanceof RailKeyMissingError)
381
+ return null;
382
+ throw error;
383
+ }
384
+ }
385
+ return { get, getOptional };
386
+ }
387
+ /** The process-wide resolver behind `readRailSecret` / `readOptionalRailSecret`. */
388
+ let processResolver;
389
+ function defaultResolver() {
390
+ processResolver ??= createRailResolver();
391
+ return processResolver;
392
+ }
393
+ /** Read a required rail key with the default resolver (one vault read per project per process). */
394
+ export function readRailSecret(key, options) {
395
+ return defaultResolver().get(key, options);
396
+ }
397
+ /** Read an optional rail key: `null` only when the key is missing; every other failure throws. */
398
+ export function readOptionalRailSecret(key, options) {
399
+ return defaultResolver().getOptional(key, options);
400
+ }