@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.
- package/dist/commands/add.js +5 -1
- package/dist/commands/backlog.js +706 -0
- package/dist/commands/provision.js +232 -4
- package/dist/commands/update.js +5 -1
- package/dist/index.js +42 -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/constants.js +23 -0
- package/dist/lib/env-file.js +42 -0
- 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/package.json +1 -1
|
@@ -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
|
+
}
|
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.
|
|
@@ -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
|
+
}
|
package/dist/lib/package-json.js
CHANGED
|
@@ -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
|
}
|