@timekast/factory 1.19.0 → 1.21.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,302 @@
1
+ /**
2
+ * Backlog-central REST v1 client for the Factory ↔ backlog.timekast.mx integration.
3
+ *
4
+ * A THIN HTTP client that MIRRORS the proven fetch shape of `neon-api.ts`
5
+ * (`Authorization: Bearer ${key}` + a constant base URL + a typed error carrying
6
+ * the HTTP status + an injectable `fetchImpl` seam) — we reuse that shape rather
7
+ * than reinvent an HTTP client per command (issue §6). The provision/push/sync
8
+ * commands (BSYNC-003/005/006) consume this single surface instead of each
9
+ * hand-rolling headers/errors/backoff.
10
+ *
11
+ * TWO auth scopes ride the SAME `Authorization: Bearer` header; the CALLER picks
12
+ * which key to pass (this client does not distinguish them):
13
+ * - the org-level `admin` token (rail `BACKLOG_ADMIN_TOKEN`) — ONLY the three
14
+ * org endpoints: `POST /projects`, `POST /projects/{pid}/keys`,
15
+ * `DELETE /projects/{pid}`. It 403s (`PROJECT_NOT_IN_KEY`) on anything scoped
16
+ * to a project's elements.
17
+ * - the per-project `read_write` key (derivative `.env.local` `BACKLOG_API_KEY`)
18
+ * — every element/release/sync-state/summary call.
19
+ * (Live-verified separation — `project/abraham/smoke-findings.md` Hallazgo 1.)
20
+ *
21
+ * Every call surfaces a typed {@link BacklogApiError} on a non-2xx response that
22
+ * carries the HTTP status + best-effort body, so the caller can map edge cases to
23
+ * actionable es-MX messages. The `429` (rate ~120 req/min) backoff lives HERE, in
24
+ * the shared request helper — NOT in the parser/mapping of BSYNC-004 (issue §6).
25
+ *
26
+ * The `fetch` impl is an INJECTABLE seam (`BacklogApiDeps.fetchImpl`) so the unit
27
+ * tests drive method + URL + payload with a mock and NEVER hit the live network;
28
+ * the real end-to-end exercise is BSYNC-001 (spike) + BSYNC-014 (E2E).
29
+ */
30
+ /** Backlog-central REST v1 base (`servers[0].url` of the live `/openapi.json`). */
31
+ export const BACKLOG_API_BASE = 'https://backlog.timekast.mx/api/v1';
32
+ /** Timeout (ms) per call — a dead network must not hang the command using it. */
33
+ const BACKLOG_API_TIMEOUT_MS = 30_000;
34
+ /**
35
+ * A `429 Too Many Requests` (rate ~120 req/min) is retried with linear backoff —
36
+ * same shape `neon-api.ts` uses for its `423` lock retry. Tests inject a no-op
37
+ * `sleepImpl` so no real time passes.
38
+ */
39
+ const BACKLOG_RATE_MAX_RETRIES = 4;
40
+ const BACKLOG_RATE_BACKOFF_MS = 1_000;
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Error + injectable seams (calqued 1:1 from neon-api.ts)
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ /** A non-2xx response from the backlog API. Carries the HTTP status for mapping. */
45
+ export class BacklogApiError extends Error {
46
+ status;
47
+ /** The raw response body (best-effort, truncated) — context, never a secret. */
48
+ body;
49
+ constructor(status, body, context) {
50
+ super(`Backlog API ${status} en ${context}: ${body || '(sin cuerpo)'}`);
51
+ this.name = 'BacklogApiError';
52
+ this.status = status;
53
+ this.body = body;
54
+ Object.setPrototypeOf(this, new.target.prototype);
55
+ }
56
+ }
57
+ /** Path segment (plural) per element type — the API routes elements by plural. */
58
+ const ELEMENT_PATH_SEGMENT = {
59
+ epic: 'epics',
60
+ feature: 'features',
61
+ story: 'stories',
62
+ task: 'tasks',
63
+ bug: 'bugs',
64
+ };
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // Shared request helper — headers, timeout, 429 backoff, error, `{data}` unwrap
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+ /** Shared headers for every backlog call (Bearer auth + JSON). */
69
+ function backlogHeaders(apiKey) {
70
+ return {
71
+ Authorization: `Bearer ${apiKey}`,
72
+ 'Content-Type': 'application/json',
73
+ Accept: 'application/json',
74
+ };
75
+ }
76
+ /** Read a response body as text (best-effort) for error context — never throws. */
77
+ async function safeBody(response) {
78
+ try {
79
+ return (await response.text()).slice(0, 500);
80
+ }
81
+ catch {
82
+ return '';
83
+ }
84
+ }
85
+ /** Parse a JSON body defensively — a missing/invalid body yields `{}`, never throws. */
86
+ async function safeJson(response) {
87
+ try {
88
+ return await response.json();
89
+ }
90
+ catch {
91
+ return {};
92
+ }
93
+ }
94
+ /**
95
+ * Perform one backlog call: Bearer headers + JSON body + per-call abort timeout,
96
+ * retrying `429` with linear backoff (the ONE place backoff lives — issue §6).
97
+ * On any other non-2xx it throws a typed {@link BacklogApiError}. On success it
98
+ * unwraps the universal `{ data }` envelope and returns `data` typed as `T`.
99
+ *
100
+ * @throws {BacklogApiError} on any non-2xx (after exhausting the 429 retries).
101
+ */
102
+ async function backlogRequest(apiKey, path, init, context, deps = {}) {
103
+ const fetchImpl = deps.fetchImpl ?? fetch;
104
+ const sleep = deps.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
105
+ const doFetch = () => fetchImpl(`${BACKLOG_API_BASE}${path}`, {
106
+ method: init.method,
107
+ headers: backlogHeaders(apiKey),
108
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
109
+ signal: AbortSignal.timeout(BACKLOG_API_TIMEOUT_MS),
110
+ });
111
+ // Retry on 429 (rate limit) with linear backoff — every endpoint can hit it.
112
+ let response = await doFetch();
113
+ for (let attempt = 0; response.status === 429 && attempt < BACKLOG_RATE_MAX_RETRIES; attempt++) {
114
+ await sleep(BACKLOG_RATE_BACKOFF_MS * (attempt + 1));
115
+ response = await doFetch();
116
+ }
117
+ if (!response.ok) {
118
+ throw new BacklogApiError(response.status, await safeBody(response), context);
119
+ }
120
+ const json = (await safeJson(response));
121
+ return json.data;
122
+ }
123
+ /**
124
+ * Create the backlog project — `POST /projects` with the org-level `admin` token.
125
+ * `internalKey` is our slug (server validates `^[a-z0-9-]{3,40}$`; a collision
126
+ * 409s `INTERNAL_KEY_TAKEN` — the caller adopts the existing project). Returns the
127
+ * project id (`{pid}`) + its public board UUID (smoke Hallazgo 1).
128
+ *
129
+ * @throws {BacklogApiError} on any non-2xx (409 = internalKey taken → adopt).
130
+ */
131
+ export async function createBacklogProject(adminToken, input, deps = {}) {
132
+ const data = await backlogRequest(adminToken, '/projects', {
133
+ method: 'POST',
134
+ body: {
135
+ name: input.name,
136
+ internalKey: input.internalKey,
137
+ defaultLanguage: input.defaultLanguage ?? 'es',
138
+ enabledLanguages: input.enabledLanguages ?? ['es', 'en'],
139
+ },
140
+ }, 'POST /projects', deps);
141
+ if (!data?.id || !data?.publicUuid) {
142
+ throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), 'POST /projects (respuesta sin id/publicUuid)');
143
+ }
144
+ return { id: data.id, publicUuid: data.publicUuid };
145
+ }
146
+ /**
147
+ * Mint a per-project `read_write` key — `POST /projects/{pid}/keys` with the
148
+ * `admin` token. The returned `plaintextKey` (`tk_live_*`, scope fixed to this
149
+ * project) is SHOW-ONCE: it is the only time this value is readable, so the caller
150
+ * writes it straight to `.env.local` (`BACKLOG_API_KEY`) and never logs it.
151
+ *
152
+ * @throws {BacklogApiError} on any non-2xx.
153
+ */
154
+ export async function mintBacklogKey(adminToken, projectId, keyName, deps = {}) {
155
+ const data = await backlogRequest(adminToken, `/projects/${encodeURIComponent(projectId)}/keys`, { method: 'POST', body: { name: keyName } }, 'POST /projects/{pid}/keys', deps);
156
+ if (!data?.plaintextKey) {
157
+ throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), 'POST /projects/{pid}/keys (respuesta sin plaintextKey)');
158
+ }
159
+ return data.plaintextKey;
160
+ }
161
+ /**
162
+ * Soft-delete the backlog project — `DELETE /projects/{pid}` with the `admin`
163
+ * token. It DEACTIVATES (does not hard-delete, does not cascade) and is
164
+ * IDEMPOTENT: a repeat returns 200 with the same `{ deactivated: true }` (smoke
165
+ * Hallazgo 7). Unblocks `--destroy` (BSYNC-003).
166
+ *
167
+ * @throws {BacklogApiError} on any non-2xx.
168
+ */
169
+ export async function deleteBacklogProject(adminToken, projectId, deps = {}) {
170
+ await backlogRequest(adminToken, `/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' }, 'DELETE /projects/{pid}', deps);
171
+ }
172
+ /**
173
+ * Shared POST for the four element creates — `POST /projects/{pid}/{plural}`.
174
+ * The body is the caller's already-mapped payload (BSYNC-004 builds it); this
175
+ * client only performs the HTTP call. Returns the created id + minted humanId.
176
+ *
177
+ * NOTE on idempotency: a re-POST of an existing `id` currently returns `500`
178
+ * (NOT the documented `409 ELEMENT_ID_EXISTS`) — smoke Hallazgo 3. So the push
179
+ * (BSYNC-005) MUST pre-check existence via {@link getSyncState}, not treat a
180
+ * status here as an "already exists" signal.
181
+ *
182
+ * @throws {BacklogApiError} on any non-2xx.
183
+ */
184
+ async function createElement(apiKey, projectId, segment, body, context, deps) {
185
+ const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/${segment}`, { method: 'POST', body }, context, deps);
186
+ const humanId = data?.humanId;
187
+ // The response may echo the id; fall back to the client-supplied one we sent.
188
+ const id = data?.id ?? body.id;
189
+ if (!id || !humanId) {
190
+ throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), `${context} (respuesta sin id/humanId)`);
191
+ }
192
+ return { id, humanId };
193
+ }
194
+ /** Create an epic — `POST /projects/{pid}/epics`. @throws {BacklogApiError} on non-2xx. */
195
+ export async function createEpic(apiKey, projectId, body, deps = {}) {
196
+ return createElement(apiKey, projectId, 'epics', body, 'POST /projects/{pid}/epics', deps);
197
+ }
198
+ /** Create a feature — `POST /projects/{pid}/features`. @throws {BacklogApiError} on non-2xx. */
199
+ export async function createFeature(apiKey, projectId, body, deps = {}) {
200
+ return createElement(apiKey, projectId, 'features', body, 'POST /projects/{pid}/features', deps);
201
+ }
202
+ /** Create a story — `POST /projects/{pid}/stories`. @throws {BacklogApiError} on non-2xx. */
203
+ export async function createStory(apiKey, projectId, body, deps = {}) {
204
+ return createElement(apiKey, projectId, 'stories', body, 'POST /projects/{pid}/stories', deps);
205
+ }
206
+ /** Create a task — `POST /projects/{pid}/tasks`. @throws {BacklogApiError} on non-2xx. */
207
+ export async function createTask(apiKey, projectId, body, deps = {}) {
208
+ return createElement(apiKey, projectId, 'tasks', body, 'POST /projects/{pid}/tasks', deps);
209
+ }
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+ // Element status PATCH — per-project `read_write` key (BSYNC-005/006)
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ /**
214
+ * Update one element's status — `PATCH /projects/{pid}/{plural}/{id}` with
215
+ * `{ status }` (smoke Hallazgo 3). `elementType` selects the plural path segment.
216
+ * The response `{ updated: true }` is not echoed back to the caller.
217
+ *
218
+ * @throws {BacklogApiError} on any non-2xx.
219
+ */
220
+ export async function patchElementStatus(apiKey, projectId, elementType, elementId, status, deps = {}) {
221
+ const segment = ELEMENT_PATH_SEGMENT[elementType];
222
+ await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/${segment}/${encodeURIComponent(elementId)}`, { method: 'PATCH', body: { status } }, `PATCH /projects/{pid}/${segment}/{id}`, deps);
223
+ }
224
+ /**
225
+ * Read the project's flat element list — `GET /projects/{pid}/sync-state` (smoke
226
+ * Hallazgo 4). It lists ALL elements (does NOT filter by visibility), so it is the
227
+ * remote `{ id → (status, points, updatedAt) }` truth the self-healing diffs
228
+ * against the local SSOT — and the existence pre-check the push uses in place of
229
+ * the (broken) 409-on-collision signal. Paginated via `page`/`pageSize`; the
230
+ * caller reads `total` to decide whether more pages are needed.
231
+ *
232
+ * @throws {BacklogApiError} on any non-2xx.
233
+ */
234
+ export async function getSyncState(apiKey, projectId, opts = {}, deps = {}) {
235
+ const query = new URLSearchParams();
236
+ if (opts.page !== undefined)
237
+ query.set('page', String(opts.page));
238
+ if (opts.pageSize !== undefined)
239
+ query.set('pageSize', String(opts.pageSize));
240
+ const suffix = query.toString() ? `?${query.toString()}` : '';
241
+ const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/sync-state${suffix}`, { method: 'GET' }, 'GET /projects/{pid}/sync-state', deps);
242
+ return {
243
+ items: data?.items ?? [],
244
+ total: data?.total ?? 0,
245
+ page: data?.page ?? 1,
246
+ pageSize: data?.pageSize ?? data?.items?.length ?? 0,
247
+ };
248
+ }
249
+ /**
250
+ * List the project's releases — `GET /projects/{pid}/releases`. Feeds the
251
+ * adopt-by-name flow (releases take NO client-supplied id, so the push matches an
252
+ * existing release by name before creating one — D12). Tolerates either a bare
253
+ * `data: [...]` or a `data: { items: [...] }` envelope (same defensive parse
254
+ * `neon-api.ts` uses for its key list).
255
+ *
256
+ * @throws {BacklogApiError} on any non-2xx.
257
+ */
258
+ export async function listBacklogReleases(apiKey, projectId, deps = {}) {
259
+ const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/releases`, { method: 'GET' }, 'GET /projects/{pid}/releases', deps);
260
+ const list = Array.isArray(data) ? data : (data?.items ?? []);
261
+ return list
262
+ .filter((r) => typeof r?.id === 'string')
263
+ .map((r) => ({ id: r.id, name: (r.name ?? {}) }));
264
+ }
265
+ /**
266
+ * Create a release — `POST /projects/{pid}/releases` (smoke Hallazgo 5). It takes
267
+ * no client-supplied `id`; the server mints one and returns it. Returns the new
268
+ * release id.
269
+ *
270
+ * @throws {BacklogApiError} on any non-2xx.
271
+ */
272
+ export async function createRelease(apiKey, projectId, body, deps = {}) {
273
+ const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/releases`, { method: 'POST', body }, 'POST /projects/{pid}/releases', deps);
274
+ if (!data?.id) {
275
+ throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), 'POST /projects/{pid}/releases (respuesta sin id)');
276
+ }
277
+ return { id: data.id };
278
+ }
279
+ /**
280
+ * Assign an element to a release — `POST /releases/{rid}/assignments` (smoke
281
+ * Hallazgo 5). NOTE this route is RELEASE-scoped, NOT under `/projects/{pid}`.
282
+ * The mapping rule: assign ONLY epics — features/stories inherit the release, and
283
+ * assigning a child whose ancestor epic is already assigned 422s `PARENT_MISMATCH`
284
+ * (tasks/bugs are not releasable at all). Enforcing that rule is the caller's job;
285
+ * the API accepts `epic`/`feature`/`story` here.
286
+ *
287
+ * @throws {BacklogApiError} on any non-2xx.
288
+ */
289
+ export async function assignToRelease(apiKey, releaseId, elementType, elementId, deps = {}) {
290
+ await backlogRequest(apiKey, `/releases/${encodeURIComponent(releaseId)}/assignments`, { method: 'POST', body: { elementType, elementId } }, 'POST /releases/{rid}/assignments', deps);
291
+ }
292
+ /**
293
+ * Read the project's rollup — `GET /projects/{pid}/summary` (smoke Hallazgo 5).
294
+ * The rollup propagates epic → release and drops `wont_do` from point
295
+ * denominators. Prefer {@link getSyncState} for heartbeat/reconcile; use this for
296
+ * a point/progress rollup view.
297
+ *
298
+ * @throws {BacklogApiError} on any non-2xx.
299
+ */
300
+ export async function getSummary(apiKey, projectId, deps = {}) {
301
+ return backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/summary`, { method: 'GET' }, 'GET /projects/{pid}/summary', deps);
302
+ }
@@ -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
+ }