@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.
- package/dist/commands/add.js +6 -1
- package/dist/commands/backlog.js +706 -0
- package/dist/commands/env.js +9 -0
- package/dist/commands/provision.js +689 -42
- package/dist/commands/update.js +45 -1
- package/dist/index.js +43 -2
- package/dist/lib/atomic-swap.js +13 -6
- package/dist/lib/backlog-api.js +302 -0
- package/dist/lib/backlog-mapping.js +175 -0
- package/dist/lib/backlog-parse.js +208 -0
- package/dist/lib/cloudflare-api.js +112 -2
- package/dist/lib/constants.js +23 -0
- package/dist/lib/env-file.js +51 -6
- package/dist/lib/env-wizard.js +30 -8
- package/dist/lib/package-json.js +59 -1
- package/dist/lib/provision-preflight.js +8 -0
- package/dist/lib/provision-state.js +10 -0
- package/dist/lib/vercel-env.js +42 -8
- package/package.json +1 -1
|
@@ -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
|
|
20
|
-
* record is never removed
|
|
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
|
+
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
package/dist/lib/env-file.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
+
}
|