@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
@@ -0,0 +1,320 @@
1
+ /**
2
+ * `CLAUDE.md` retrofit โ€” the kit-managed rules-INDEX import line (PROJ-006).
3
+ *
4
+ * The kit's `CLAUDE.md` moved from six explicit `@.claude/rules/*.md` imports to a
5
+ * single `@.claude/rules/INDEX.md` line that fans out to all of them. A derivative
6
+ * whose `CLAUDE.md` is dev-owned (it is: `diffLockfiles` routes it to `ignoreLocal`
7
+ * the moment it is edited) would never receive that line, so every rule the kit adds
8
+ * from now on lands on disk dead.
9
+ *
10
+ * This module is the WHOLE retrofit: classification (A/B/C), the idempotent
11
+ * insertion, and the closing report. `update.ts` only wires it into the flow.
12
+ *
13
+ * ๐Ÿ”ด ZERO MECHANICAL INTRUSION is the guarantee. The retrofit only ever ADDS one
14
+ * line, and only when it is absent. It never deletes, reorders or rewrites anything
15
+ * the dev wrote โ€” the insertion is a string splice into the original bytes, so line
16
+ * endings, trailing whitespace and every other byte outside the inserted line survive
17
+ * untouched.
18
+ *
19
+ * The three cases (fixed in CP-A, discriminated off the LOCKFILE so `--verify` can
20
+ * produce the census with no network and no incoming manifest):
21
+ *
22
+ * A โ€” virgin: the on-disk hash equals the hash the lockfile recorded โ†’ the dev
23
+ * never touched it โ†’ the normal `overwriteSilent` branch propagates
24
+ * the kit's version, which already carries the line. Untouched here.
25
+ * B โ€” edited outside the kit's zone: it differs from the lockfile, but it still
26
+ * imports EVERY kit rule โ†’ the import block is intact โ†’ insert the
27
+ * INDEX line if missing.
28
+ * C โ€” edited INSIDE the kit's zone: a kit rule is missing from the imports (the dev
29
+ * took one out by hand) โ†’ do NOT touch. Report for manual review.
30
+ * Deliberately conservative: guessing what the dev meant is exactly
31
+ * the fragile heuristic the plan avoids.
32
+ */
33
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
34
+ import path from 'node:path';
35
+ import { CLAUDE_MD_FILE } from './constants.js';
36
+ import { normalizeThenHash } from './lockfile.js';
37
+ /** Repo-relative directory holding the kit's always-on rules. */
38
+ export const RULES_DIR = '.claude/rules';
39
+ /** Repo-relative path of the kit-managed rules index (full profile only). */
40
+ export const RULES_INDEX_PATH = `${RULES_DIR}/INDEX.md`;
41
+ /** The single line this retrofit owns. */
42
+ export const RULES_INDEX_IMPORT = `@${RULES_INDEX_PATH}`;
43
+ /**
44
+ * A flat kit rule file (`.claude/rules/<name>.md`). Nested paths are out: the
45
+ * always-on set the runtime auto-loads is flat, and a `methodology/` subdir under
46
+ * `rules/` would otherwise read as a rule.
47
+ */
48
+ export const KIT_RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
49
+ /** A bare `@.claude/rules/<name>.md` directive line (used to find the import block). */
50
+ const RULE_IMPORT_LINE_RE = /^@\.claude\/rules\/[^/\s]+\.md$/;
51
+ /**
52
+ * First-level markdown heading (`# Title`) โ€” the insertion anchor of last resort.
53
+ * A leading BOM is tolerated: an editor that writes UTF-8-with-BOM puts `๏ปฟ`
54
+ * before the `#` of line 1, and without this the file's own title would not match,
55
+ * pushing the insertion all the way to the end of the file for no good reason.
56
+ */
57
+ const H1_RE = /^๏ปฟ?#\s+\S/;
58
+ /** A markdown fence delimiter line: three or more backticks or tildes, plus an optional info string. */
59
+ const FENCE_RE = /^(`{3,}|~{3,})(.*)$/;
60
+ /**
61
+ * The paths a markdown file `@import`s: a line whose trimmed text is `@<path>`.
62
+ * Same strictness as `doctor`'s original reader (which now calls THIS function, so
63
+ * the two can no longer drift): a prose mention is not a bare directive line and
64
+ * does not count.
65
+ *
66
+ * ๐Ÿ”ด Lines inside a fenced code block are SKIPPED. The kit's own docs show import
67
+ * lines inside ``` samples ("agrega `@.claude/rules/INDEX.md` asรญ:"), and counting
68
+ * those would make a file that only DEMONSTRATES an import read as if it performed
69
+ * it โ€” the retrofit would then skip the insertion, and `doctor` would report a rule
70
+ * as covered when the runtime never loads it. Both fence characters are handled,
71
+ * with or without an info string; a closing fence is one of the same character and
72
+ * at least as long, which is what keeps a nested ```` ```` ```` sample from closing
73
+ * its own container early.
74
+ */
75
+ export function bareImportPaths(text) {
76
+ const lines = text.split('\n');
77
+ const fenced = markFenced(lines);
78
+ const out = [];
79
+ lines.forEach((raw, i) => {
80
+ if (fenced[i])
81
+ return;
82
+ const line = raw.trim();
83
+ if (line.startsWith('@'))
84
+ out.push(line.slice(1).trim());
85
+ });
86
+ return out;
87
+ }
88
+ /**
89
+ * Per-line "is this inside (or is) a fenced code block?" flags. Fence delimiter lines
90
+ * are flagged too โ€” they are never directive lines, so flagging them costs nothing and
91
+ * keeps the scan a single pass. Shared by the reader above and by the insertion anchor
92
+ * below, so the two can never disagree about where the code samples are.
93
+ */
94
+ function markFenced(lines) {
95
+ const flags = [];
96
+ let fence = null;
97
+ for (const raw of lines) {
98
+ const match = FENCE_RE.exec(raw.trim());
99
+ if (match) {
100
+ const char = match[1][0];
101
+ const length = match[1].length;
102
+ if (fence === null)
103
+ fence = { char, length };
104
+ // A closing fence: same character, at least as long, and no info string.
105
+ else if (char === fence.char && length >= fence.length && match[2].trim() === '')
106
+ fence = null;
107
+ flags.push(true);
108
+ continue;
109
+ }
110
+ flags.push(fence !== null);
111
+ }
112
+ return flags;
113
+ }
114
+ /**
115
+ * Resolve a path found inside an `@import` RELATIVE TO THE FILE THAT CONTAINS IT โ€”
116
+ * the documented Claude Code rule for memory imports. It is the reason `INDEX.md`
117
+ * lists `@CORE.md` and not `@.claude/rules/CORE.md`: the latter would resolve to
118
+ * `.claude/rules/.claude/rules/CORE.md` and fail silently. Absolute and `~` paths
119
+ * are left verbatim (they are not repo-relative and nothing here can normalize them).
120
+ */
121
+ export function resolveImportFrom(containerPath, imported) {
122
+ if (imported.startsWith('/') || imported.startsWith('~'))
123
+ return imported;
124
+ return path.posix.normalize(path.posix.join(path.posix.dirname(containerPath), imported));
125
+ }
126
+ /** The kit's always-on rules as recorded in a lockfile โ€” the INDEX itself excluded. */
127
+ export function kitRulePaths(lock) {
128
+ return lock.files
129
+ .map((f) => f.path)
130
+ .filter((p) => KIT_RULE_PATH_RE.test(p) && p !== RULES_INDEX_PATH);
131
+ }
132
+ /** True when the text already carries the bare INDEX import directive. */
133
+ export function hasIndexImport(text) {
134
+ return bareImportPaths(text).includes(RULES_INDEX_PATH);
135
+ }
136
+ /**
137
+ * Kit rules the text does not cover. The INDEX line counts as covering ALL of them:
138
+ * that is literally what it does at runtime (one hop, it imports every rule), and
139
+ * without this an already-retrofitted `CLAUDE.md` โ€” which imports the INDEX and
140
+ * nothing else โ€” would classify as C and be reported as needing manual review on
141
+ * every later update.
142
+ */
143
+ export function uncoveredKitRules(text, kitRules) {
144
+ if (hasIndexImport(text))
145
+ return [];
146
+ const imported = new Set(bareImportPaths(text));
147
+ return kitRules.filter((p) => !imported.has(p));
148
+ }
149
+ /** Scan `content` into lines, preserving each line's offsets and terminator. */
150
+ function scanLines(content) {
151
+ const lines = [];
152
+ let start = 0;
153
+ for (;;) {
154
+ const nl = content.indexOf('\n', start);
155
+ if (nl === -1) {
156
+ lines.push({ start, end: content.length, text: content.slice(start), eol: '' });
157
+ return lines;
158
+ }
159
+ const raw = content.slice(start, nl);
160
+ const crlf = raw.endsWith('\r');
161
+ lines.push({
162
+ start,
163
+ end: nl + 1,
164
+ text: crlf ? raw.slice(0, -1) : raw,
165
+ eol: crlf ? '\r\n' : '\n',
166
+ });
167
+ start = nl + 1;
168
+ }
169
+ }
170
+ /**
171
+ * Add the INDEX import line, preserving every other byte. IDEMPOTENT: a text that
172
+ * already has the line is returned unchanged (identity, not a rewrite).
173
+ *
174
+ * Anchor, in order:
175
+ * 1. Immediately BEFORE the first `@.claude/rules/*.md` line โ€” so the new import
176
+ * lands inside the existing import block instead of orphaned at the bottom.
177
+ * 2. Otherwise right after the first `# H1` (the file's title).
178
+ * 3. Otherwise appended at the end.
179
+ *
180
+ * The inserted line borrows the terminator of the line it sits next to, so a CRLF
181
+ * file stays CRLF and a mixed file is not normalized.
182
+ */
183
+ export function insertIndexImport(content) {
184
+ if (hasIndexImport(content))
185
+ return content;
186
+ const lines = scanLines(content);
187
+ const fallbackEol = lines.some((l) => l.eol === '\r\n') ? '\r\n' : '\n';
188
+ // Fence-aware like the reader: a rule import that only appears inside a ``` sample is
189
+ // not an import block, and splicing the new line in there would put a live directive
190
+ // inside a code block โ€” invisible to the runtime and confusing in the doc.
191
+ const fenced = markFenced(lines.map((l) => l.text));
192
+ const firstRuleImport = lines.find((l, i) => !fenced[i] && RULE_IMPORT_LINE_RE.test(l.text.trim()));
193
+ if (firstRuleImport) {
194
+ const eol = firstRuleImport.eol || fallbackEol;
195
+ return (content.slice(0, firstRuleImport.start) +
196
+ RULES_INDEX_IMPORT +
197
+ eol +
198
+ content.slice(firstRuleImport.start));
199
+ }
200
+ const h1 = lines.find((l, i) => !fenced[i] && H1_RE.test(l.text));
201
+ if (h1) {
202
+ const at = h1.end;
203
+ const rest = content.slice(at);
204
+ // A blank line after the heading, and another before whatever followed โ€” unless
205
+ // it already starts with one (never stack two blank lines).
206
+ const trailing = rest.length === 0 || rest.startsWith('\n') || rest.startsWith('\r') ? '' : fallbackEol;
207
+ return content.slice(0, at) + fallbackEol + RULES_INDEX_IMPORT + fallbackEol + trailing + rest;
208
+ }
209
+ const separator = content.length === 0 || content.endsWith('\n') ? '' : fallbackEol;
210
+ return content + separator + RULES_INDEX_IMPORT + fallbackEol;
211
+ }
212
+ /**
213
+ * Classify a `CLAUDE.md` โ€” pure. `content: undefined` means "not on disk".
214
+ * `registeredHash` is the hash the lockfile recorded for it (absent on a lockfile
215
+ * that predates tracking it โ†’ it cannot be proven virgin, so it is treated as edited).
216
+ */
217
+ export function classifyClaudeMd(opts) {
218
+ const { content, registeredHash, kitRules, profile } = opts;
219
+ if (content === undefined)
220
+ return { case: 'absent', action: 'none', uncovered: [] };
221
+ if (registeredHash !== undefined && normalizeThenHash(content) === registeredHash) {
222
+ // Virgin: the kit's own version is on disk. The `overwriteSilent` branch already
223
+ // propagates the new one (which carries the line) โ€” nothing to retrofit here.
224
+ return { case: 'A', action: 'none', uncovered: [] };
225
+ }
226
+ const uncovered = uncoveredKitRules(content, kitRules);
227
+ if (uncovered.length > 0)
228
+ return { case: 'C', action: 'none', uncovered };
229
+ if (hasIndexImport(content))
230
+ return { case: 'B', action: 'already-present', uncovered: [] };
231
+ // The `core` profile does not ship `.claude/rules/INDEX.md` at all (it resolves its
232
+ // rules with explicit imports, and the INDEX pulls in `SK.md`, which `core` excludes
233
+ // on purpose). Inserting the line there would point at a file that never arrives.
234
+ if (profile === 'core')
235
+ return { case: 'B', action: 'skip-core', uncovered: [] };
236
+ return { case: 'B', action: 'insert', uncovered: [] };
237
+ }
238
+ /**
239
+ * Classify the `CLAUDE.md` of a repo on disk against its INSTALLED lockfile.
240
+ *
241
+ * ๐Ÿ”ด Must run BEFORE `applyPlan`. Case A is "disk == lockfile", and once the swap has
242
+ * written the incoming version that comparison is destroyed โ€” every repo would read
243
+ * as edited.
244
+ */
245
+ export function assessClaudeMd(rootDir, lock) {
246
+ const abs = path.join(rootDir, CLAUDE_MD_FILE);
247
+ const present = existsSync(abs) && statSync(abs).isFile();
248
+ return classifyClaudeMd({
249
+ content: present ? readFileSync(abs, 'utf8') : undefined,
250
+ registeredHash: lock.files.find((f) => f.path === CLAUDE_MD_FILE)?.hash,
251
+ kitRules: kitRulePaths(lock),
252
+ profile: lock.profile,
253
+ });
254
+ }
255
+ /**
256
+ * Perform the retrofit decided by {@link assessClaudeMd}. Returns true only when a
257
+ * line was actually written. Any other action is a no-op by construction.
258
+ *
259
+ * Best-effort like the rest of the post-apply maintenance: the brain is already
260
+ * installed and the lockfile already written, so a write failure warns and reports
261
+ * instead of failing an update that succeeded.
262
+ */
263
+ export function applyClaudeMdRetrofit(rootDir, assessment) {
264
+ if (assessment.action !== 'insert')
265
+ return false;
266
+ const abs = path.join(rootDir, CLAUDE_MD_FILE);
267
+ try {
268
+ const before = readFileSync(abs, 'utf8');
269
+ const after = insertIndexImport(before);
270
+ if (after === before)
271
+ return false; // already there (idempotent re-entry)
272
+ writeFileSync(abs, after, 'utf8');
273
+ return true;
274
+ }
275
+ catch {
276
+ console.warn('Aviso: no se pudo agregar `@.claude/rules/INDEX.md` a tu `CLAUDE.md`; el cerebro se instalรณ igual. ' +
277
+ 'Agrรฉgala a mano para que las rules del kit se carguen.');
278
+ return false;
279
+ }
280
+ }
281
+ /**
282
+ * Name the case the repo's `CLAUDE.md` fell into โ€” same closing-summary style with
283
+ * which the update already names the `package.json` aliases it put back or retired.
284
+ * `mode: 'preview'` is the `--verify` census (nothing was written).
285
+ */
286
+ export function reportClaudeMd(assessment, mode, inserted = true) {
287
+ const { case: kind, action, uncovered } = assessment;
288
+ if (kind === 'absent') {
289
+ console.log(`\n\`${CLAUDE_MD_FILE}\`: no estรก en el disco. El update instala el del Factory (ya trae \`${RULES_INDEX_IMPORT}\`).`);
290
+ return;
291
+ }
292
+ if (kind === 'A') {
293
+ console.log(`\n\`${CLAUDE_MD_FILE}\` (caso A โ€” sin ediciones tuyas): ${mode === 'applied' ? 'se actualizรณ' : 'se actualizarรญa'} con la versiรณn del Factory, como siempre.`);
294
+ return;
295
+ }
296
+ if (kind === 'C') {
297
+ console.log(`\n\`${CLAUDE_MD_FILE}\` (caso C โ€” necesita revisiรณn manual): lo editaste y le faltan rules del kit ` +
298
+ `en los imports: ${uncovered.join(', ')}.`);
299
+ console.log(` No se tocรณ nada. Agrega \`${RULES_INDEX_IMPORT}\` a mano (importa todas) o repรณn los imports que falten.`);
300
+ return;
301
+ }
302
+ // Case B โ€” edited outside the kit's zone.
303
+ if (action === 'already-present') {
304
+ console.log(`\n\`${CLAUDE_MD_FILE}\` (caso B โ€” lo editaste): ya importa \`${RULES_INDEX_IMPORT}\`; no se tocรณ.`);
305
+ return;
306
+ }
307
+ if (action === 'skip-core') {
308
+ console.log(`\n\`${CLAUDE_MD_FILE}\` (caso B โ€” lo editaste): el perfil \`core\` no incluye \`${RULES_INDEX_PATH}\`, ` +
309
+ 'asรญ que no se agregรณ ninguna lรญnea; no se tocรณ.');
310
+ return;
311
+ }
312
+ if (mode === 'preview') {
313
+ console.log(`\n\`${CLAUDE_MD_FILE}\` (caso B โ€” lo editaste): le falta \`${RULES_INDEX_IMPORT}\`. ` +
314
+ 'Un `factory update` la agrega sin tocar el resto (no se modificรณ nada ahora).');
315
+ return;
316
+ }
317
+ console.log(inserted
318
+ ? `\n\`${CLAUDE_MD_FILE}\` (caso B โ€” lo editaste): se le agregรณ la lรญnea \`${RULES_INDEX_IMPORT}\`; el resto quedรณ igual, byte a byte.`
319
+ : `\n\`${CLAUDE_MD_FILE}\` (caso B โ€” lo editaste): no se pudo agregar \`${RULES_INDEX_IMPORT}\`; agrรฉgala a mano.`);
320
+ }
@@ -5,9 +5,15 @@
5
5
  * `vercel-env.ts` (`Authorization: Bearer ${token}` + a single central URL
6
6
  * builder + an injectable `fetchImpl` seam). Its scope is DNS-ONLY: it resolves
7
7
  * the org zone id, looks up an existing CNAME, creates one
8
- * (`proxied: false` so Vercel's own TLS terminates the domain), and PATCHes an
8
+ * (`proxied: false` so the hosting's own TLS terminates the domain), and PATCHes an
9
9
  * existing record that was manually left `proxied: true` back to `false`.
10
10
  *
11
+ * The CNAME destination is a PARAMETER (RWAY-005): Vercel's fixed
12
+ * {@link CNAME_VERCEL_TARGET}, or the `requiredValue` Railway returns for each
13
+ * domain. ๐Ÿ”ด A record that already exists pointing ANYWHERE ELSE stops with a
14
+ * {@link DnsRecordConflictError} naming both destinations โ€” it is never rewritten
15
+ * (Decision 19: moving production traffic is the person's call, not the CLI's).
16
+ *
11
17
  * ๐Ÿ”ด Scope contract (issue ยง4 + the frozen plan): the token handed in here is the
12
18
  * shared org Cloudflare token from the rail, minted SCOPED to the org zones with
13
19
  * DNS-edit only โ€” never an account-wide token (a DNS-write leak over an org zone could
@@ -23,8 +29,9 @@
23
29
  * path is create-or-PATCH; a record is never removed to "fix" a failed call
24
30
  * (issue ยง8). The ONE deletion shipped here ({@link deleteDnsRecord},
25
31
  * BOOT-007) is DELIBERATE cleanup inside a flow a human already confirmed
26
- * (retiring the interim domain after the client domain went live) โ€” it is
27
- * never an automatic recovery move.
32
+ * (retiring the interim domain after the client domain went live, or a
33
+ * `--destroy` โ€” which first re-reads the record with {@link getDnsRecord}) โ€”
34
+ * it is never an automatic recovery move.
28
35
  * - Read or log a token value. The token arrives as an argument and is handed
29
36
  * straight to the `Authorization` header; it never reaches stdout.
30
37
  *
@@ -101,6 +108,50 @@ export function existingProvisionDomains(slug, dns) {
101
108
  }
102
109
  /** Timeout (ms) for every Cloudflare REST call โ€” a dead network must not hang provision. */
103
110
  const CLOUDFLARE_API_TIMEOUT_MS = 30_000;
111
+ /**
112
+ * The test-isolation marker the CLI's Vitest setup installs on `globalThis`
113
+ * (`cli/tests/setup/isolate-home-path.ts`) โ€” the SAME registry symbol as `vault-api.ts`'s
114
+ * `VAULT_TEST_ISOLATION_KEY`, which the Railway client reads too. Resolved through
115
+ * `Symbol.for` instead of imported: `provision-state.ts` imports this module and must stay free
116
+ * of external deps (the root suite typechecks it without `cli/`'s `node_modules`), and
117
+ * `vault-api.ts` pulls them in. A marker, not `process.env.VITEST`: a CLI a test spawns as a
118
+ * subprocess inherits the env var and would turn a real run into a refusal.
119
+ */
120
+ const TEST_ISOLATION_KEY = Symbol.for('@timekast/factory:vault.test-isolation');
121
+ /** Under the CLI's Vitest setup without an injected `fetchImpl`: refuses before any request. */
122
+ export class CloudflareSeamMissingError extends CLIError {
123
+ constructor() {
124
+ super('cloudflare-api: falta el seam `fetchImpl` bajo Vitest. Un test NUNCA habla con Cloudflare ' +
125
+ 'real: pasa `{ fetchImpl }` en los deps (tests/unit/provision-railway-dns.test.ts tiene el patrรณn).');
126
+ this.name = 'CloudflareSeamMissingError';
127
+ Object.setPrototypeOf(this, new.target.prototype);
128
+ }
129
+ }
130
+ /**
131
+ * A record that already exists with ANOTHER content than the one expected (RWAY-005, Decision
132
+ * 19). The CLI stops naming the record, what it points at now and what it should point at; it
133
+ * never rewrites it, because that would move live traffic (typically: a repo that moved from
134
+ * Vercel to Railway and still has its CNAME at `cname.vercel-dns.com`).
135
+ */
136
+ export class DnsRecordConflictError extends CLIError {
137
+ recordType;
138
+ recordName;
139
+ current;
140
+ expected;
141
+ constructor(recordType, recordName, current, expected) {
142
+ super(`El registro ${recordType} \`${recordName}\` ya existe en Cloudflare y apunta a \`${current}\`, ` +
143
+ `no a \`${expected}\`.\n` +
144
+ 'No lo reescribo: cambiarlo moverรญa el trรกfico que hoy sirve ese registro, y eso lo decides tรบ. ' +
145
+ `Si el destino correcto es \`${expected}\`, edita ese registro en Cloudflare (o bรณrralo) y ` +
146
+ 'reintenta con `--resume`. No modifiquรฉ ningรบn registro.');
147
+ this.name = 'DnsRecordConflictError';
148
+ this.recordType = recordType;
149
+ this.recordName = recordName;
150
+ this.current = current;
151
+ this.expected = expected;
152
+ Object.setPrototypeOf(this, new.target.prototype);
153
+ }
154
+ }
104
155
  /** A non-2xx (or `success: false`) response from the Cloudflare REST API. */
105
156
  export class CloudflareApiError extends Error {
106
157
  status;
@@ -145,6 +196,10 @@ export function cloudflareUrl(pathname, query = {}) {
145
196
  * permission/validation failures, so the HTTP status alone is not enough).
146
197
  */
147
198
  async function cloudflareApi(method, pathname, token, deps, body, query) {
199
+ if (!deps.fetchImpl &&
200
+ globalThis[TEST_ISOLATION_KEY] !== undefined) {
201
+ throw new CloudflareSeamMissingError();
202
+ }
148
203
  const fetchImpl = deps.fetchImpl ?? fetch;
149
204
  const url = cloudflareUrl(pathname, query);
150
205
  const init = {
@@ -266,16 +321,27 @@ export function zoneCandidatesOf(domain) {
266
321
  * @throws {CLIError} when NO candidate is visible to this token.
267
322
  */
268
323
  export async function resolveZoneIdForDomain(domain, token, deps = {}) {
324
+ return (await resolveZoneForDomain(domain, token, deps)).id;
325
+ }
326
+ /**
327
+ * {@link resolveZoneIdForDomain}, returning the zone's NAME too (RWAY-005): a record whose host
328
+ * a provider hands RELATIVE to the zone (Railway's `verificationDnsHost`) is written absolute
329
+ * with the zone that really contains the domain ({@link absoluteRecordName}) โ€” never with a
330
+ * guessed apex nor a literal.
331
+ */
332
+ export async function resolveZoneForDomain(domain, token, deps = {}) {
269
333
  const candidates = zoneCandidatesOf(domain);
270
334
  for (const candidate of candidates) {
271
335
  try {
272
- return await resolveZoneIdForName(candidate, token, deps);
336
+ return { id: await resolveZoneIdForName(candidate, token, deps), name: candidate };
273
337
  }
274
338
  catch (error) {
275
339
  // Only "this zone is not visible" is a reason to try the next candidate.
276
- // An API-level failure (403 out-of-scope, 5xx) propagates untouched.
277
- if (error instanceof CloudflareApiError)
340
+ // An API-level failure (403 out-of-scope, 5xx) propagates untouched, and so does a
341
+ // missing test seam (it is not "zone not found").
342
+ if (error instanceof CloudflareApiError || error instanceof CloudflareSeamMissingError) {
278
343
  throw error;
344
+ }
279
345
  }
280
346
  }
281
347
  throw new CLIError(`No se encontrรณ en Cloudflare ninguna zona que contenga \`${domain}\` con el token usado.\n` +
@@ -283,6 +349,22 @@ export async function resolveZoneIdForDomain(domain, token, deps = {}) {
283
349
  'Verifica que el token estรฉ scopeado a la zona (DNS-edit) y reintenta con `--resume`; ' +
284
350
  'si usas `--dns=cliente-cf`, confirma que la API key del cliente cubre esa zona.');
285
351
  }
352
+ /**
353
+ * The ABSOLUTE record name for a host a provider gives RELATIVE to `zoneName` (RWAY-005 ยง8):
354
+ * `_railway-verify.mi-app` in `timekast.com` โ†’ `_railway-verify.mi-app.timekast.com`. A host
355
+ * that already ends in the zone (or is `@`/empty, the apex) is not suffixed again โ€” written
356
+ * as-is, Cloudflare would create it under the wrong name or duplicate the zone.
357
+ */
358
+ export function absoluteRecordName(host, zoneName) {
359
+ const zone = zoneName.replace(/\.$/, '').toLowerCase();
360
+ const bare = host.trim().replace(/\.$/, '');
361
+ if (bare === '' || bare === '@')
362
+ return zone;
363
+ const lower = bare.toLowerCase();
364
+ if (lower === zone || lower.endsWith(`.${zone}`))
365
+ return bare;
366
+ return `${bare}.${zone}`;
367
+ }
286
368
  /**
287
369
  * Find an existing CNAME record for `name` in the zone (issue ยง8 idempotency).
288
370
  * Returns the record when present (so the caller can detect a `proxied: true`
@@ -298,67 +380,161 @@ export async function findCnameRecord(zoneId, name, token, deps = {}) {
298
380
  return (data.result ?? []).find((r) => r.name === name && r.type === 'CNAME') ?? null;
299
381
  }
300
382
  /**
301
- * Create a CNAME record pointing `name` โ†’ {@link CNAME_VERCEL_TARGET} with
302
- * `proxied: false` and `ttl: 1` (auto) โ€” `POST /zones/{id}/dns_records` (issue ยง4).
303
- * `proxied: false` is non-negotiable: a proxied record would terminate TLS at
304
- * Cloudflare instead of letting Vercel issue+serve the cert for the domain.
383
+ * Create a CNAME record pointing `name` โ†’ `target` with `proxied: false` and `ttl: 1` (auto) โ€”
384
+ * `POST /zones/{id}/dns_records` (issue ยง4). `target` is the destination of the repo:
385
+ * {@link CNAME_VERCEL_TARGET} for Vercel, the domain's `requiredValue` for Railway (RWAY-005).
386
+ * `proxied: false` is non-negotiable: a proxied record would terminate TLS at Cloudflare instead
387
+ * of letting the hosting issue+serve the cert for the domain.
305
388
  *
389
+ * @returns the id Cloudflare gave the record (`null` if the answer did not carry one).
306
390
  * @throws {CloudflareApiError} on any non-2xx / `success: false`.
307
391
  */
308
- export async function createCnameRecord(zoneId, name, token, deps = {}) {
309
- await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, {
392
+ export async function createCnameRecord(zoneId, name, target, token, deps = {}) {
393
+ const data = (await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, {
310
394
  type: 'CNAME',
311
395
  name,
312
- content: CNAME_VERCEL_TARGET,
396
+ content: target,
313
397
  proxied: false,
314
398
  ttl: 1,
315
- });
399
+ }));
400
+ return typeof data.result?.id === 'string' ? data.result.id : null;
316
401
  }
317
402
  /**
318
403
  * PATCH an existing CNAME record to `proxied: false` โ€” `PATCH
319
- * /zones/{id}/dns_records/{recordId}` (issue ยง8). Used when a record was left
320
- * `proxied: true` by a manual edit: we flip the proxy flag in place. We NEVER
321
- * delete the record to "fix" it (a destructive change requires confirmation).
404
+ * /zones/{id}/dns_records/{recordId}` (issue ยง8). Used when a record was left `proxied: true`
405
+ * by a manual edit: we flip the proxy flag in place. `target` is sent as the content and MUST be
406
+ * the content the record already has โ€” {@link ensureCnameRecord} only calls this after checking
407
+ * it (a record pointing elsewhere stops instead: Decision 19). We NEVER delete the record to
408
+ * "fix" it (a destructive change requires confirmation).
322
409
  *
323
410
  * @throws {CloudflareApiError} on any non-2xx / `success: false`.
324
411
  */
325
- export async function unproxyCnameRecord(zoneId, recordId, name, token, deps = {}) {
412
+ export async function unproxyCnameRecord(zoneId, recordId, name, target, token, deps = {}) {
326
413
  await cloudflareApi('PATCH', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps, {
327
414
  type: 'CNAME',
328
415
  name,
329
- content: CNAME_VERCEL_TARGET,
416
+ content: target,
330
417
  proxied: false,
331
418
  });
332
419
  }
420
+ /** A CNAME destination normalized for comparison: no trailing dot, case-insensitive. */
421
+ function normalizeHostContent(value) {
422
+ return value.trim().replace(/\.$/, '').toLowerCase();
423
+ }
333
424
  /**
334
- * Idempotently ensure a `proxied: false` CNAME for `name` exists in the zone
335
- * (issue ยง4 + ยง8). Returns the outcome so the caller can narrate it (no-echo โ€”
336
- * a domain name is not a secret, but the token never crosses this boundary):
337
- * - `created` โ€” no record existed โ†’ POSTed a fresh `proxied: false` CNAME
338
- * - `unproxied` โ€” a `proxied: true` record existed โ†’ PATCHed it to `false`
339
- * - `unchanged` โ€” a `proxied: false` record already existed โ†’ nothing to do
340
- *
341
- * @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
425
+ * The shared core of {@link ensureCnameRecord} and {@link ensureTxtRecord} (RWAY-005): look the
426
+ * record up by type + name and compare its CONTENT with the expected one.
427
+ * - absent โ†’ POST (`created`);
428
+ * - same content, `proxied: true` โ†’ PATCH the proxy off, content unchanged (`unproxied`);
429
+ * - same content, `proxied: false` โ†’ nothing (`unchanged`);
430
+ * - ๐Ÿ”ด other content (proxied or not) โ†’ {@link DnsRecordConflictError}, with NO PATCH and NO
431
+ * DELETE (Decision 19).
432
+ * Returns the record id too, so the caller can record it in the state (ids, never values).
342
433
  */
343
- export async function ensureCnameRecord(zoneId, name, token, deps = {}) {
344
- const existing = await findCnameRecord(zoneId, name, token, deps);
434
+ export async function ensureUnproxiedRecord(zoneId, type, name, content, token, deps = {}) {
435
+ const existing = type === 'CNAME'
436
+ ? await findCnameRecord(zoneId, name, token, deps)
437
+ : await findDnsRecord(zoneId, 'TXT', name, token, deps);
345
438
  if (!existing) {
346
- await createCnameRecord(zoneId, name, token, deps);
347
- return 'created';
439
+ const id = type === 'CNAME'
440
+ ? await createCnameRecord(zoneId, name, content, token, deps)
441
+ : await createTxtRecord(zoneId, name, content, token, deps);
442
+ return { outcome: 'created', id };
443
+ }
444
+ const same = type === 'CNAME'
445
+ ? normalizeHostContent(existing.content) === normalizeHostContent(content)
446
+ : normalizeRecordContent(existing.content) === normalizeRecordContent(content);
447
+ if (!same) {
448
+ throw new DnsRecordConflictError(type, name, type === 'CNAME' ? existing.content : normalizeRecordContent(existing.content), content);
348
449
  }
349
450
  if (existing.proxied) {
350
- // A manually-edited record left proxied:true โ†’ flip it (never delete โ€” ยง8).
351
- await unproxyCnameRecord(zoneId, existing.id, name, token, deps);
352
- return 'unproxied';
451
+ // A manually-edited record left proxied:true โ†’ flip it, content untouched (never delete โ€” ยง8).
452
+ if (type === 'CNAME') {
453
+ await unproxyCnameRecord(zoneId, existing.id, name, content, token, deps);
454
+ }
455
+ else {
456
+ await cloudflareApi('PATCH', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(existing.id)}`, token, deps, { type, name, content, proxied: false });
457
+ }
458
+ return { outcome: 'unproxied', id: existing.id };
459
+ }
460
+ return { outcome: 'unchanged', id: existing.id };
461
+ }
462
+ /**
463
+ * Idempotently ensure a `proxied: false` CNAME `name โ†’ target` exists in the zone (issue ยง4 + ยง8,
464
+ * RWAY-005). Returns the outcome so the caller can narrate it (no-echo โ€” a domain name is not a
465
+ * secret, but the token never crosses this boundary):
466
+ * - `created` โ€” no record existed โ†’ POSTed a fresh `proxied: false` CNAME to `target`
467
+ * - `unproxied` โ€” a `proxied: true` record to `target` existed โ†’ PATCHed it to `false`
468
+ * - `unchanged` โ€” a `proxied: false` record to `target` already existed โ†’ nothing to do
469
+ *
470
+ * ๐Ÿ”ด A record pointing ANYWHERE ELSE (proxied or not) throws {@link DnsRecordConflictError}
471
+ * naming both destinations โ€” never `unchanged`, never rewritten (Decision 19).
472
+ *
473
+ * @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
474
+ */
475
+ export async function ensureCnameRecord(zoneId, name, target, token, deps = {}) {
476
+ return (await ensureUnproxiedRecord(zoneId, 'CNAME', name, target, token, deps)).outcome;
477
+ }
478
+ /**
479
+ * Create a TXT record โ€” `POST /zones/{id}/dns_records` (RWAY-005), for a provider's domain
480
+ * verification. A TXT cannot be proxied, so no `proxied` flag is sent (the same call shape the
481
+ * Resend records of {@link createDnsRecord} already use against the live API).
482
+ *
483
+ * @returns the id Cloudflare gave the record (`null` if the answer did not carry one).
484
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
485
+ */
486
+ async function createTxtRecord(zoneId, name, content, token, deps) {
487
+ const data = (await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, { type: 'TXT', name, content, ttl: 1 }));
488
+ return typeof data.result?.id === 'string' ? data.result.id : null;
489
+ }
490
+ /**
491
+ * Idempotently ensure the TXT `absoluteName โ†’ content` exists (RWAY-005) โ€” same four cases as
492
+ * {@link ensureCnameRecord}: absent โ†’ `created`; same value and proxied โ†’ `unproxied`; same value
493
+ * โ†’ `unchanged`; ๐Ÿ”ด ANOTHER value โ†’ {@link DnsRecordConflictError}, never overwritten. The value
494
+ * is compared without Cloudflare's surrounding quotes. `absoluteName` must already be absolute
495
+ * ({@link absoluteRecordName}).
496
+ *
497
+ * @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
498
+ */
499
+ export async function ensureTxtRecord(zoneId, absoluteName, content, token, deps = {}) {
500
+ return (await ensureUnproxiedRecord(zoneId, 'TXT', absoluteName, content, token, deps)).outcome;
501
+ }
502
+ /**
503
+ * Read ONE DNS record by id โ€” `GET /zones/{id}/dns_records/{recordId}` โ€” or `null` when Cloudflare
504
+ * answers 404 (already gone). Read-only. `--destroy` (Railway) calls it before
505
+ * {@link deleteDnsRecord}: the ids come from the committed state, which is low-trust, so the live
506
+ * record's `type` and `name` are checked against what the stack owns before anything is deleted.
507
+ *
508
+ * @throws {CloudflareApiError} on any non-2xx other than 404 / `success: false`.
509
+ */
510
+ export async function getDnsRecord(zoneId, recordId, token, deps = {}) {
511
+ let data;
512
+ try {
513
+ data = (await cloudflareApi('GET', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps));
353
514
  }
354
- return 'unchanged';
515
+ catch (error) {
516
+ if (error instanceof CloudflareApiError && error.status === 404)
517
+ return null;
518
+ throw error;
519
+ }
520
+ const record = data.result;
521
+ if (!record || typeof record.type !== 'string' || typeof record.name !== 'string') {
522
+ throw new CloudflareApiError(200, 'la respuesta no trae `type` y `name` del registro', `GET /zones/${zoneId}/dns_records/${recordId}`);
523
+ }
524
+ return {
525
+ id: typeof record.id === 'string' ? record.id : recordId,
526
+ type: record.type,
527
+ name: record.name,
528
+ content: typeof record.content === 'string' ? record.content : '',
529
+ proxied: record.proxied === true,
530
+ };
355
531
  }
356
532
  /**
357
533
  * Delete a DNS record by id โ€” `DELETE /zones/{id}/dns_records/{recordId}`
358
534
  * (BOOT-007). The ONE deletion in this module, and it is DELIBERATE: it runs only
359
535
  * inside a flow the human already confirmed (retiring the interim domain after the
360
- * client domain verified โ€” the orphaned interim CNAME would otherwise resolve to
361
- * {@link CNAME_VERCEL_TARGET} and serve a 404 forever). It is NEVER used to
536
+ * client domain verified โ€” the orphaned interim CNAME would otherwise keep resolving to
537
+ * the hosting's target and serve a 404 forever). It is NEVER used to
362
538
  * recover from a failed call โ€” the module-header contract stands.
363
539
  *
364
540
  * Mirrors `removeVercelDomain`'s 404 tolerance: a record already gone (deleted by