@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.
- package/dist/commands/backlog.js +52 -15
- package/dist/commands/doctor.js +210 -29
- package/dist/commands/env.js +154 -18
- package/dist/commands/invite-admin.js +62 -15
- package/dist/commands/provision.js +5815 -763
- package/dist/commands/status.js +12 -2
- package/dist/commands/ticket.js +4 -4
- package/dist/commands/update.js +97 -12
- package/dist/commands/vault.js +2627 -0
- package/dist/index.js +97 -33
- package/dist/lib/backlog-mapping.js +111 -18
- package/dist/lib/backlog-parse.js +1 -1
- package/dist/lib/claude-md.js +320 -0
- package/dist/lib/cloudflare-api.js +213 -37
- package/dist/lib/constants.js +70 -6
- package/dist/lib/env-file.js +45 -7
- package/dist/lib/env-wizard.js +66 -2
- package/dist/lib/initial-migration.js +3 -2
- package/dist/lib/neon-api.js +145 -10
- package/dist/lib/package-json.js +37 -16
- package/dist/lib/preflight.js +92 -38
- package/dist/lib/provision-preflight.js +158 -100
- package/dist/lib/provision-state.js +652 -20
- package/dist/lib/publish-engine.js +1 -1
- package/dist/lib/rail.js +400 -0
- package/dist/lib/railway-api.js +851 -0
- package/dist/lib/vault-adopt.js +1495 -0
- package/dist/lib/vault-api.js +1106 -0
- package/dist/lib/vault-config.js +21 -0
- package/dist/lib/vault-deploys.js +898 -0
- package/dist/lib/vercel-env.js +251 -31
- package/dist/lib/wrapper-wiring.js +160 -0
- package/dist/tui/env-wizard-flow.js +17 -3
- package/dist/tui/menu.js +4 -22
- package/dist/tui/provision-flow.js +68 -13
- package/package.json +1 -1
- package/dist/commands/secrets.js +0 -71
- 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
|
|
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
|
|
27
|
-
*
|
|
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
|
-
|
|
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` โ
|
|
302
|
-
* `
|
|
303
|
-
*
|
|
304
|
-
*
|
|
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:
|
|
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
|
-
*
|
|
321
|
-
*
|
|
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:
|
|
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
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* - `
|
|
338
|
-
* -
|
|
339
|
-
* -
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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
|
|
344
|
-
const existing =
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
*
|
|
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
|