@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
@@ -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
  }
@@ -3,19 +3,23 @@
3
3
  *
4
4
  * A DEDICATED preflight, independent of `lib/preflight.ts` (whose scope is the
5
5
  * `new`/`add`/`update` org-membership gate). Provision mutates external infra
6
- * (Neon, Vercel, DNS) with the shared org tokens imported into the rail
7
- * (`factory secrets <archivo>`, SEC-001), so it must fail CLOSED if any credential
8
- * it will use is missing or invalid — BEFORE a single resource is created.
6
+ * (Neon, Vercel, DNS) with the org tokens of the rail — read from the vault
7
+ * (`rail-timekast`) with the person's session through the single resolver
8
+ * (`rail.ts`, ADR-006) — so it must fail CLOSED if any credential it will use is
9
+ * missing or invalid — BEFORE a single resource is created.
9
10
  *
10
- * What is FAIL-CLOSED (aborts with `PreflightError`):
11
+ * What is FAIL-CLOSED (aborts with `PreflightError` / a typed `RailError`):
11
12
  * 1. `gh` installed + a valid `gh auth status` session (the git substrate of
12
- * PROV-004 needs it; and it is the org identity that gates the secrets import).
13
- * 2. The rail tokens for the requested services are PRESENT in `~/.claude/.env`
14
- * (`VERCEL_TOKEN`, `NEON_API_KEY`, …). Absent → re-run `factory secrets <archivo>`.
13
+ * PROV-004 needs it).
14
+ * 2. The rail tokens for the requested services are PRESENT in the vault
15
+ * (`VERCEL_TOKEN`, `NEON_API_KEY`, …). Absent → a vault admin loads them. No
16
+ * session / no access / an unrecognized answer is the resolver's own error —
17
+ * never read as "absent".
15
18
  * 3. `VERCEL_TOKEN` is valid AND team-scoped: `GET /v2/teams/{teamId}` (NOT
16
19
  * `/v2/user` — a personal token passes `/v2/user` yet 403s on the team).
17
20
  * 401 = invalid token; 403 = valid token without team access — distinct
18
- * messages so the dev knows whether to re-import or check team membership.
21
+ * messages so the dev knows whether to check the session / ask for a rotation
22
+ * or check team membership.
19
23
  *
20
24
  * What is OPTIONAL (a WARNING, never an abort — frozen plan decision):
21
25
  * - `neonctl`. Provision mutates Neon over REST with the rail `NEON_API_KEY`,
@@ -24,28 +28,39 @@
24
28
  * The session is detected by its credentials FILE, never by running
25
29
  * `neonctl me` — that command starts a login instead of failing.
26
30
  *
27
- * The token reader, the `fetch`, and the `gh` spawns are all injectable seams so
31
+ * The rail resolver, the `fetch`, and the `gh` spawns are all injectable seams so
28
32
  * the unit tests drive every branch (401/403/missing-token/no-gh/no-neonctl)
29
- * without touching the real `~/.claude/.env`, the real `gh`, or the network.
33
+ * without touching the real vault, the real `gh`, or the network.
30
34
  */
31
- import { existsSync, readFileSync } from 'node:fs';
35
+ import { existsSync } from 'node:fs';
32
36
  import { homedir } from 'node:os';
33
37
  import path from 'node:path';
34
38
  import { execa } from 'execa';
35
39
  import { PreflightError } from './cli-error.js';
40
+ import { WITH_VAULT_SCRIPT_FILE } from './constants.js';
41
+ import { hasLockfile, readLockfile } from './lockfile.js';
42
+ import { loginCommand, RAIL_ENV, RAIL_TIMEKAST, readOptionalRailSecret, readRailSecret, } from './rail.js';
43
+ import { RAILWAY_TOKEN_KEY } from './railway-api.js';
36
44
  /** Timeout (ms) for the Vercel team-check fetch — a dead network must not hang. */
37
45
  const VERCEL_API_TIMEOUT_MS = 10_000;
38
46
  /** Base URL of the Vercel REST API (named constant — never an inline literal). */
39
47
  export const VERCEL_API_BASE = 'https://api.vercel.com';
40
- /** The gate token each provision service needs present in `~/.claude/.env`. */
48
+ /** The gate token each provision service needs present in the vault rail. */
41
49
  const SERVICE_TOKENS = {
50
+ // `vault` (VAULT-002) reads no rail token: it talks to the vault with the person's own
51
+ // session, which the vault client obtains (and names `infisical login` when it is missing).
52
+ vault: null,
42
53
  neon: 'NEON_API_KEY',
43
54
  vercel: 'VERCEL_TOKEN',
44
55
  // `github` (PROV-004) uses the dev's `gh` session for the substrate AND the
45
56
  // gate `VERCEL_TOKEN` to create the Vercel project; `dns` rides the Cloudflare
46
57
  // token but only when a `--domain` is requested (PROV-005), so it is not gated
47
58
  // at the scaffold stage.
59
+ // RWAY-003: ONLY with destination Vercel — see `gateTokenFor`: with destination Railway the
60
+ // `github` step creates no Vercel project and never reads `VERCEL_TOKEN`.
48
61
  github: 'VERCEL_TOKEN',
62
+ // RWAY-003 — the Railway step (RWAY-004) talks to Railway with the team token of the rail.
63
+ railway: RAILWAY_TOKEN_KEY,
49
64
  dns: 'CLOUDFLARE_API_TOKEN',
50
65
  // `backlog` (BSYNC-003) registers the project in backlog.timekast.mx with the org
51
66
  // `admin` token. NOTE: unlike the other gate tokens (fail-closed here), a missing
@@ -56,46 +71,54 @@ const SERVICE_TOKENS = {
56
71
  // entry is the defense-in-depth double-check.
57
72
  backlog: 'BACKLOG_ADMIN_TOKEN',
58
73
  };
74
+ /**
75
+ * The gate token of `service` for this run's destination (RWAY-003). The Vercel coupling of the
76
+ * `github` step exists only with destination Vercel: with Railway, `github` needs no
77
+ * `VERCEL_TOKEN`. An unresolved destination keeps today's (Vercel) behavior.
78
+ */
79
+ function gateTokenFor(service, target) {
80
+ if (service === 'github' && target === 'railway')
81
+ return null;
82
+ return SERVICE_TOKENS[service];
83
+ }
59
84
  /** True when the thrown error is execa's "command not found" (ENOENT). */
60
85
  function isCommandNotFound(error) {
61
86
  return error?.code === 'ENOENT';
62
87
  }
63
- /** Absolute path to the per-user secrets rail (`~/.claude/.env`). */
64
- export function defaultEnvPath() {
65
- return path.join(homedir(), '.claude', '.env');
88
+ /**
89
+ * The process-wide rail resolver (`rail.ts`): one vault read per rail project per
90
+ * process, shared by every step of a run. The default of every `rail` seam in
91
+ * `provision` / `env push`; tests inject `createRailResolver({ runner })` instead.
92
+ */
93
+ export function defaultRail() {
94
+ return { get: readRailSecret, getOptional: readOptionalRailSecret };
66
95
  }
67
96
  /**
68
- * Read the SET of KEY NAMES present in a `KEY=value` rail file — NAMES ONLY, the
69
- * value of every line is discarded immediately so a secret never leaves this
70
- * function (no-echo discipline, mirrors `secrets.ts`). Split on the FIRST `=`;
71
- * comment / blank / no-`=` lines are skipped. A missing file → empty set.
72
- *
73
- * `envPath` is injectable so tests point at a temp file (or a non-existent path).
97
+ * Which of `keys` are PRESENT in the vault rail — NAMES ONLY. Each value is dropped
98
+ * the moment it is read, so no secret leaves this function (no-echo). Only a missing
99
+ * key counts as absent: no session, no access, a timeout or an unrecognized answer
100
+ * propagate as the resolver's typed error — a required token is never skipped
101
+ * because the vault could not be read.
74
102
  */
75
- export function readEnvKeyNames(envPath = defaultEnvPath()) {
76
- if (!existsSync(envPath))
77
- return new Set();
78
- let text;
79
- try {
80
- text = readFileSync(envPath, 'utf8');
81
- }
82
- catch {
83
- return new Set(); // unreadable → treat as "no keys" (preflight will report missing)
84
- }
103
+ export async function readEnvKeyNames(keys, rail = defaultRail()) {
85
104
  const names = new Set();
86
- for (const line of text.split('\n')) {
87
- const trimmed = line.trimStart();
88
- if (trimmed === '' || trimmed.startsWith('#'))
89
- continue;
90
- const eq = line.indexOf('=');
91
- if (eq === -1)
92
- continue;
93
- const key = line.slice(0, eq).trim();
94
- if (key !== '')
105
+ for (const key of keys) {
106
+ if ((await rail.getOptional(key)) !== null)
95
107
  names.add(key);
96
108
  }
97
109
  return names;
98
110
  }
111
+ /**
112
+ * The action for a rail token a provider REJECTED (401): first the vault session —
113
+ * it expires, and a dead session is not a dead token — and only if it is alive, a
114
+ * vault admin rotates the token in `rail-timekast` under the SAME name
115
+ * (`fx-secrets-vault §9`). Nobody re-imports anything: there is no local copy.
116
+ */
117
+ export function railTokenRejectedHint(key) {
118
+ return (`Comprueba primero tu sesión de la bóveda (\`${loginCommand()}\`); si está viva, avisa a un ` +
119
+ `admin de la bóveda para que rote \`${key}\` en \`${RAIL_TIMEKAST.name}\` ` +
120
+ '(el valor nuevo entra con el mismo nombre).');
121
+ }
99
122
  /** Step 1a — `gh` is installed on PATH. */
100
123
  async function assertGhInstalled(spawn) {
101
124
  try {
@@ -165,24 +188,25 @@ async function assertRepoExists(repoSlug, spawn) {
165
188
  }
166
189
  }
167
190
  /**
168
- * Step 2 — the rail tokens for the requested services are present.
191
+ * Step 2 — the rail tokens for the requested services are present in the vault.
169
192
  *
170
- * Maps each requested service to its required token (`SERVICE_TOKENS`) and
171
- * asserts every one is a key in `~/.claude/.env`. A missing token → fail-closed:
172
- * the dev must re-run `factory secrets <archivo>` (SEC-001) to import them. Services
173
- * with no required token (e.g. `github`) are skipped.
193
+ * Maps each requested service to its required token (`SERVICE_TOKENS`) and asserts
194
+ * every one is a key of `rail-timekast`. A missing token → fail-closed, naming ALL
195
+ * the missing ones at once (a vault admin loads them). A vault that cannot be read
196
+ * (no session, no access, …) is the resolver's error, not a "missing token".
174
197
  */
175
- function assertGateTokensPresent(services, present) {
198
+ async function assertGateTokensPresent(services, rail, target) {
176
199
  const required = new Set();
177
200
  for (const service of services) {
178
- const token = SERVICE_TOKENS[service];
201
+ const token = gateTokenFor(service, target);
179
202
  if (token)
180
203
  required.add(token);
181
204
  }
205
+ const present = await readEnvKeyNames([...required], rail);
182
206
  const missing = [...required].filter((token) => !present.has(token));
183
207
  if (missing.length > 0) {
184
- throw new PreflightError(`Faltan tokens del archivo de secretos en \`~/.claude/.env\`: ${missing.join(', ')}.\n` +
185
- 'Ejecuta `factory secrets <archivo>` para bajarlos del archivo de secretos del org y vuelve a intentar.');
208
+ throw new PreflightError(`Faltan tokens en \`${RAIL_TIMEKAST.name}\` (bóveda, entorno ${RAIL_ENV}): ${missing.join(', ')}.\n` +
209
+ 'Pide a un admin de la bóveda que los cargue y vuelve a intentar.');
186
210
  }
187
211
  }
188
212
  /**
@@ -209,7 +233,7 @@ async function checkNeonctlOptional(spawn) {
209
233
  if (isCommandNotFound(error)) {
210
234
  return {
211
235
  status: 'warn',
212
- message: '`neonctl` no está instalado (opcional — provision muta Neon por REST con el `NEON_API_KEY` del archivo de secretos).\n' +
236
+ message: '`neonctl` no está instalado (opcional — provision muta Neon por REST con el `NEON_API_KEY` del rail).\n' +
213
237
  'Si lo quieres para inspección manual: `npm i -g neonctl`.',
214
238
  };
215
239
  }
@@ -226,7 +250,7 @@ async function checkNeonctlOptional(spawn) {
226
250
  // depend on the neonctl session") therefore had two side effects it had no business
227
251
  // having: it popped an OAuth consent in the dev's browser mid-provision, and, if
228
252
  // approved, wrote an OAuth session to their host. Worse where nobody can see it: on
229
- // a headless Agent Server there is no browser and no one to approve, so the spawn
253
+ // a headless run there is no browser and no one to approve, so the spawn
230
254
  // sits waiting and hangs the preflight of a run that never needed it.
231
255
  //
232
256
  // Reading the credentials file is the same answer with no side effects. It can go
@@ -246,7 +270,8 @@ async function checkNeonctlOptional(spawn) {
246
270
  * Step 3 — `VERCEL_TOKEN` is valid and scoped to `teamId` via `GET /v2/teams/{teamId}`.
247
271
  *
248
272
  * Distinguishes the two failure modes the issue calls out (§8):
249
- * - 401 → the token itself is invalid/expired → re-pull
273
+ * - 401 → the token itself is invalid/expired → check the vault session, then
274
+ * ask a vault admin to rotate it
250
275
  * - 403 → a valid token WITHOUT access to this team (e.g. a personal-scoped
251
276
  * token) → check the team id / membership
252
277
  *
@@ -271,7 +296,7 @@ async function assertVercelTeamAccess(token, teamId, fetchImpl) {
271
296
  return; // 2xx → token válido y con acceso al team.
272
297
  if (response.status === 401) {
273
298
  throw new PreflightError('El `VERCEL_TOKEN` es inválido o expiró (Vercel respondió 401).\n' +
274
- 'Vuelve a bajar los tokens del archivo de secretos con `factory secrets <archivo>` y reintenta.');
299
+ railTokenRejectedHint('VERCEL_TOKEN'));
275
300
  }
276
301
  if (response.status === 403) {
277
302
  throw new PreflightError(`El \`VERCEL_TOKEN\` no tiene acceso al team \`${teamId}\` (Vercel respondió 403).\n` +
@@ -291,7 +316,12 @@ async function assertVercelTeamAccess(token, teamId, fetchImpl) {
291
316
  export async function runProvisionPreflight(input, deps = {}) {
292
317
  const spawn = deps.spawn ?? execa;
293
318
  const fetchImpl = deps.fetchImpl ?? fetch;
294
- const envPath = deps.envPath ?? defaultEnvPath();
319
+ const rail = deps.rail ?? defaultRail();
320
+ // 0. VAULT-004 — the brain carries what the vault needs locally. Local reads only, so it runs
321
+ // first: a repo that cannot use the vault stops before any network call and before the
322
+ // vault step creates the project.
323
+ if (input.rootDir && input.services.includes('vault'))
324
+ assertVaultBrainPresent(input.rootDir);
295
325
  // 1. gh installed + authenticated.
296
326
  await assertGhInstalled(spawn);
297
327
  await assertGhAuthenticated(spawn);
@@ -304,25 +334,21 @@ export async function runProvisionPreflight(input, deps = {}) {
304
334
  if (input.repoSlug)
305
335
  await assertRepoExists(input.repoSlug, spawn);
306
336
  }
307
- // 2. Gate tokens for the requested services present in the rail.
308
- const present = readEnvKeyNames(envPath);
309
- assertGateTokensPresent(input.services, present);
337
+ // 2. Gate tokens for the requested services present in the vault rail.
338
+ await assertGateTokensPresent(input.services, rail, input.target);
310
339
  // 3. Vercel team-scoped token check — when provisioning Vercel (env sync), GitHub
311
340
  // (PROV-004 creates the team-scoped Vercel project), OR DNS (PROV-005 attaches
312
- // the interim domains to the team-scoped project).
313
- if (input.services.includes('vercel') ||
314
- input.services.includes('github') ||
315
- input.services.includes('dns')) {
341
+ // the interim domains to the team-scoped project). RWAY-003: only with destination
342
+ // Vercel — a Railway run never asks for the team nor reads `VERCEL_TOKEN`.
343
+ if (input.target !== 'railway' &&
344
+ (input.services.includes('vercel') ||
345
+ input.services.includes('github') ||
346
+ input.services.includes('dns'))) {
316
347
  if (!input.teamId) {
317
348
  throw new PreflightError('Falta `--team=<teamId>`: el provision de Vercel/GitHub/DNS requiere el team del org.\n' +
318
349
  'Pásalo como `factory provision --services=github,vercel,dns --team=team_xxx`.');
319
350
  }
320
- const token = readVercelToken(envPath);
321
- if (!token) {
322
- // Defensive: step 2 already asserted presence, but guard the read path.
323
- throw new PreflightError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
324
- 'Ejecuta `factory secrets <archivo>` y vuelve a intentar.');
325
- }
351
+ const token = await readVercelToken(rail);
326
352
  await assertVercelTeamAccess(token, input.teamId, fetchImpl);
327
353
  }
328
354
  // 4. Optional neonctl — never fatal; surfaced as a warning by the caller.
@@ -330,41 +356,73 @@ export async function runProvisionPreflight(input, deps = {}) {
330
356
  return { neonctl };
331
357
  }
332
358
  /**
333
- * Read the VALUE of `VERCEL_TOKEN` from the rail (the team-check needs the value,
334
- * not just the name). Split on the FIRST `=`; strip a single matched pair of
335
- * surrounding quotes (the rail may store quoted values — `env-merge.ts`
336
- * preserves them). Returns `null` when absent/unreadable.
359
+ * Read the VALUE of `VERCEL_TOKEN` from the vault rail (the team-check and every
360
+ * Vercel call need the value, not just the name). A missing key is the resolver's
361
+ * `key-missing` error (it names the key); no session / no access propagate as-is.
337
362
  *
338
- * This is the ONE place a token VALUE is read; it is handed straight to the
339
- * Authorization header and never logged.
363
+ * The value is handed straight to an Authorization header and never logged.
340
364
  */
341
- export function readVercelToken(envPath = defaultEnvPath()) {
342
- if (!existsSync(envPath))
343
- return null;
344
- let text;
345
- try {
346
- text = readFileSync(envPath, 'utf8');
347
- }
348
- catch {
349
- return null;
350
- }
351
- for (const line of text.split('\n')) {
352
- const trimmed = line.trimStart();
353
- if (trimmed === '' || trimmed.startsWith('#'))
354
- continue;
355
- const eq = line.indexOf('=');
356
- if (eq === -1)
357
- continue;
358
- const key = line.slice(0, eq).trim();
359
- if (key !== 'VERCEL_TOKEN')
360
- continue;
361
- let value = line.slice(eq + 1).replace(/\s+$/, '');
362
- if (value.length >= 2 &&
363
- ((value.startsWith('"') && value.endsWith('"')) ||
364
- (value.startsWith("'") && value.endsWith("'")))) {
365
- value = value.slice(1, -1);
365
+ export function readVercelToken(rail = defaultRail()) {
366
+ return rail.get('VERCEL_TOKEN');
367
+ }
368
+ // ─────────────────────────────────────────────────────────────────────────────
369
+ // VAULT-002 — is this repo on the `core` profile? (the vault is not offered there)
370
+ // ─────────────────────────────────────────────────────────────────────────────
371
+ /**
372
+ * The local wrapper that injects the vault's `local` environment into the kit's scripts
373
+ * (VAULT-003). The `core` profile does not ship it, so a repo without it cannot work from the
374
+ * vault — that is the fallback signal when there is no lockfile to read.
375
+ */
376
+ export const VAULT_WRAPPER_PATH = WITH_VAULT_SCRIPT_FILE;
377
+ /**
378
+ * The org vault's coordinates as the repo's brain carries them — what the wrapper reads to find
379
+ * the instance. Shipped by the kit (`.claude/policy/`); `factory update` brings it when missing.
380
+ */
381
+ export const VAULT_POLICY_PATH = '.claude/policy/vault.json';
382
+ /**
383
+ * Detect the `core` profile, where `provision` does not offer the vault (VAULT-002). One
384
+ * function, reused by VAULT-004's preflight:
385
+ *
386
+ * 1. A READABLE lockfile decides: `profile === 'core'` → core; `full` → not core, even if the
387
+ * wrapper is missing (an outdated brain is VAULT-004's `factory update`, not a profile).
388
+ * 2. Only when there is no lockfile to read (absent, or present but unparseable): core when
389
+ * `scripts/tools/with-vault.mjs` is absent.
390
+ *
391
+ * 🔴 NEVER by the absence of `package.json`: a `full` derivative always has one, but so can a
392
+ * `core` repo of another stack that happens to use Node tooling — the lockfile is the authority.
393
+ * `lockfile.ts` is only READ here.
394
+ */
395
+ export function detectCoreProfile(rootDir) {
396
+ if (hasLockfile(rootDir)) {
397
+ try {
398
+ return readLockfile(rootDir).profile === 'core' ? 'lockfile' : null;
399
+ }
400
+ catch {
401
+ // Unreadable lockfile → no profile to read; fall through to the wrapper signal.
366
402
  }
367
- return value === '' ? null : value;
368
403
  }
369
- return null;
404
+ return existsSync(path.join(rootDir, VAULT_WRAPPER_PATH)) ? null : 'no-wrapper';
405
+ }
406
+ /**
407
+ * VAULT-004 — in a `full` repo about to get the vault, the files the vault needs locally must be
408
+ * on disk: the wrapper that injects it into the scripts and `vault.json`, the coordinates it
409
+ * reads. Without them the project would be created and `pnpm dev` would start with no secrets.
410
+ * The fix is `factory update` (it brings a missing kit file), or `--no-vault` to go on without.
411
+ *
412
+ * `core` never reaches the check (the vault is not offered there): detected by the ONE function
413
+ * {@link detectCoreProfile}, shared with VAULT-002.
414
+ *
415
+ * @throws {PreflightError} naming every missing file.
416
+ */
417
+ export function assertVaultBrainPresent(rootDir) {
418
+ if (detectCoreProfile(rootDir) !== null)
419
+ return;
420
+ const missing = [VAULT_WRAPPER_PATH, VAULT_POLICY_PATH].filter((rel) => !existsSync(path.join(rootDir, rel)));
421
+ if (missing.length === 0)
422
+ return;
423
+ throw new PreflightError(`Este repo no trae lo que la bóveda necesita en local: falta ${missing
424
+ .map((rel) => `\`${rel}\``)
425
+ .join(' y ')}. No creé nada.\n` +
426
+ ' · Corre `factory update` para traer el cerebro al día y repite el comando.\n' +
427
+ ' · O sigue sin bóveda, como hasta hoy: repite el comando con `--no-vault`.');
370
428
  }