@hegemonart/get-design-done 1.30.5 → 1.31.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/.claude-plugin/marketplace.json +6 -3
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +129 -0
- package/README.md +22 -1
- package/SKILL.md +1 -0
- package/agents/design-integration-checker.md +1 -1
- package/agents/design-planner.md +1 -1
- package/agents/gdd-graph-refresh.md +90 -0
- package/bin/gdd-graph +261 -0
- package/connections/connections.md +10 -9
- package/connections/graphify.md +65 -54
- package/package.json +8 -3
- package/reference/capability-gap-stage-gate.md +7 -4
- package/reference/model-tiers.md +2 -2
- package/reference/start-interview.md +1 -1
- package/scripts/detect-stale-refs.cjs +6 -0
- package/scripts/lib/figma-extract/digest.cjs +430 -0
- package/scripts/lib/figma-extract/parse-url.cjs +87 -0
- package/scripts/lib/figma-extract/payload-schema.json +108 -0
- package/scripts/lib/figma-extract/pull.cjs +394 -0
- package/scripts/lib/figma-extract/receiver.cjs +273 -0
- package/scripts/lib/figma-extract/render-md.cjs +143 -0
- package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
- package/scripts/lib/figma-extract/walk.cjs +100 -0
- package/scripts/lib/graph/atomic-write.mjs +68 -0
- package/scripts/lib/graph/build.mjs +124 -0
- package/scripts/lib/graph/diff.mjs +90 -0
- package/scripts/lib/graph/index.mjs +14 -0
- package/scripts/lib/graph/query.mjs +155 -0
- package/scripts/lib/graph/schema.json +69 -0
- package/scripts/lib/graph/schema.mjs +47 -0
- package/scripts/lib/graph/status.mjs +88 -0
- package/scripts/lib/graph/token-estimate.mjs +27 -0
- package/scripts/lib/graph/upsert.mjs +210 -0
- package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +89 -2
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
- package/skills/connections/connections-onboarding.md +6 -6
- package/skills/figma-extract/SKILL.md +64 -0
- package/skills/graphify/SKILL.md +11 -10
- package/skills/health/SKILL.md +10 -0
- package/skills/scan/scan-procedure.md +9 -8
- package/agents/gdd-graphify-sync.md +0 -110
- /package/scripts/lib/{gsd-health-mirror → health-mirror}/index.d.cts +0 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/figma-extract/pull.cjs — Plan 31-01 (Wave A.1)
|
|
3
|
+
// Productionized from spike 001 (.planning/spikes/001-figma-offcontext-extractor/extract.mjs).
|
|
4
|
+
//
|
|
5
|
+
// RAW-PULL stage of the two-stage Figma extractor pipeline. Pulls the 5 Figma
|
|
6
|
+
// REST endpoints the spike validated into a local cache dir, writing JSON to
|
|
7
|
+
// disk ONLY. Raw response bodies are never returned to a caller that could log
|
|
8
|
+
// them — they go straight to <outDir>/<name>.json. The digest/markdown stage
|
|
9
|
+
// (Plan 31-02) consumes the cache; this file does ZERO digest work.
|
|
10
|
+
//
|
|
11
|
+
// Decisions honored:
|
|
12
|
+
// D-01 Two-stage separation: pull.cjs only pulls + caches. No digest here.
|
|
13
|
+
// D-03 geometry=paths is DROPPED — the file endpoint is /files/:key with no
|
|
14
|
+
// geometry query param. Saves ~30% raw size for data the digest throws away.
|
|
15
|
+
// D-10 FIGMA_TOKEN is read from process.env.FIGMA_TOKEN (fallback
|
|
16
|
+
// FIGMA_PERSONAL_ACCESS_TOKEN) only. It is NEVER written to disk and
|
|
17
|
+
// NEVER passed to console.log / the logger seam. (31-10 ships a static
|
|
18
|
+
// analysis test scanning this dir for token persistence/logging.)
|
|
19
|
+
// D-11 Cache invalidation is content-based via Figma's `version` field, with
|
|
20
|
+
// a 1h wall-clock TTL fallback when no version field is available.
|
|
21
|
+
//
|
|
22
|
+
// Hardening over the spike: retry-with-backoff on transient 429/5xx, structured
|
|
23
|
+
// per-endpoint timing emitted to an injectable logger seam (NOT raw bodies),
|
|
24
|
+
// file-URL-or-bare-key input via parse-url.cjs, version-based cache skip.
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { parseFigmaFileKey } = require('./parse-url.cjs');
|
|
29
|
+
|
|
30
|
+
const FIGMA_API_BASE = 'https://api.figma.com/v1';
|
|
31
|
+
|
|
32
|
+
// Endpoint inventory mirroring the spike's 5 pulls. D-03: the file endpoint is
|
|
33
|
+
// `/files/${k}` with NO `?geometry=paths`. Each entry: { name, path(key)→string,
|
|
34
|
+
// optional?:bool }. `optional` endpoints (Path A Variables) may 403 on
|
|
35
|
+
// non-Enterprise plans and are skipped gracefully rather than aborting the run.
|
|
36
|
+
const DEFAULT_ENDPOINTS = [
|
|
37
|
+
{ name: 'file', path: (k) => `/files/${k}` },
|
|
38
|
+
{ name: 'variables', path: (k) => `/files/${k}/variables/local`, optional: true },
|
|
39
|
+
{ name: 'styles', path: (k) => `/files/${k}/styles` },
|
|
40
|
+
{ name: 'components', path: (k) => `/files/${k}/components` },
|
|
41
|
+
{ name: 'component_sets', path: (k) => `/files/${k}/component_sets` },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Retry/backoff tuning. Bounded — never an infinite loop.
|
|
45
|
+
const MAX_ATTEMPTS = 4; // 1 initial + 3 retries
|
|
46
|
+
const BACKOFF_BASE_MS = 250; // base × 2^attempt, capped
|
|
47
|
+
const BACKOFF_CAP_MS = 4000;
|
|
48
|
+
const TTL_MS = 60 * 60 * 1000; // 1h wall-clock fallback (D-11)
|
|
49
|
+
|
|
50
|
+
const noopLogger = { info() {}, warn() {}, error() {} };
|
|
51
|
+
|
|
52
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
|
|
54
|
+
function isTransient(status) {
|
|
55
|
+
return status === 429 || status >= 500;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fetch a Figma API path with timing + bounded retry/backoff on transient errors.
|
|
60
|
+
* Returns { json, ms, bytes } on success. Throws a structured Error after the
|
|
61
|
+
* retry budget is exhausted, or immediately on a non-transient error.
|
|
62
|
+
*
|
|
63
|
+
* The token lives only inside `headers` (caller-provided); it is NEVER logged.
|
|
64
|
+
*/
|
|
65
|
+
async function fetchJson(apiPath, { fetchImpl, headers, logger, sleepImpl }) {
|
|
66
|
+
const url = `${FIGMA_API_BASE}${apiPath}`;
|
|
67
|
+
const wait = sleepImpl || sleep;
|
|
68
|
+
let lastErr;
|
|
69
|
+
|
|
70
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
71
|
+
const t0 = Date.now();
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetchImpl(url, { headers });
|
|
75
|
+
} catch (networkErr) {
|
|
76
|
+
// Treat raw network/transport failures as transient too.
|
|
77
|
+
lastErr = networkErr;
|
|
78
|
+
logger.warn({ event: 'fetch_error', path: apiPath, attempt, message: networkErr.message });
|
|
79
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
80
|
+
await wait(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const e = new Error(`Figma API network failure on ${apiPath}: ${networkErr.message}`);
|
|
84
|
+
e.path = apiPath;
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ms = Date.now() - t0;
|
|
89
|
+
|
|
90
|
+
if (res.ok) {
|
|
91
|
+
const json = await res.json();
|
|
92
|
+
const bytes = Buffer.byteLength(JSON.stringify(json), 'utf8');
|
|
93
|
+
return { json, ms, bytes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Non-2xx. Read a short body prefix for diagnostics — NEVER the token.
|
|
97
|
+
let bodyPrefix = '';
|
|
98
|
+
try {
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
bodyPrefix = String(text).slice(0, 200);
|
|
101
|
+
} catch {
|
|
102
|
+
bodyPrefix = '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (isTransient(res.status) && attempt < MAX_ATTEMPTS - 1) {
|
|
106
|
+
logger.warn({ event: 'transient_retry', path: apiPath, status: res.status, attempt });
|
|
107
|
+
await wait(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
108
|
+
lastErr = new Error(`Figma API ${res.status} on ${apiPath}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Non-transient, or transient budget exhausted → structured throw.
|
|
113
|
+
const err = new Error(`Figma API ${res.status} on ${apiPath}: ${bodyPrefix}`);
|
|
114
|
+
err.status = res.status;
|
|
115
|
+
err.path = apiPath;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Defensive: loop only exits via return/throw above, but guard anyway.
|
|
120
|
+
const err = lastErr || new Error(`Figma API request failed on ${apiPath}`);
|
|
121
|
+
err.path = apiPath;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write one endpoint's JSON to <outDir>/<name>.json and emit structured timing.
|
|
127
|
+
* Returns { name, bytes, ms }. The logger receives only { endpoint, bytes, ms } —
|
|
128
|
+
* never the body, never the token (D-10).
|
|
129
|
+
*/
|
|
130
|
+
function save(outDir, name, data, meta, logger) {
|
|
131
|
+
const filePath = path.join(outDir, `${name}.json`);
|
|
132
|
+
const body = JSON.stringify(data);
|
|
133
|
+
fs.writeFileSync(filePath, body);
|
|
134
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
135
|
+
logger.info({ event: 'endpoint_saved', endpoint: name, bytes, ms: meta.ms });
|
|
136
|
+
return { name, bytes, ms: meta.ms };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readMetaIfPresent(outDir) {
|
|
140
|
+
const metaPath = path.join(outDir, '_meta.json');
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Productionized Figma REST puller.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} opts
|
|
152
|
+
* @param {string} opts.input - required: bare file key OR full Figma file URL
|
|
153
|
+
* @param {string} opts.outDir - required: raw/ cache dir to write *.json into
|
|
154
|
+
* @param {string} [opts.token] - defaults to env FIGMA_TOKEN / FIGMA_PERSONAL_ACCESS_TOKEN
|
|
155
|
+
* @param {Function} [opts.fetchImpl]- injectable fetch (defaults to global fetch)
|
|
156
|
+
* @param {object} [opts.logger] - { info(obj), warn(obj), error(obj) } structured sink
|
|
157
|
+
* @param {Date} [opts.now] - deterministic clock for TTL tests
|
|
158
|
+
* @param {boolean}[opts.forceRefresh]- bypass version/TTL cache check
|
|
159
|
+
* @param {Function}[opts.sleepImpl] - injectable sleep for fast backoff tests
|
|
160
|
+
* @returns {Promise<{fileKey,version,cached,endpoints,outDir}>}
|
|
161
|
+
*
|
|
162
|
+
* Effect: writes <outDir>/file.json, styles.json, components.json,
|
|
163
|
+
* component_sets.json (+ variables.json when Path A succeeds) and
|
|
164
|
+
* <outDir>/_meta.json { file_key, fetched_at, version, totals }.
|
|
165
|
+
* NEVER returns raw response bodies. NEVER logs/persists `token`.
|
|
166
|
+
*/
|
|
167
|
+
async function pull(opts) {
|
|
168
|
+
const {
|
|
169
|
+
input,
|
|
170
|
+
outDir,
|
|
171
|
+
token,
|
|
172
|
+
fetchImpl = globalThis.fetch,
|
|
173
|
+
logger = noopLogger,
|
|
174
|
+
now,
|
|
175
|
+
forceRefresh = false,
|
|
176
|
+
sleepImpl,
|
|
177
|
+
} = opts || {};
|
|
178
|
+
|
|
179
|
+
if (!input) {
|
|
180
|
+
throw new TypeError('pull: opts.input (file key or URL) is required');
|
|
181
|
+
}
|
|
182
|
+
if (!outDir) {
|
|
183
|
+
throw new TypeError('pull: opts.outDir (cache dir) is required');
|
|
184
|
+
}
|
|
185
|
+
if (typeof fetchImpl !== 'function') {
|
|
186
|
+
throw new TypeError('pull: a fetch implementation is required (global fetch or opts.fetchImpl)');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// D-10: token from env only when not explicitly injected. NEVER interpolate
|
|
190
|
+
// the resolved token into any message, log line, or file.
|
|
191
|
+
const tok = token || process.env.FIGMA_TOKEN || process.env.FIGMA_PERSONAL_ACCESS_TOKEN;
|
|
192
|
+
if (!tok) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
'FIGMA_TOKEN not set. Export FIGMA_TOKEN=figd_… (get one at ' +
|
|
195
|
+
'https://www.figma.com/developers/api#access-tokens).'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const headers = { 'X-Figma-Token': tok };
|
|
200
|
+
const fileKey = parseFigmaFileKey(input);
|
|
201
|
+
const clock = now instanceof Date ? now : new Date();
|
|
202
|
+
|
|
203
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
204
|
+
|
|
205
|
+
// ── Version probe (D-11) ────────────────────────────────────────────────
|
|
206
|
+
// Cheapest file call that still returns the `version` field. depth=1 avoids
|
|
207
|
+
// pulling the full (potentially hundreds-of-MB) node tree just to compare.
|
|
208
|
+
let probedVersion = null;
|
|
209
|
+
try {
|
|
210
|
+
const probe = await fetchJson(`/files/${fileKey}?depth=1`, {
|
|
211
|
+
fetchImpl,
|
|
212
|
+
headers,
|
|
213
|
+
logger,
|
|
214
|
+
sleepImpl,
|
|
215
|
+
});
|
|
216
|
+
probedVersion = probe.json && probe.json.version != null ? probe.json.version : null;
|
|
217
|
+
logger.info({ event: 'version_probe', endpoint: 'file_probe', version: probedVersion, ms: probe.ms });
|
|
218
|
+
} catch (probeErr) {
|
|
219
|
+
// Probe failure should not be fatal on its own — fall through to TTL logic
|
|
220
|
+
// and let the heavy pull surface a real error if the file is unreachable.
|
|
221
|
+
logger.warn({ event: 'version_probe_failed', message: probeErr.message });
|
|
222
|
+
probedVersion = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Cache check (D-11) ──────────────────────────────────────────────────
|
|
226
|
+
const cachedMeta = readMetaIfPresent(outDir);
|
|
227
|
+
if (!forceRefresh && cachedMeta) {
|
|
228
|
+
const versionMatch =
|
|
229
|
+
probedVersion != null && cachedMeta.version != null && cachedMeta.version === probedVersion;
|
|
230
|
+
|
|
231
|
+
let ttlFresh = false;
|
|
232
|
+
if (probedVersion == null && cachedMeta.fetched_at) {
|
|
233
|
+
const age = clock.getTime() - new Date(cachedMeta.fetched_at).getTime();
|
|
234
|
+
ttlFresh = Number.isFinite(age) && age >= 0 && age < TTL_MS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (versionMatch || ttlFresh) {
|
|
238
|
+
logger.info({
|
|
239
|
+
event: 'cache_hit',
|
|
240
|
+
reason: versionMatch ? 'version_match' : 'ttl_fresh',
|
|
241
|
+
version: probedVersion != null ? probedVersion : cachedMeta.version || null,
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
fileKey,
|
|
245
|
+
version: probedVersion != null ? probedVersion : cachedMeta.version || null,
|
|
246
|
+
cached: true,
|
|
247
|
+
endpoints: [],
|
|
248
|
+
outDir,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Heavy pull ──────────────────────────────────────────────────────────
|
|
254
|
+
const endpoints = [];
|
|
255
|
+
for (const ep of DEFAULT_ENDPOINTS) {
|
|
256
|
+
const apiPath = ep.path(fileKey);
|
|
257
|
+
if (ep.optional) {
|
|
258
|
+
// Path A (Variables): may 403 on non-Enterprise plans → graceful skip.
|
|
259
|
+
try {
|
|
260
|
+
const result = await fetchJson(apiPath, { fetchImpl, headers, logger, sleepImpl });
|
|
261
|
+
endpoints.push(save(outDir, ep.name, result.json, result, logger));
|
|
262
|
+
} catch (e) {
|
|
263
|
+
const reason = e.status ? `HTTP ${e.status}` : e.message;
|
|
264
|
+
logger.warn({ event: 'endpoint_skipped', endpoint: ep.name, reason });
|
|
265
|
+
endpoints.push({ name: ep.name, bytes: 0, ms: 0, skipped: true, reason });
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
const result = await fetchJson(apiPath, { fetchImpl, headers, logger, sleepImpl });
|
|
269
|
+
endpoints.push(save(outDir, ep.name, result.json, result, logger));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Persist _meta.json (D-11 shape, extends spike _meta with version) ─────
|
|
274
|
+
const meta = {
|
|
275
|
+
file_key: fileKey,
|
|
276
|
+
fetched_at: clock.toISOString(),
|
|
277
|
+
version: probedVersion,
|
|
278
|
+
totals: endpoints,
|
|
279
|
+
};
|
|
280
|
+
fs.writeFileSync(path.join(outDir, '_meta.json'), JSON.stringify(meta, null, 2));
|
|
281
|
+
logger.info({ event: 'pull_complete', fileKey, version: probedVersion, endpoints: endpoints.length });
|
|
282
|
+
|
|
283
|
+
return { fileKey, version: probedVersion, cached: false, endpoints, outDir };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { pull, DEFAULT_ENDPOINTS, FIGMA_API_BASE };
|
|
287
|
+
|
|
288
|
+
// ── CLI entry ───────────────────────────────────────────────────────────────
|
|
289
|
+
// Usage: node scripts/lib/figma-extract/pull.cjs <file-url-or-key> [--out <dir>] [--force]
|
|
290
|
+
// node scripts/lib/figma-extract/pull.cjs --help
|
|
291
|
+
// FIGMA_TOKEN must be set in the environment (never passed as a flag — D-10).
|
|
292
|
+
if (require.main === module) {
|
|
293
|
+
const argv = process.argv.slice(2);
|
|
294
|
+
|
|
295
|
+
const HELP = `gdd figma-extract pull — raw Figma REST puller (Plan 31-01)
|
|
296
|
+
|
|
297
|
+
Usage:
|
|
298
|
+
node scripts/lib/figma-extract/pull.cjs <file-url-or-key> [options]
|
|
299
|
+
|
|
300
|
+
Arguments:
|
|
301
|
+
<file-url-or-key> A Figma file URL (https://www.figma.com/file/<key>/… or
|
|
302
|
+
/design/<key>/…) OR a bare file key.
|
|
303
|
+
|
|
304
|
+
Options:
|
|
305
|
+
--out <dir> Raw cache output dir.
|
|
306
|
+
Default: .figma-extract-cache/raw/<file-key>
|
|
307
|
+
--force Bypass version/TTL cache and re-pull all endpoints.
|
|
308
|
+
-h, --help Show this help.
|
|
309
|
+
|
|
310
|
+
Environment:
|
|
311
|
+
FIGMA_TOKEN Personal access token (required).
|
|
312
|
+
FIGMA_PERSONAL_ACCESS_TOKEN Fallback token env var.
|
|
313
|
+
(The token is read from the environment only — never accepted as a flag,
|
|
314
|
+
never logged, never written to disk. — D-10)
|
|
315
|
+
|
|
316
|
+
Notes:
|
|
317
|
+
- Drops geometry=paths from the file endpoint (D-03).
|
|
318
|
+
- Skips a 403 Variables endpoint gracefully (Path A, D-04).
|
|
319
|
+
- Content-version cache invalidation with 1h TTL fallback (D-11).
|
|
320
|
+
- Writes JSON to disk only; raw bodies never printed (off-context, D-01).
|
|
321
|
+
`;
|
|
322
|
+
|
|
323
|
+
if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
|
|
324
|
+
process.stdout.write(HELP);
|
|
325
|
+
process.exit(0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Minimal flag parse — first non-flag token is the input.
|
|
329
|
+
let input = null;
|
|
330
|
+
let outDir = null;
|
|
331
|
+
let forceRefresh = false;
|
|
332
|
+
for (let i = 0; i < argv.length; i++) {
|
|
333
|
+
const a = argv[i];
|
|
334
|
+
if (a === '--force') {
|
|
335
|
+
forceRefresh = true;
|
|
336
|
+
} else if (a === '--out') {
|
|
337
|
+
outDir = argv[++i];
|
|
338
|
+
} else if (!a.startsWith('-') && input === null) {
|
|
339
|
+
input = a;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!input) {
|
|
344
|
+
process.stderr.write('ERROR: a Figma file URL or key is required.\n\n' + HELP);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Resolve default outDir lazily so we have the parsed key in the path.
|
|
349
|
+
(async () => {
|
|
350
|
+
try {
|
|
351
|
+
const fileKey = parseFigmaFileKey(input);
|
|
352
|
+
const resolvedOut =
|
|
353
|
+
outDir || path.join('.figma-extract-cache', 'raw', fileKey);
|
|
354
|
+
|
|
355
|
+
// CLI logger → structured JSON lines on stderr (never the token/body).
|
|
356
|
+
const cliLogger = {
|
|
357
|
+
info: (o) => process.stderr.write(JSON.stringify({ level: 'info', ...o }) + '\n'),
|
|
358
|
+
warn: (o) => process.stderr.write(JSON.stringify({ level: 'warn', ...o }) + '\n'),
|
|
359
|
+
error: (o) => process.stderr.write(JSON.stringify({ level: 'error', ...o }) + '\n'),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const result = await pull({
|
|
363
|
+
input,
|
|
364
|
+
outDir: resolvedOut,
|
|
365
|
+
logger: cliLogger,
|
|
366
|
+
forceRefresh,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
process.stdout.write(
|
|
370
|
+
JSON.stringify(
|
|
371
|
+
{
|
|
372
|
+
fileKey: result.fileKey,
|
|
373
|
+
version: result.version,
|
|
374
|
+
cached: result.cached,
|
|
375
|
+
outDir: result.outDir,
|
|
376
|
+
endpoints: result.endpoints.map((e) => ({
|
|
377
|
+
name: e.name,
|
|
378
|
+
bytes: e.bytes,
|
|
379
|
+
ms: e.ms,
|
|
380
|
+
skipped: e.skipped || false,
|
|
381
|
+
})),
|
|
382
|
+
},
|
|
383
|
+
null,
|
|
384
|
+
2
|
|
385
|
+
) + '\n'
|
|
386
|
+
);
|
|
387
|
+
process.exit(0);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
// Surface the message (which never contains the token) on stderr.
|
|
390
|
+
process.stderr.write('FAILED: ' + e.message + '\n');
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
})();
|
|
394
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/figma-extract/receiver.cjs — Plan 31-06 (Wave B.3)
|
|
3
|
+
// Path C receiver; D-06: ephemeral + 127.0.0.1-only.
|
|
4
|
+
//
|
|
5
|
+
// The localhost half of Path C (D-04). The Figma plugin "GDD Sync" (31-05)
|
|
6
|
+
// reads `figma.variables` from inside Figma (works on any plan — sidesteps the
|
|
7
|
+
// spike's Variables-API-403 Enterprise blocker) and POSTs them here. This
|
|
8
|
+
// receiver validates the payload against payload-schema.json and writes it into
|
|
9
|
+
// the raw/ cache as variables.json, where digest.cjs (31-02) consumes it as
|
|
10
|
+
// Path C via the `source:'gdd-plugin'` marker.
|
|
11
|
+
//
|
|
12
|
+
// Security properties are the WHOLE point (D-06):
|
|
13
|
+
// - Binds 127.0.0.1 ONLY (host '127.0.0.1', never 0.0.0.0) — unreachable off
|
|
14
|
+
// the loopback interface.
|
|
15
|
+
// - REFUSES any non-loopback remote with 403 (req.socket.remoteAddress gate),
|
|
16
|
+
// even though the bind already makes that essentially unreachable — defense
|
|
17
|
+
// in depth, and asserted by test via a mocked remote address.
|
|
18
|
+
// - Validates EVERY payload against the schema BEFORE touching disk (400 on
|
|
19
|
+
// invalid; nothing written).
|
|
20
|
+
// - Port is HARDCODED to 5179 — NOT read from env or a CLI flag (acceptance
|
|
21
|
+
// criterion). Changing it requires a code edit. There is intentionally no
|
|
22
|
+
// `process.env.*PORT*` read in this module.
|
|
23
|
+
// - EPHEMERAL: listens only for the duration of one extract run and exits on
|
|
24
|
+
// the FIRST valid receipt OR on a timeout — never a lingering open port.
|
|
25
|
+
//
|
|
26
|
+
// D-10: this module handles design variables ONLY. It NEVER touches the Figma
|
|
27
|
+
// token (that's a REST-path concern, not Path C). There is no secret-handling
|
|
28
|
+
// code here, and the logger seam receives lifecycle events + counts only —
|
|
29
|
+
// never full payloads.
|
|
30
|
+
|
|
31
|
+
const http = require('node:http');
|
|
32
|
+
const fs = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const Ajv = require('ajv');
|
|
35
|
+
|
|
36
|
+
const payloadSchema = require('./payload-schema.json');
|
|
37
|
+
|
|
38
|
+
// ── constants (D-06 acceptance criterion: hardcoded, no env override) ─────────
|
|
39
|
+
const RECEIVER_HOST = '127.0.0.1'; // loopback ONLY
|
|
40
|
+
const RECEIVER_PORT = 5179; // HARDCODED — intentionally not read from process.env
|
|
41
|
+
|
|
42
|
+
// The marker digest.cjs (31-02) keys on to route variables.json to Path C.
|
|
43
|
+
const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
|
|
44
|
+
|
|
45
|
+
// Defensive body cap. Large design systems can ship sizeable variable sets
|
|
46
|
+
// (the risk register notes streaming for the raw pull); 50MB is generous for a
|
|
47
|
+
// variables-only JSON payload while still bounding memory from a hostile body.
|
|
48
|
+
const MAX_BODY_BYTES = 50 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
// ── validator (Ajv is a hard repo dependency — package.json "ajv": "^8.18.0") ─
|
|
51
|
+
// Compiled once at module load. Ajv 8 CJS: require('ajv') is the constructor.
|
|
52
|
+
// NOTE: fail-fast (default, NO allErrors). The receiver validates an UNTRUSTED
|
|
53
|
+
// HTTP body from the plugin; `allErrors: true` would walk the entire (possibly
|
|
54
|
+
// hostile, deeply-nested) object collecting every violation — a resource-
|
|
55
|
+
// exhaustion / DoS amplifier (CodeQL js/resources-exhaustion). Fail-fast stops
|
|
56
|
+
// at the first violation, which is all the 400 response needs. The 50MB
|
|
57
|
+
// MAX_BODY_BYTES cap bounds input size; fail-fast bounds traversal cost.
|
|
58
|
+
const AjvCtor = Ajv.default || Ajv;
|
|
59
|
+
const _ajv = new AjvCtor({ strict: false });
|
|
60
|
+
const _validate = _ajv.compile(payloadSchema);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate a parsed body against payload-schema.json.
|
|
64
|
+
* @param {*} body
|
|
65
|
+
* @returns {{ valid: boolean, errors: Array }}
|
|
66
|
+
*/
|
|
67
|
+
function validatePayload(body) {
|
|
68
|
+
const valid = _validate(body) === true;
|
|
69
|
+
return { valid, errors: valid ? [] : (_validate.errors || []) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalize req.socket.remoteAddress to a loopback test (IPv4, IPv6, mapped). */
|
|
73
|
+
function isLoopbackRemote(remoteAddress) {
|
|
74
|
+
return (
|
|
75
|
+
remoteAddress === '127.0.0.1' ||
|
|
76
|
+
remoteAddress === '::1' ||
|
|
77
|
+
remoteAddress === '::ffff:127.0.0.1'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read the full request body with a hard size cap. Rejects on overflow. */
|
|
82
|
+
function readBody(req) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const chunks = [];
|
|
85
|
+
let size = 0;
|
|
86
|
+
req.on('data', (chunk) => {
|
|
87
|
+
size += chunk.length;
|
|
88
|
+
if (size > MAX_BODY_BYTES) {
|
|
89
|
+
reject(new Error('payload too large'));
|
|
90
|
+
req.destroy();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
});
|
|
95
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
96
|
+
req.on('error', reject);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** No-op logger fallback. The real seam receives lifecycle events + counts only. */
|
|
101
|
+
function emit(logger, event) {
|
|
102
|
+
if (typeof logger === 'function') {
|
|
103
|
+
try {
|
|
104
|
+
logger(event);
|
|
105
|
+
} catch {
|
|
106
|
+
/* a broken logger must never crash the receiver */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the request handler. Exported (`createHandler`) so tests can exercise
|
|
113
|
+
* the loopback gate / routing synchronously by invoking it with a fake req/res
|
|
114
|
+
* — no real remote socket needed (D-06 refusal path is asserted this way).
|
|
115
|
+
*
|
|
116
|
+
* The `onReceipt(parsed, filePath)` callback is invoked exactly once, on the
|
|
117
|
+
* first VALID localhost POST, AFTER the file is written. startReceiver wires it
|
|
118
|
+
* to close the server + resolve. Non-localhost (403), bad route (404), parse
|
|
119
|
+
* error / schema-invalid (400) NEVER call onReceipt — the server keeps waiting.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {string} opts.outDir
|
|
123
|
+
* @param {Function} [opts.logger]
|
|
124
|
+
* @param {Function} opts.onReceipt async (parsed, filePath) => void
|
|
125
|
+
* @returns {Function} (req, res) => void
|
|
126
|
+
*/
|
|
127
|
+
function createHandler({ outDir, logger, onReceipt }) {
|
|
128
|
+
return function handler(req, res) {
|
|
129
|
+
// (1) Loopback gate FIRST (D-06) — defense in depth on top of the bind.
|
|
130
|
+
const remoteAddress = req.socket && req.socket.remoteAddress;
|
|
131
|
+
if (!isLoopbackRemote(remoteAddress)) {
|
|
132
|
+
emit(logger, { event: 'reject-403', reason: 'non-localhost' });
|
|
133
|
+
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
134
|
+
res.end('forbidden: non-localhost');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// (2) Route — only POST /variables is handled.
|
|
139
|
+
if (req.method !== 'POST' || req.url !== '/variables') {
|
|
140
|
+
emit(logger, { event: 'reject-404', method: req.method, url: req.url });
|
|
141
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
142
|
+
res.end('not found');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// (3) Read + parse + validate + write. Any thrown error → 500 (no crash).
|
|
147
|
+
readBody(req)
|
|
148
|
+
.then(async (raw) => {
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(raw);
|
|
152
|
+
} catch {
|
|
153
|
+
emit(logger, { event: 'reject-400', reason: 'malformed-json' });
|
|
154
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
155
|
+
res.end(JSON.stringify({ error: 'malformed-json' }));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { valid, errors } = validatePayload(parsed);
|
|
160
|
+
if (!valid) {
|
|
161
|
+
emit(logger, { event: 'reject-400', reason: 'schema', errorCount: errors.length });
|
|
162
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify({ error: 'schema', details: errors }));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Valid. Stamp the marker if (defensively) absent, then write the file.
|
|
168
|
+
if (parsed.source !== PLUGIN_PAYLOAD_MARKER) parsed.source = PLUGIN_PAYLOAD_MARKER;
|
|
169
|
+
const filePath = path.join(outDir, 'variables.json');
|
|
170
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
171
|
+
await fs.writeFile(filePath, JSON.stringify(parsed));
|
|
172
|
+
|
|
173
|
+
emit(logger, {
|
|
174
|
+
event: 'receipt',
|
|
175
|
+
path: filePath,
|
|
176
|
+
collections: Array.isArray(parsed.collections) ? parsed.collections.length : 0,
|
|
177
|
+
variables: Array.isArray(parsed.variables) ? parsed.variables.length : 0,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
181
|
+
res.end(JSON.stringify({ ok: true }));
|
|
182
|
+
|
|
183
|
+
if (typeof onReceipt === 'function') await onReceipt(parsed, filePath);
|
|
184
|
+
})
|
|
185
|
+
.catch((err) => {
|
|
186
|
+
// Body-too-large or unexpected I/O error. Do NOT leak internals; do NOT
|
|
187
|
+
// resolve the receipt. 500 keeps the server waiting for a retry.
|
|
188
|
+
emit(logger, { event: 'error', message: err && err.message });
|
|
189
|
+
if (!res.headersSent) {
|
|
190
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
191
|
+
res.end(JSON.stringify({ error: 'internal' }));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Start the ephemeral Path C receiver.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} opts
|
|
201
|
+
* @param {string} opts.outDir REQUIRED — writes <outDir>/variables.json on valid receipt
|
|
202
|
+
* @param {number} [opts.timeoutMs=120000] exits if no valid payload arrives in time
|
|
203
|
+
* @param {Function} [opts.logger] structured lifecycle sink (never receives secrets/full payloads)
|
|
204
|
+
* @returns {Promise<{received:true, path:string} | {received:false, reason:'timeout'}>}
|
|
205
|
+
*
|
|
206
|
+
* Resolves with `{received:true, path}` on the FIRST valid POST /variables, or
|
|
207
|
+
* `{received:false, reason:'timeout'}` on timeout. The server is closed on BOTH
|
|
208
|
+
* exit paths (ephemeral — D-06). Non-localhost → 403; schema-invalid → 400;
|
|
209
|
+
* neither resolves the promise (the server keeps waiting until receipt/timeout).
|
|
210
|
+
*/
|
|
211
|
+
function startReceiver({ outDir, timeoutMs = 120000, logger } = {}) {
|
|
212
|
+
if (!outDir) {
|
|
213
|
+
return Promise.reject(new TypeError('startReceiver: opts.outDir is required'));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
let settled = false;
|
|
218
|
+
let timer = null;
|
|
219
|
+
let server = null;
|
|
220
|
+
|
|
221
|
+
const finish = (result) => {
|
|
222
|
+
if (settled) return;
|
|
223
|
+
settled = true;
|
|
224
|
+
if (timer) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
timer = null;
|
|
227
|
+
}
|
|
228
|
+
// Close the server on BOTH exit paths so the port is never left open and
|
|
229
|
+
// the event loop can drain (process can exit). close() is idempotent-safe.
|
|
230
|
+
if (server) server.close(() => resolve(result));
|
|
231
|
+
else resolve(result);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handler = createHandler({
|
|
235
|
+
outDir,
|
|
236
|
+
logger,
|
|
237
|
+
onReceipt: (_parsed, filePath) => finish({ received: true, path: filePath }),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
server = http.createServer(handler);
|
|
241
|
+
|
|
242
|
+
server.on('error', (err) => {
|
|
243
|
+
// Most likely EADDRINUSE (another receiver already bound 5179) — surface
|
|
244
|
+
// it to the caller rather than hanging. Only meaningful before listen.
|
|
245
|
+
if (!settled) {
|
|
246
|
+
settled = true;
|
|
247
|
+
if (timer) clearTimeout(timer);
|
|
248
|
+
reject(err);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
server.listen(RECEIVER_PORT, RECEIVER_HOST, () => {
|
|
253
|
+
emit(logger, { event: 'listen', host: RECEIVER_HOST, port: RECEIVER_PORT });
|
|
254
|
+
// Arm the timeout only once we are actually listening.
|
|
255
|
+
timer = setTimeout(() => {
|
|
256
|
+
emit(logger, { event: 'timeout', timeoutMs });
|
|
257
|
+
finish({ received: false, reason: 'timeout' });
|
|
258
|
+
}, timeoutMs);
|
|
259
|
+
// Don't let the timeout itself keep the process alive past its purpose.
|
|
260
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
startReceiver,
|
|
267
|
+
createHandler,
|
|
268
|
+
validatePayload,
|
|
269
|
+
isLoopbackRemote,
|
|
270
|
+
RECEIVER_PORT,
|
|
271
|
+
RECEIVER_HOST,
|
|
272
|
+
PLUGIN_PAYLOAD_MARKER,
|
|
273
|
+
};
|