@hegemonart/get-design-done 1.30.6 → 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 +61 -0
- package/README.md +21 -0
- package/SKILL.md +1 -0
- package/package.json +5 -2
- 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/health-mirror/index.cjs +88 -1
- package/skills/figma-extract/SKILL.md +64 -0
- package/skills/health/SKILL.md +10 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/figma-extract/parse-url.cjs — Plan 31-01 (Wave A.1)
|
|
3
|
+
//
|
|
4
|
+
// Figma file URL/key parser. Accepts either a bare file key or a full Figma
|
|
5
|
+
// file URL and returns the canonical file key. Both legacy `/file/<key>/...`
|
|
6
|
+
// and newer `/design/<key>/...` URL forms are supported.
|
|
7
|
+
//
|
|
8
|
+
// CommonJS, zero external dependencies. Pure function — no I/O, no logging.
|
|
9
|
+
//
|
|
10
|
+
// Examples:
|
|
11
|
+
// parseFigmaFileKey('IAHNrYoqIh56SCxgv3PjCS')
|
|
12
|
+
// → 'IAHNrYoqIh56SCxgv3PjCS' (bare-key passthrough)
|
|
13
|
+
// parseFigmaFileKey('https://www.figma.com/file/IAHNrYoqIh56SCxgv3PjCS/My-DS?node-id=0-1')
|
|
14
|
+
// → 'IAHNrYoqIh56SCxgv3PjCS'
|
|
15
|
+
// parseFigmaFileKey('https://www.figma.com/design/IAHNrYoqIh56SCxgv3PjCS/My-DS')
|
|
16
|
+
// → 'IAHNrYoqIh56SCxgv3PjCS'
|
|
17
|
+
// parseFigmaFileKey('') → throws TypeError
|
|
18
|
+
// parseFigmaFileKey('https://example.com/no-key') → throws Error
|
|
19
|
+
|
|
20
|
+
// Matches the key segment after `/file/` or `/design/` in a Figma URL.
|
|
21
|
+
// Figma file keys are URL-safe base62-ish tokens ([A-Za-z0-9]).
|
|
22
|
+
const URL_KEY_RE = /(?:file|design)\/([A-Za-z0-9]+)/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a Figma file key from a bare key or a full Figma file URL.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} input - bare key | https://www.figma.com/file/<key>/... | .../design/<key>/...
|
|
28
|
+
* @returns {string} the extracted file key
|
|
29
|
+
* @throws {TypeError} when input is missing / empty / not a string
|
|
30
|
+
* @throws {Error} when input looks like a URL but no file key can be extracted
|
|
31
|
+
*/
|
|
32
|
+
function parseFigmaFileKey(input) {
|
|
33
|
+
if (typeof input !== 'string') {
|
|
34
|
+
throw new TypeError('parseFigmaFileKey: non-empty input (file key or URL) required');
|
|
35
|
+
}
|
|
36
|
+
const trimmed = input.trim();
|
|
37
|
+
if (trimmed === '') {
|
|
38
|
+
throw new TypeError('parseFigmaFileKey: non-empty input (file key or URL) required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// URL form carrying an http(s) scheme: validate the HOST is figma.com via the
|
|
42
|
+
// URL parser — NOT a substring check. A substring test (`includes('figma.com')`)
|
|
43
|
+
// is unsafe: `https://figma.com.evil.test/...` and `https://evil.test/figma.com`
|
|
44
|
+
// both contain the literal but are not Figma (CodeQL js/incomplete-url-substring-
|
|
45
|
+
// sanitization). new URL().hostname gives the real authority to compare exactly.
|
|
46
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
47
|
+
if (hasScheme) {
|
|
48
|
+
let host;
|
|
49
|
+
try {
|
|
50
|
+
host = new URL(trimmed).hostname.toLowerCase();
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error('parseFigmaFileKey: malformed URL: ' + trimmed);
|
|
53
|
+
}
|
|
54
|
+
const isFigmaHost =
|
|
55
|
+
host === 'figma.com' || host === 'www.figma.com' || host.endsWith('.figma.com');
|
|
56
|
+
if (!isFigmaHost) {
|
|
57
|
+
throw new Error('parseFigmaFileKey: not a figma.com URL: ' + trimmed);
|
|
58
|
+
}
|
|
59
|
+
const match = trimmed.match(URL_KEY_RE);
|
|
60
|
+
if (!match) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'parseFigmaFileKey: could not extract a Figma file key from URL: ' + trimmed
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return match[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Scheme-less path form (e.g. `www.figma.com/file/<key>` or `/design/<key>/...`):
|
|
69
|
+
// there is no parseable authority, so extract the key from the path segment.
|
|
70
|
+
if (trimmed.includes('/')) {
|
|
71
|
+
const match = trimmed.match(URL_KEY_RE);
|
|
72
|
+
if (!match) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'parseFigmaFileKey: could not extract a Figma file key from URL: ' + trimmed
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return match[1];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bare key — a single URL-safe base62-ish token (no scheme, no path).
|
|
81
|
+
if (!/^[A-Za-z0-9]+$/.test(trimmed)) {
|
|
82
|
+
throw new Error('parseFigmaFileKey: invalid Figma file key: ' + trimmed);
|
|
83
|
+
}
|
|
84
|
+
return trimmed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { parseFigmaFileKey };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://get-design-done/figma-extract/payload-schema.json",
|
|
4
|
+
"title": "GDD Sync plugin variables payload (Path C contract — D-04, D-13)",
|
|
5
|
+
"description": "The JSON body the Figma plugin 'GDD Sync' (31-05) POSTs to the localhost receiver (31-06) at 127.0.0.1:5179/variables. Carries ALL local variables (D-13); filtering happens later in digest.cjs (31-02). The top-level `source` const is the marker digest.cjs keys on to route this file to Path C. This schema is the source of truth that figma-plugin/src/payload-schema.ts mirrors.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["source", "collections", "variables"],
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"source": {
|
|
11
|
+
"const": "gdd-plugin",
|
|
12
|
+
"description": "Path C marker. digest.cjs (31-02) routes a variables.json carrying this top-level field to the plugin path (NOT the Variables-API path)."
|
|
13
|
+
},
|
|
14
|
+
"fileKey": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Optional Figma file key the variables were read from (provenance only)."
|
|
17
|
+
},
|
|
18
|
+
"exportedAt": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Optional ISO-8601 timestamp of when the plugin exported the payload."
|
|
21
|
+
},
|
|
22
|
+
"collections": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"description": "Local variable collections. Each carries its modes so digest can label valuesByMode.",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["id", "name", "modes"],
|
|
28
|
+
"additionalProperties": true,
|
|
29
|
+
"properties": {
|
|
30
|
+
"id": { "type": "string" },
|
|
31
|
+
"name": { "type": "string" },
|
|
32
|
+
"modes": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"required": ["modeId", "name"],
|
|
37
|
+
"additionalProperties": true,
|
|
38
|
+
"properties": {
|
|
39
|
+
"modeId": { "type": "string" },
|
|
40
|
+
"name": { "type": "string" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"variables": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"description": "ALL local variables (D-13 — published flag is NOT required; digest filters). Each value per mode is either a resolved primitive/color OR an alias reference.",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["id", "name", "resolvedType", "collectionId", "valuesByMode"],
|
|
53
|
+
"additionalProperties": true,
|
|
54
|
+
"properties": {
|
|
55
|
+
"id": { "type": "string" },
|
|
56
|
+
"name": { "type": "string" },
|
|
57
|
+
"resolvedType": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"enum": ["COLOR", "FLOAT", "STRING", "BOOLEAN"]
|
|
60
|
+
},
|
|
61
|
+
"collectionId": { "type": "string" },
|
|
62
|
+
"valuesByMode": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"description": "Keyed by modeId. A value is a resolved primitive (number/string/boolean), a resolved color object {r,g,b,a?}, or an alias marker ({type:'VARIABLE_ALIAS', id} or {alias:<name>}).",
|
|
65
|
+
"additionalProperties": {
|
|
66
|
+
"oneOf": [
|
|
67
|
+
{ "type": "number" },
|
|
68
|
+
{ "type": "string" },
|
|
69
|
+
{ "type": "boolean" },
|
|
70
|
+
{
|
|
71
|
+
"type": "object",
|
|
72
|
+
"description": "Resolved color (Figma 0..1 floats).",
|
|
73
|
+
"required": ["r", "g", "b"],
|
|
74
|
+
"additionalProperties": true,
|
|
75
|
+
"properties": {
|
|
76
|
+
"r": { "type": "number" },
|
|
77
|
+
"g": { "type": "number" },
|
|
78
|
+
"b": { "type": "number" },
|
|
79
|
+
"a": { "type": "number" }
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "object",
|
|
84
|
+
"description": "Alias reference to another variable id.",
|
|
85
|
+
"required": ["type", "id"],
|
|
86
|
+
"additionalProperties": true,
|
|
87
|
+
"properties": {
|
|
88
|
+
"type": { "const": "VARIABLE_ALIAS" },
|
|
89
|
+
"id": { "type": "string" }
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"type": "object",
|
|
94
|
+
"description": "Alias reference by name.",
|
|
95
|
+
"required": ["alias"],
|
|
96
|
+
"additionalProperties": true,
|
|
97
|
+
"properties": {
|
|
98
|
+
"alias": { "type": "string" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|