@timekast/factory 1.19.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,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
+ }
@@ -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.
@@ -140,3 +146,39 @@ export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
140
146
  chmodSync(localPath, 0o600); // holds RESEND_API_KEY → owner-only.
141
147
  return true;
142
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
+ }
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
11
+ import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -197,6 +197,64 @@ export function insertFactoryUpdateScript(pkgPath) {
197
197
  }
198
198
  return primary;
199
199
  }
200
+ /**
201
+ * Retire the E2E port the kit used to ship in a derivative's `package.json`: KEEP the key,
202
+ * EMPTY the value (`"e2e": null`), and add the `//e2e` sibling that explains why.
203
+ *
204
+ * WHY EMPTY RATHER THAN DELETE. `null` is a signal the e2e runner reads: with the key
205
+ * present and empty it derives a port from the checkout's absolute path, so two worktrees of
206
+ * one repo can run their suites at the same time. Deleting the key would say something
207
+ * different — "this project never had one" — which is the state that keeps a project on the
208
+ * runner's hardcoded fallback. So the key is the contract; only the value retires.
209
+ *
210
+ * WHY ONLY EXACTLY `3006`. Same discipline as `ensureFactoryScripts`: a KNOWN-OBSOLETE kit
211
+ * value is healed, a divergent one is never touched. A dev who pinned `4100` on purpose
212
+ * keeps `4100` through every future `factory update`; anything that is not the shipped
213
+ * number — including a `null` already written by an earlier update — is left alone, which is
214
+ * also what makes this idempotent (a second run writes nothing and cannot duplicate the
215
+ * comment).
216
+ *
217
+ * Surgical like the rest of this module: reads, mutates only inside `ports`, re-serializes
218
+ * with the original indentation + trailing newline, and writes ONLY on a real change. Key
219
+ * order inside `ports` is preserved and the comment is inserted immediately BEFORE `e2e`, so
220
+ * what a derivative sees in `git diff` is two lines: the emptied value and its explanation.
221
+ *
222
+ * @param pkgPath Absolute path to the derived project's `package.json`.
223
+ */
224
+ export function emptyShippedE2EPort(pkgPath) {
225
+ const raw = readFileSync(pkgPath, 'utf8');
226
+ const indent = detectIndent(raw);
227
+ const pkg = parsePackageJson(raw);
228
+ const ports = pkg[PORTS_KEY];
229
+ if (typeof ports !== 'object' || ports === null || Array.isArray(ports))
230
+ return 'absent';
231
+ const block = ports;
232
+ const current = block[PORTS_E2E_KEY];
233
+ if (current === undefined)
234
+ return 'absent';
235
+ if (current === null)
236
+ return 'already-empty';
237
+ if (current !== SHIPPED_E2E_PORT)
238
+ return 'preserved';
239
+ // Rebuilt rather than assigned in place: an object literal's key order is its insertion
240
+ // order, so this is what puts the comment immediately before the key it describes while
241
+ // leaving every sibling (`dev`, whatever else the project keeps here) where it was. An
242
+ // existing `//e2e` is dropped on the way through and re-inserted once, never twice.
243
+ const rebuilt = {};
244
+ for (const [key, value] of Object.entries(block)) {
245
+ if (key === PORTS_E2E_COMMENT_KEY)
246
+ continue;
247
+ if (key === PORTS_E2E_KEY) {
248
+ rebuilt[PORTS_E2E_COMMENT_KEY] = PORTS_E2E_COMMENT;
249
+ rebuilt[PORTS_E2E_KEY] = null;
250
+ continue;
251
+ }
252
+ rebuilt[key] = value;
253
+ }
254
+ pkg[PORTS_KEY] = rebuilt;
255
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
256
+ return 'emptied';
257
+ }
200
258
  /**
201
259
  * Surgically set the derived project's `package.json.agentKitVersion` to the
202
260
  * installed brain version, mirroring the lockfile's `version`. The Factory's own
@@ -45,6 +45,14 @@ const SERVICE_TOKENS = {
45
45
  // at the scaffold stage.
46
46
  github: 'VERCEL_TOKEN',
47
47
  dns: 'CLOUDFLARE_API_TOKEN',
48
+ // `backlog` (BSYNC-003) registers the project in backlog.timekast.mx with the org
49
+ // `admin` token. NOTE: unlike the other gate tokens (fail-closed here), a missing
50
+ // `BACKLOG_ADMIN_TOKEN` degrades GRACEFULLY at the command layer — WARN + skip when
51
+ // backlog is one of several services (`resolveBacklogService` in provision.ts strips
52
+ // it BEFORE this preflight runs), a hard error only when it is the sole service. So
53
+ // by the time the preflight sees `backlog` in `services`, the token is present; this
54
+ // entry is the defense-in-depth double-check.
55
+ backlog: 'BACKLOG_ADMIN_TOKEN',
48
56
  };
49
57
  /** True when the thrown error is execa's "command not found" (ENOENT). */
50
58
  function isCommandNotFound(error) {
@@ -247,6 +247,12 @@ export function isServiceComplete(state, service) {
247
247
  return state.github?.done === true;
248
248
  case 'dns':
249
249
  return state.dns !== undefined;
250
+ case 'backlog':
251
+ // Complete only when the per-project key was minted AND written — NOT on the
252
+ // mere presence of state.backlog (its `projectId` is recorded before the mint,
253
+ // so a run that created the project but failed to mint would be wrongly skipped
254
+ // on resume, and the show-once key would never reach `.env.local`).
255
+ return state.backlog?.keyMinted === true;
250
256
  default:
251
257
  return false;
252
258
  }
@@ -348,6 +354,9 @@ export function readDestroyTarget(rootDir) {
348
354
  neonBranchDevId: realId(adopted.neonBranchDevId),
349
355
  vercelProjectId: realId(adopted.vercelProjectId),
350
356
  githubRepo: realRepo(adopted.githubRepo),
357
+ // The adopt backfill (PROV-007) does not reconstruct backlog central — nothing
358
+ // to tear down from an adopted stack; a normal provision state carries it.
359
+ backlogProjectId: null,
351
360
  domains,
352
361
  };
353
362
  }
@@ -380,6 +389,7 @@ export function readDestroyTarget(rootDir) {
380
389
  neonBranchDevId: realId(step.neon?.developBranchId),
381
390
  vercelProjectId: realId(step.vercel?.projectId),
382
391
  githubRepo: realRepo(step.github?.repo),
392
+ backlogProjectId: realId(step.backlog?.projectId),
383
393
  domains,
384
394
  };
385
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",