@timekast/factory 1.18.0 → 1.20.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Mapping tables + credential resolution for the Factory ↔ backlog.timekast.mx
3
+ * integration — the shared translation layer between the LOCAL backlog vocabulary
4
+ * (the emoji+word `Status:`, `P0..P3` priority, integer Story Points, `must/should/
5
+ * could` MoSCoW that live in issue/epic markdown) and the REMOTE enums the API
6
+ * accepts (`backlog-api.ts`). Both `push` (BSYNC-005) and `sync` (BSYNC-006)
7
+ * consume this ONE module so neither hand-rolls the translation.
8
+ *
9
+ * Two design rules the issue (§4) pins down:
10
+ * - Every axis (status/priority/points/moscow) is a DATA TABLE, never scattered
11
+ * if/else — a reviewer reads the bijection at a glance.
12
+ * - Status is a strict 6↔6 BIJECTION: each of the 6 local buckets has exactly one
13
+ * remote destination and vice-versa (asserted by the unit tests).
14
+ *
15
+ * Local status buckets mirror `scripts/tools/update-board.ts` `parseStatus()` (SSOT
16
+ * vocabulary: `.claude/skills/tk-backlog/methodology/issue-shape.md`). `Deferred`
17
+ * and `Postponed` are synonyms that both bucket to `postponed` locally → `deferred`
18
+ * remotely, keeping the 6↔6 bijection intact.
19
+ */
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ /**
22
+ * LOCAL bucket → REMOTE `WorkStatus`. Half of the bijection; {@link STATUS_REMOTE_TO_LOCAL}
23
+ * is its exact inverse. The 6 remote states were live-verified (smoke Hallazgo 3).
24
+ */
25
+ export const STATUS_LOCAL_TO_REMOTE = {
26
+ todo: 'todo',
27
+ 'in-progress': 'in_progress',
28
+ done: 'done',
29
+ 'wont-do': 'wont_do',
30
+ postponed: 'deferred',
31
+ blocked: 'blocked',
32
+ };
33
+ /** REMOTE `WorkStatus` → LOCAL bucket. Exact inverse of {@link STATUS_LOCAL_TO_REMOTE}. */
34
+ export const STATUS_REMOTE_TO_LOCAL = {
35
+ todo: 'todo',
36
+ in_progress: 'in-progress',
37
+ done: 'done',
38
+ wont_do: 'wont-do',
39
+ deferred: 'postponed',
40
+ blocked: 'blocked',
41
+ };
42
+ /** Translate a local status bucket to the remote `WorkStatus` the API PATCH accepts. */
43
+ export function mapStatusToRemote(local) {
44
+ return STATUS_LOCAL_TO_REMOTE[local];
45
+ }
46
+ /** Translate a remote `WorkStatus` (from `sync-state`) back to the local bucket for the diff. */
47
+ export function mapStatusToLocal(remote) {
48
+ return STATUS_REMOTE_TO_LOCAL[remote];
49
+ }
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // Priority — P0..P3 → p0..p3 (data table)
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ /** LOCAL `P0..P3` → REMOTE `p0..p3` (FCT-005 — an axis independent of MoSCoW). */
54
+ export const PRIORITY_LOCAL_TO_REMOTE = {
55
+ P0: 'p0',
56
+ P1: 'p1',
57
+ P2: 'p2',
58
+ P3: 'p3',
59
+ };
60
+ /**
61
+ * Map a raw `Priority:` value (`P1`, `P0 (Crítico)`, …) to the remote enum. Extracts
62
+ * the `P0..P3` token defensively; anything without one → `undefined` (the push then
63
+ * omits `priority` rather than sending a bogus value).
64
+ */
65
+ export function mapPriority(raw) {
66
+ if (!raw)
67
+ return undefined;
68
+ const match = raw.toUpperCase().match(/P[0-3]/);
69
+ return match ? PRIORITY_LOCAL_TO_REMOTE[match[0]] : undefined;
70
+ }
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // MoSCoW — must/should/could (data table)
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ /** The three MoSCoW commitments that ever reach the backlog (`won't` never pushes). */
75
+ export const MOSCOW_VALUES = ['must', 'should', 'could'];
76
+ /**
77
+ * Normalize a raw `MoSCoW:` value to the remote enum. A `—` / empty / unknown value
78
+ * (a legacy issue without the BSYNC-007 field, or a placeholder) → `undefined`, so
79
+ * the push omits `moscow` instead of guessing.
80
+ */
81
+ export function mapMoscow(raw) {
82
+ if (!raw)
83
+ return undefined;
84
+ const value = raw.trim().toLowerCase();
85
+ return MOSCOW_VALUES.includes(value)
86
+ ? value
87
+ : undefined;
88
+ }
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ // Story points — canonical scale (data table)
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+ /** The canonical Fibonacci-ish Story Points scale (DoR: 1/2/3/5/8/13). */
93
+ export const STORY_POINTS_SCALE = [1, 2, 3, 5, 8, 13];
94
+ /** Whether a value sits on the canonical {@link STORY_POINTS_SCALE}. */
95
+ export function isCanonicalStoryPoints(sp) {
96
+ return STORY_POINTS_SCALE.includes(sp);
97
+ }
98
+ /**
99
+ * Map local Story Points to the remote `points` field. A positive integer passes
100
+ * through unchanged (fidelity — we never re-bucket the team's estimate); `0`/missing
101
+ * → `null`, which the API treats as "no points" (epics/features carry `null` too,
102
+ * smoke Hallazgo 4). Non-canonical positive values (e.g. `4`) pass through as-is;
103
+ * {@link isCanonicalStoryPoints} lets the caller warn without dropping the value.
104
+ */
105
+ export function mapStoryPoints(sp) {
106
+ return Number.isInteger(sp) && sp > 0 ? sp : null;
107
+ }
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+ // Credential resolution — fail-closed, NEVER throws (issue §4)
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ /** The env keys the derivative's `.env.local` carries for the backlog integration. */
112
+ export const BACKLOG_API_KEY_VAR = 'BACKLOG_API_KEY';
113
+ export const BACKLOG_PROJECT_ID_VAR = 'BACKLOG_PROJECT_ID';
114
+ /** Default FS reader — reads `.env.local` if present, swallowing any error to `null`. */
115
+ function defaultReadFile(filePath) {
116
+ try {
117
+ return existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /** Read one `KEY=value` from an env-file body, stripping a matching quote pair. */
124
+ function readEnvValue(body, key) {
125
+ const match = body.match(new RegExp(`^${key}=(.*)$`, 'm'));
126
+ if (!match)
127
+ return undefined;
128
+ let value = match[1].trim();
129
+ if (value.length >= 2 &&
130
+ ((value.startsWith('"') && value.endsWith('"')) ||
131
+ (value.startsWith("'") && value.endsWith("'")))) {
132
+ value = value.slice(1, -1);
133
+ }
134
+ return value.length > 0 ? value : undefined;
135
+ }
136
+ /**
137
+ * Resolve the per-project backlog credentials from `{rootDir}/.env.local`. FAIL-CLOSED
138
+ * (issue §4): a missing file, an unreadable file, or a missing/empty `BACKLOG_API_KEY`
139
+ * / `BACKLOG_PROJECT_ID` all return the distinctive `not-configured` result — this
140
+ * function NEVER throws, so a derivative that never provisioned the backlog service
141
+ * does not crash `push`/`sync`; they just skip. The `readFile` seam is injectable so
142
+ * tests drive every branch without disk.
143
+ */
144
+ export function resolveBacklogCredentials(rootDir, deps = {}) {
145
+ const read = deps.readFile ?? defaultReadFile;
146
+ let body;
147
+ try {
148
+ body = read(`${rootDir.replace(/\/+$/, '')}/.env.local`);
149
+ }
150
+ catch {
151
+ // A throwing injected reader must still fail closed, never propagate.
152
+ body = null;
153
+ }
154
+ if (body === null) {
155
+ return {
156
+ status: 'not-configured',
157
+ reason: 'No se encontró `.env.local`; el backlog central no está configurado.',
158
+ };
159
+ }
160
+ const apiKey = readEnvValue(body, BACKLOG_API_KEY_VAR);
161
+ const projectId = readEnvValue(body, BACKLOG_PROJECT_ID_VAR);
162
+ if (!apiKey || !projectId) {
163
+ const missing = [
164
+ !apiKey ? BACKLOG_API_KEY_VAR : null,
165
+ !projectId ? BACKLOG_PROJECT_ID_VAR : null,
166
+ ]
167
+ .filter(Boolean)
168
+ .join(' + ');
169
+ return {
170
+ status: 'not-configured',
171
+ reason: `Falta ${missing} en \`.env.local\`; el backlog central no está configurado.`,
172
+ };
173
+ }
174
+ return { status: 'configured', apiKey, projectId };
175
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Parser for the backlog issue/epic markdown blockquote header — the shared
3
+ * reader `push` (BSYNC-005) and `sync` (BSYNC-006) use instead of each re-deriving
4
+ * how to pull `Issue ID` / `Status` / `Priority` / `Story Points` / `Epic` /
5
+ * `Skills` / `Depends on` / `Board` (+ the BSYNC-007 `Backlog UUID` / `MoSCoW`) out
6
+ * of a `> **Label:** value` blockquote.
7
+ *
8
+ * The regex shape is CALQUED from `scripts/tools/update-board.ts` (`parseStatus` /
9
+ * `parseStoryPoints` / `parseEpic` / `parseTitle`) — same `[>-] \*\*(Label):\*\*`
10
+ * anchor — so the two parsers can never drift on the tooling-critical fields. It is
11
+ * NOT a re-implementation of a new format: it extends the same one.
12
+ *
13
+ * DEFENSIVE TOLERANCE (issue §8): the BSYNC-007 lines (`Backlog UUID` / `MoSCoW` /
14
+ * `Board`) do not exist in legacy issues. Every field parser degrades to a default
15
+ * (`Board` → `story`, `MoSCoW` → `undefined`, `Backlog UUID` → the deterministic v5
16
+ * fallback) when its line is absent, and the presence of the new lines never breaks
17
+ * the existing fields. This module is PURE (operates on a `content: string`), so the
18
+ * parser itself needs no FS seam; the one disk touch (credential resolution) lives in
19
+ * `backlog-mapping.ts` behind an injectable reader.
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { mapMoscow } from './backlog-mapping.js';
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // Field-level regex helpers (calqued from update-board.ts)
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ /** Escape a label for safe interpolation into a `RegExp` (labels are literal text). */
27
+ function escapeLabel(label) {
28
+ return label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+ /**
31
+ * Capture the value of a LINE-START blockquote field: `> **Label:** value`. Mirrors
32
+ * `update-board.ts`'s `[>-] \*\*Label:\*\*` anchor (accepts `>` or `-` bullet). Returns
33
+ * the trimmed rest of the line, or `undefined` if the field is absent.
34
+ *
35
+ * NOTE: fields that share a line with another via a `·` separator (e.g. `Depends on`
36
+ * sits beside `Parallelizable`) capture the whole tail here; the field-specific parser
37
+ * splits on `·`. `Story Points` is the one MID-LINE field (`**Effort:** M · **Story
38
+ * Points:** 5`) and uses its own loose regex below, exactly as `update-board.ts` does.
39
+ */
40
+ export function matchBlockquoteField(content, label) {
41
+ const match = content.match(new RegExp(`[>-] \\*\\*${escapeLabel(label)}:\\*\\* ([^\\n]*)`, 'i'));
42
+ return match ? match[1].trim() : undefined;
43
+ }
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Individual field parsers
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ /**
48
+ * Bucket the `> **Status:**` line to a {@link LocalStatus} — calqued VERBATIM from
49
+ * `update-board.ts` `parseStatus()` (including the Spanish synonyms + `Deferred`/
50
+ * `Postponed` both bucketing to `postponed`). Default `todo`.
51
+ */
52
+ export function parseStatus(content) {
53
+ if (content.match(/[>-] \*\*(Status|Estado):\*\*.*(✅|Completed|Completado|Done)/i))
54
+ return 'done';
55
+ if (content.match(/[>-] \*\*(Status|Estado):\*\*.*(In Progress|En Progreso|🚧|Working)/i))
56
+ return 'in-progress';
57
+ if (content.match(/[>-] \*\*(Status|Estado):\*\*.*(Won't Do|Wont Do|Cancelled|Cancelado|❌|No se hará)/i))
58
+ return 'wont-do';
59
+ if (content.match(/[>-] \*\*(Status|Estado):\*\*.*(Postponed|Pospuesto|Deferred|Diferido|⏸️|Post-MVP)/i))
60
+ return 'postponed';
61
+ if (content.match(/[>-] \*\*(Status|Estado):\*\*.*(Blocked|Bloqueado|🚫)/i))
62
+ return 'blocked';
63
+ return 'todo';
64
+ }
65
+ /** Parse Story Points (mid-line field). Calqued from `update-board.ts`. 0 if missing/invalid. */
66
+ export function parseStoryPoints(content) {
67
+ const match = content.match(/\*\*(Story Points|SP):\*\* (\d+)/i);
68
+ if (!match)
69
+ return 0;
70
+ const value = parseInt(match[2], 10);
71
+ return Number.isNaN(value) ? 0 : value;
72
+ }
73
+ /** Parse the raw `> **Priority:**` value (e.g. `P1`). `undefined` if absent. */
74
+ export function parsePriority(content) {
75
+ return matchBlockquoteField(content, 'Priority') ?? matchBlockquoteField(content, 'Prioridad');
76
+ }
77
+ /** Parse the raw `> **Epic:**` value (the markdown link/text). `undefined` if absent. */
78
+ export function parseEpic(content) {
79
+ return matchBlockquoteField(content, 'Epic');
80
+ }
81
+ /**
82
+ * Parse the `Issue ID` — the meta field (`> **Issue ID:** ID`) preferred, falling back
83
+ * to the H1 prefix (`# ID: title`) like `update-board.ts` does (handles post-rename
84
+ * mismatch). `undefined` only for a malformed file with neither.
85
+ */
86
+ export function parseIssueId(content) {
87
+ const meta = content.match(/[>-] \*\*Issue ID:\*\* ([\w-]+)/i);
88
+ if (meta)
89
+ return meta[1];
90
+ const h1 = content.match(/^# ([\w-]+): /m);
91
+ return h1 ? h1[1] : undefined;
92
+ }
93
+ /** Parse the bare title text from the H1 (`# ID: title` → `title`). `undefined` if absent. */
94
+ export function parseTitle(content) {
95
+ const match = content.match(/^# [\w-]+: (.*)$/m);
96
+ return match ? match[1].trim() : undefined;
97
+ }
98
+ /** Parse `> **Skills:**` into its backtick-quoted tokens (`` `sk-api`, `kb-api` `` → [...]). */
99
+ export function parseSkills(content) {
100
+ const line = matchBlockquoteField(content, 'Skills');
101
+ if (!line)
102
+ return [];
103
+ return Array.from(line.matchAll(/`([^`]+)`/g), (m) => m[1]);
104
+ }
105
+ /**
106
+ * Parse `> **Depends on:**` into single-token issue IDs. The field shares a line with
107
+ * `Parallelizable` (`BSYNC-001 · **Parallelizable:** no`), so we take the segment before
108
+ * the `·`, then split on comma/space. A `—` (no deps) yields `[]`.
109
+ */
110
+ export function parseDependsOn(content) {
111
+ const line = matchBlockquoteField(content, 'Depends on');
112
+ if (!line)
113
+ return [];
114
+ const head = line.split('·')[0];
115
+ return head
116
+ .split(/[\s,]+/)
117
+ .map((token) => token.trim())
118
+ .filter((token) => token.length > 0 && token !== '—' && token !== '-');
119
+ }
120
+ /** The element boards an ISSUE can declare (`> **Board:**`) — epics/features are separate. */
121
+ const ISSUE_BOARD_VALUES = ['story', 'task', 'bug'];
122
+ /**
123
+ * Parse `> **Board:**` to an element type. Defaults to `story` (the BSYNC-007 template
124
+ * default) when the line is absent (legacy issue) or carries an unknown value.
125
+ */
126
+ export function parseBoard(content) {
127
+ const raw = matchBlockquoteField(content, 'Board')?.toLowerCase();
128
+ return raw && ISSUE_BOARD_VALUES.includes(raw)
129
+ ? raw
130
+ : 'story';
131
+ }
132
+ /** Parse `> **MoSCoW:**` to the remote enum (`must`/`should`/`could`); `undefined` if `—`/absent. */
133
+ export function parseMoscow(content) {
134
+ return mapMoscow(matchBlockquoteField(content, 'MoSCoW'));
135
+ }
136
+ /** UUID string shape (any version) — used to validate a stamped `Backlog UUID`. */
137
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
138
+ /**
139
+ * Parse a stamped `> **Backlog UUID:**` value. Returns the UUID only when it is a real
140
+ * UUID — a `{{uuid}}` placeholder, a `—`, or an absent line (legacy issue) → `undefined`
141
+ * (the caller then falls back to {@link deterministicElementUuid}).
142
+ */
143
+ export function parseBacklogUuid(content) {
144
+ const raw = matchBlockquoteField(content, 'Backlog UUID');
145
+ return raw && UUID_RE.test(raw) ? raw.toLowerCase() : undefined;
146
+ }
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // Deterministic UUID v5 fallback (no new dependency — uses node:crypto SHA-1)
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+ /**
151
+ * The FIXED namespace for the Factory backlog's deterministic UUIDs. 🔴 NEVER change
152
+ * this value — it seeds every legacy issue's fallback id; changing it would re-map
153
+ * every un-stamped element to a NEW remote element on the next push (duplication).
154
+ * A named constant (not a magic literal): CODING.md §5.
155
+ */
156
+ export const BACKLOG_UUID_NAMESPACE = '9e3f8b7a-2c4d-4e5f-8a1b-6c7d8e9f0a1b';
157
+ /** 16 raw bytes of a UUID string. */
158
+ function uuidToBytes(uuid) {
159
+ return Buffer.from(uuid.replace(/-/g, ''), 'hex');
160
+ }
161
+ /** 16 raw bytes → canonical UUID string. */
162
+ function bytesToUuid(bytes) {
163
+ const hex = bytes.toString('hex');
164
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
165
+ }
166
+ /**
167
+ * Compute a DETERMINISTIC RFC-4122 v5 (SHA-1) UUID from a stable `name` under
168
+ * {@link BACKLOG_UUID_NAMESPACE}. Same input → same UUID on every run (issue §4/§8):
169
+ * this is what lets `push` re-run without duplicating a legacy (un-stamped) element —
170
+ * the collision would otherwise surface as a `500` (smoke Hallazgo 3), indistinguishable
171
+ * from a transient error, so determinism (not the server) is what guarantees idempotency.
172
+ * Implemented on `node:crypto` (SHA-1) — no `uuid` dependency added.
173
+ */
174
+ export function deterministicElementUuid(name) {
175
+ const hash = createHash('sha1')
176
+ .update(Buffer.concat([uuidToBytes(BACKLOG_UUID_NAMESPACE), Buffer.from(name, 'utf8')]))
177
+ .digest();
178
+ const bytes = hash.subarray(0, 16);
179
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
180
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC-4122 variant
181
+ return bytesToUuid(bytes);
182
+ }
183
+ /**
184
+ * Parse one issue's markdown into a normalized {@link ParsedIssue}. Tolerant of legacy
185
+ * issues (BSYNC-007 fields absent → defaults) and of the new lines being present
186
+ * (they never break the existing fields). The effective `uuid` is the stamped
187
+ * `Backlog UUID` or the deterministic fallback keyed on the Issue ID.
188
+ */
189
+ export function parseIssue(content) {
190
+ const issueId = parseIssueId(content);
191
+ const title = parseTitle(content);
192
+ const backlogUuid = parseBacklogUuid(content);
193
+ const uuid = backlogUuid ?? deterministicElementUuid(issueId ?? title ?? content.trim());
194
+ return {
195
+ issueId,
196
+ title,
197
+ status: parseStatus(content),
198
+ priority: parsePriority(content),
199
+ storyPoints: parseStoryPoints(content),
200
+ epic: parseEpic(content),
201
+ skills: parseSkills(content),
202
+ dependsOn: parseDependsOn(content),
203
+ board: parseBoard(content),
204
+ moscow: parseMoscow(content),
205
+ backlogUuid,
206
+ uuid,
207
+ };
208
+ }
@@ -16,8 +16,12 @@
16
16
  * actionable `CLIError` (issue §8 Gherkin) — it NEVER deletes a record to recover.
17
17
  *
18
18
  * What this module does NOT do:
19
- * - Delete a DNS record. The idempotent path is create-or-PATCH; an existing
20
- * record is never removed without explicit human confirmation (issue §8).
19
+ * - Delete a DNS record to RECOVER from an error. The idempotent provisioning
20
+ * path is create-or-PATCH; a record is never removed to "fix" a failed call
21
+ * (issue §8). The ONE deletion shipped here ({@link deleteDnsRecord},
22
+ * BOOT-007) is DELIBERATE cleanup inside a flow a human already confirmed
23
+ * (retiring the interim domain after the client domain went live) — it is
24
+ * never an automatic recovery move.
21
25
  * - Read or log a token value. The token arrives as an argument and is handed
22
26
  * straight to the `Authorization` header; it never reaches stdout.
23
27
  *
@@ -241,3 +245,109 @@ export async function ensureCnameRecord(zoneId, name, token, deps = {}) {
241
245
  }
242
246
  return 'unchanged';
243
247
  }
248
+ /**
249
+ * Delete a DNS record by id — `DELETE /zones/{id}/dns_records/{recordId}`
250
+ * (BOOT-007). The ONE deletion in this module, and it is DELIBERATE: it runs only
251
+ * inside a flow the human already confirmed (retiring the interim domain after the
252
+ * client domain verified — the orphaned interim CNAME would otherwise resolve to
253
+ * {@link CNAME_VERCEL_TARGET} and serve a 404 forever). It is NEVER used to
254
+ * recover from a failed call — the module-header contract stands.
255
+ *
256
+ * Mirrors `removeVercelDomain`'s 404 tolerance: a record already gone (deleted by
257
+ * hand or by a previous run) is `absent`, not an error — the cleanup is idempotent.
258
+ *
259
+ * @throws {CloudflareApiError} on any non-2xx other than 404 / `success: false`.
260
+ */
261
+ export async function deleteDnsRecord(zoneId, recordId, token, deps = {}) {
262
+ try {
263
+ await cloudflareApi('DELETE', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps);
264
+ return 'removed';
265
+ }
266
+ catch (error) {
267
+ if (error instanceof CloudflareApiError && error.status === 404)
268
+ return 'absent';
269
+ throw error;
270
+ }
271
+ }
272
+ /**
273
+ * Normalize a record content for comparison (issue §8 idempotency). Cloudflare
274
+ * returns TXT content wrapped in double quotes (`"v=spf1 …"`) while Resend hands
275
+ * it bare — a byte-compare would false-positive a "different content" and PATCH
276
+ * on every re-run. Stripping ONE layer of surrounding quotes + trimming makes the
277
+ * re-run comparison stable without touching the value actually stored.
278
+ */
279
+ function normalizeRecordContent(value) {
280
+ const trimmed = value.trim();
281
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
282
+ return trimmed.slice(1, -1);
283
+ }
284
+ return trimmed;
285
+ }
286
+ /**
287
+ * Find an existing record of `spec.type` for `name` in the zone (issue §8
288
+ * idempotency) — same lookup shape as {@link findCnameRecord}, parameterized on
289
+ * the type. Returns the record (so the caller can compare content and PATCH a
290
+ * rotated value) or `null` when absent.
291
+ *
292
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
293
+ */
294
+ export async function findDnsRecord(zoneId, type, name, token, deps = {}) {
295
+ const data = (await cloudflareApi('GET', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, undefined, { type, name }));
296
+ return (data.result ?? []).find((r) => r.name === name && r.type === type) ?? null;
297
+ }
298
+ /**
299
+ * Create a TXT/MX record — `POST /zones/{id}/dns_records` (BOOT-003 §4), same
300
+ * call shape as {@link createCnameRecord} (`ttl: 1` = auto). TXT/MX cannot be
301
+ * proxied, so no `proxied` flag is sent. `priority` rides along for MX only.
302
+ *
303
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
304
+ */
305
+ export async function createDnsRecord(zoneId, spec, token, deps = {}) {
306
+ await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, {
307
+ type: spec.type,
308
+ name: spec.name,
309
+ content: spec.content,
310
+ ttl: 1,
311
+ ...(spec.priority !== undefined ? { priority: spec.priority } : {}),
312
+ });
313
+ }
314
+ /**
315
+ * PATCH an existing record to the content/priority the spec demands — `PATCH
316
+ * /zones/{id}/dns_records/{recordId}` (issue §8: a DKIM rotation leaves a record
317
+ * with STALE content; we repoint it in place). Same principle as
318
+ * {@link unproxyCnameRecord}: we NEVER delete a record to "fix" it.
319
+ *
320
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
321
+ */
322
+ export async function patchDnsRecord(zoneId, recordId, spec, token, deps = {}) {
323
+ await cloudflareApi('PATCH', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps, {
324
+ type: spec.type,
325
+ name: spec.name,
326
+ content: spec.content,
327
+ ...(spec.priority !== undefined ? { priority: spec.priority } : {}),
328
+ });
329
+ }
330
+ /**
331
+ * Idempotently ensure a TXT/MX record matching `spec` exists in the zone
332
+ * (BOOT-003 §4 + §8) — the TXT/MX sibling of {@link ensureCnameRecord}. Returns
333
+ * the outcome so the caller can narrate it:
334
+ * - `created` — no record existed → POSTed a fresh one
335
+ * - `updated` — a record existed with DIFFERENT content/priority (e.g. a DKIM
336
+ * rotation) → PATCHed it in place (never deleted — §8)
337
+ * - `unchanged` — a record with the same content already existed → nothing to do
338
+ *
339
+ * @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
340
+ */
341
+ export async function ensureDnsRecord(zoneId, spec, token, deps = {}) {
342
+ const existing = await findDnsRecord(zoneId, spec.type, spec.name, token, deps);
343
+ if (!existing) {
344
+ await createDnsRecord(zoneId, spec, token, deps);
345
+ return 'created';
346
+ }
347
+ const sameContent = normalizeRecordContent(existing.content) === normalizeRecordContent(spec.content);
348
+ const samePriority = spec.priority === undefined || existing.priority === spec.priority;
349
+ if (sameContent && samePriority)
350
+ return 'unchanged';
351
+ await patchDnsRecord(zoneId, existing.id, spec, token, deps);
352
+ return 'updated';
353
+ }
@@ -168,6 +168,29 @@ export const GENERATOR_SCRIPTS = [
168
168
  export const GENERATE_REFERENCE_NAME = 'generate:reference';
169
169
  export const GENERATE_REFERENCE_CMD = 'tsx scripts/tools/generate-inventory.ts && tsx scripts/tools/generate-codebase.ts && tsx scripts/tools/generate-hooks.ts && tsx scripts/tools/generate-schema.ts && tsx scripts/tools/generate-api.ts';
170
170
  export const GENERATE_REFERENCE_GATE_FILE = 'scripts/tools/generate-schema.ts';
171
+ /**
172
+ * The `ports` block of a derivative's `package.json`, and the one key inside it the CLI
173
+ * maintains.
174
+ *
175
+ * `SHIPPED_E2E_PORT` is the value the kit's boilerplate USED to carry (`"e2e": 3006`). It is
176
+ * the ONLY value `emptyShippedE2EPort` is allowed to overwrite — the same discipline as
177
+ * `OBSOLETE_SCRIPT_CMDS` above: heal a known-obsolete kit value, never touch one the dev
178
+ * chose. Since `package.json` is frozen at bootstrap in a derivative (BR-FACTORY-006) and is
179
+ * not part of the tracked file set, the CLI is the only thing that can retire it.
180
+ *
181
+ * `PORTS_E2E_COMMENT_KEY` is how a comment is spelled in a file format that has none: a
182
+ * SIBLING key (the `"//"` convention npm itself documents), so the emptied value carries its
183
+ * own explanation instead of looking like a mistake. The text deliberately names no port
184
+ * range — the range lives in the runner, which ships on a different track (`scripts/**` via
185
+ * `factory update`) than this CLI (npm), so a number repeated here would drift.
186
+ */
187
+ export const SHIPPED_E2E_PORT = 3006;
188
+ export const PORTS_KEY = 'ports';
189
+ export const PORTS_E2E_KEY = 'e2e';
190
+ export const PORTS_E2E_COMMENT_KEY = '//e2e';
191
+ export const PORTS_E2E_COMMENT = "Empty on purpose: the e2e runner derives a port from this checkout's absolute path, so " +
192
+ 'two checkouts can run their suites at the same time. Put a number here to pin one, or ' +
193
+ 'export E2E_PORT for a single run.';
171
194
  /**
172
195
  * Update-time healing registry: for each kit-owned script, the prior command(s)
173
196
  * the kit used to ship. When the kit changes HOW a script is invoked (e.g. the
@@ -39,6 +39,12 @@ export const PROVISION_OWNED = new Set([
39
39
  // Secrets; they must NEVER reach Vercel (a day-2 push would leak the Neon key).
40
40
  'NEON_API_KEY',
41
41
  'NEON_PROJECT_ID',
42
+ // LOCAL-ONLY backlog-central credentials (BSYNC-003): the derivative's `backlog
43
+ // push`/`sync` read them from `.env.local`. The `read_write` key is scoped to the
44
+ // project and must NEVER reach Vercel (a day-2 push would leak it). `env:push` skips
45
+ // both; provision writes them per-derivative to the gitignored `.env.local`.
46
+ 'BACKLOG_API_KEY',
47
+ 'BACKLOG_PROJECT_ID',
42
48
  ]);
43
49
  /**
44
50
  * Replace the value of `KEY=...` in an env-file body, preserving every other line.
@@ -78,10 +84,13 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
78
84
  body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
79
85
  body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
80
86
  body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
81
- writeFileSync(localPath, body, 'utf8');
82
- // `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600). Same
83
- // discipline the secrets rail uses; matters on the headless multi-tenant
84
- // Agent Server where the default umask would leave it world-readable.
87
+ // `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600) from birth.
88
+ // The `mode: 0o600` makes the file created 0600 with no world-readable window
89
+ // between create and chmod — critical on the headless multi-tenant Agent Server
90
+ // where the default umask would otherwise leave one tenant's secrets briefly
91
+ // readable by others. `mode` only applies when the file is CREATED, so the
92
+ // chmod below still covers the `--force` overwrite path (file already exists).
93
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
85
94
  chmodSync(localPath, 0o600);
86
95
  return 'written';
87
96
  }
@@ -104,7 +113,7 @@ export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
104
113
  let body = readFileSync(localPath, 'utf8');
105
114
  body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
106
115
  body = setEnvLine(body, 'NEON_API_KEY', apiKey);
107
- writeFileSync(localPath, body, 'utf8');
116
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
108
117
  chmodSync(localPath, 0o600); // holds NEON_API_KEY → owner-only.
109
118
  return true;
110
119
  }
@@ -133,7 +142,43 @@ export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
133
142
  body = setEnvLine(body, 'EMAIL_PROVIDER', 'resend');
134
143
  body = setEnvLine(body, 'RESEND_API_KEY', resendKey);
135
144
  body = setEnvLine(body, 'EMAIL_FROM', emailFrom);
136
- writeFileSync(localPath, body, 'utf8');
145
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
137
146
  chmodSync(localPath, 0o600); // holds RESEND_API_KEY → owner-only.
138
147
  return true;
139
148
  }
149
+ /**
150
+ * Write the backlog-central credentials into `.env.local` (BSYNC-003): the minted
151
+ * per-project `read_write` key (`BACKLOG_API_KEY`) + the project id
152
+ * (`BACKLOG_PROJECT_ID`), so the derivative's `backlog push`/`sync` can talk to
153
+ * backlog.timekast.mx. Both are PROVISION_OWNED → LOCAL-ONLY, never pushed to Vercel.
154
+ *
155
+ * DIVERGENCE from {@link writeE2eLocalCreds}/{@link writeEmailLocalCreds} (which
156
+ * no-op when `.env.local` is absent, relying on {@link writeEnvLocal} to create it in
157
+ * the Vercel step): `backlog` can be provisioned STANDALONE (`--services=backlog`),
158
+ * with no Neon/Vercel step to seed `.env.local` first — so this CREATES `.env.local`
159
+ * (from `.env.example` when present, else a bare file) if it does not exist, then
160
+ * fills the two keys. Safe on an existing file: `setEnvLine` only touches these two
161
+ * keys, preserving the rest. Returns false only when the key/project id is missing —
162
+ * the key VALUE is never logged (no-echo; the caller prints only key NAMES).
163
+ */
164
+ export function writeBacklogLocalCreds(rootDir, projectId, apiKey) {
165
+ if (!apiKey || !projectId)
166
+ return false;
167
+ const localPath = join(rootDir, '.env.local');
168
+ let body = '';
169
+ if (existsSync(localPath)) {
170
+ body = readFileSync(localPath, 'utf8');
171
+ }
172
+ else {
173
+ const examplePath = join(rootDir, '.env.example');
174
+ if (existsSync(examplePath))
175
+ body = readFileSync(examplePath, 'utf8');
176
+ }
177
+ body = setEnvLine(body, 'BACKLOG_PROJECT_ID', projectId);
178
+ body = setEnvLine(body, 'BACKLOG_API_KEY', apiKey);
179
+ // `mode: 0o600` on create closes the world-readable window; chmod covers the
180
+ // overwrite path too. `.env.local` holds BACKLOG_API_KEY → owner-only.
181
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
182
+ chmodSync(localPath, 0o600);
183
+ return true;
184
+ }