figma-cache-toolchain 1.4.2 → 1.4.4

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.
@@ -4,10 +4,49 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { createRequire } = require("module");
6
6
  const { URL } = require("url");
7
+ const { handleFlowCommand } = require("./js/flow-cli");
8
+ const { validateMcpRawEvidence, validateIndex } = require("./js/validate-cli");
9
+ const { buildBudgetReport } = require("./js/budget-cli");
10
+ const { createIndexStore } = require("./js/index-store");
11
+ const { copyCursorBootstrap } = require("./js/cursor-bootstrap-cli");
12
+ const { createEntryFilesService } = require("./js/entry-files");
13
+ const { backfillFromIterations } = require("./js/backfill-cli");
14
+ const { createUpsertService } = require("./js/upsert-core");
15
+ const { createProjectConfigService } = require("./js/project-config");
7
16
 
8
17
  const ROOT = process.cwd();
9
18
  const NORMALIZATION_VERSION = 1;
10
19
  const SCHEMA_VERSION = 2;
20
+ const DEFAULT_COMPLETENESS = Object.freeze([
21
+ "layout",
22
+ "text",
23
+ "tokens",
24
+ "interactions",
25
+ "states",
26
+ "accessibility",
27
+ ]);
28
+ const COMPLETENESS_ALL_DIMENSIONS = Object.freeze([
29
+ "layout",
30
+ "text",
31
+ "tokens",
32
+ "interactions",
33
+ "states",
34
+ "accessibility",
35
+ "flow",
36
+ "assets",
37
+ ]);
38
+ const COMPLETENESS_TOOL_REQUIREMENTS = Object.freeze({
39
+ layout: Object.freeze([
40
+ Object.freeze(["get_metadata", "get_design_context"]),
41
+ ]),
42
+ text: Object.freeze([Object.freeze(["get_design_context"])]),
43
+ tokens: Object.freeze([Object.freeze(["get_variable_defs"])]),
44
+ interactions: Object.freeze([Object.freeze(["get_design_context"])]),
45
+ states: Object.freeze([Object.freeze(["get_design_context"])]),
46
+ accessibility: Object.freeze([Object.freeze(["get_design_context"])]),
47
+ flow: Object.freeze([Object.freeze(["get_design_context"])]),
48
+ assets: Object.freeze([Object.freeze(["get_design_context"])]),
49
+ });
11
50
 
12
51
  function parsePositiveInt(input, fallback) {
13
52
  const n = Number(input);
@@ -40,15 +79,15 @@ const INDEX_FILE_NAME = process.env.FIGMA_CACHE_INDEX_FILE || "index.json";
40
79
  const DEFAULT_FLOW_ID = process.env.FIGMA_DEFAULT_FLOW || "";
41
80
  const DEFAULT_STALE_DAYS = parsePositiveInt(
42
81
  process.env.FIGMA_CACHE_STALE_DAYS,
43
- 14
82
+ 14,
44
83
  );
45
84
 
46
85
  const CACHE_DIR = resolveMaybeAbsolutePath(CACHE_DIR_INPUT);
47
- /** 与 `figma-cache/figma-cache.js` 同级的 `cursor-bootstrap/`(随 npm 包分发) */
86
+ /** 涓?`figma-cache/figma-cache.js` 鍚岀骇鐨?`cursor-bootstrap/`锛堥殢 npm 鍖呭垎鍙戯級 */
48
87
  const CURSOR_BOOTSTRAP_DIR = path.join(__dirname, "..", "cursor-bootstrap");
49
88
  const ITERATIONS_DIR = resolveMaybeAbsolutePath(ITERATIONS_DIR_INPUT);
50
89
 
51
- /** 当前安装包在 package.json 中的 name(用于写入 AGENT-SETUP-PROMPT.md) */
90
+ /** 褰撳墠瀹夎鍖呭湪 package.json 涓殑 name锛堢敤浜庡啓鍏?AGENT-SETUP-PROMPT.md锛?*/
52
91
  function readSelfNpmPackageName() {
53
92
  try {
54
93
  const pkgPath = path.join(__dirname, "..", "package.json");
@@ -64,89 +103,45 @@ const INDEX_PATH = path.isAbsolute(INDEX_FILE_NAME)
64
103
  : path.join(CACHE_DIR, INDEX_FILE_NAME);
65
104
  const CACHE_BASE_FOR_STORAGE = toProjectRelativeOrAbsolute(CACHE_DIR);
66
105
 
67
- /** @type {object | null} null = not loaded yet; after load always an object (possibly empty) */
68
- let memoProjectConfig = null;
69
- /** @type {string | null} */
70
- let memoProjectConfigPath = null;
71
-
72
- /**
73
- * Load optional project-level config from (first match):
74
- * 1) FIGMA_CACHE_PROJECT_CONFIG (absolute or relative to ROOT)
75
- * 2) figma-cache.config.js
76
- * 3) .figmacacherc.js
77
- * Core stays agnostic: only reads `hooks` and other neutral keys.
78
- */
79
- function loadProjectConfig() {
80
- if (memoProjectConfig) {
81
- return memoProjectConfig;
82
- }
83
- const candidates = [];
84
- if (process.env.FIGMA_CACHE_PROJECT_CONFIG) {
85
- candidates.push(resolveMaybeAbsolutePath(process.env.FIGMA_CACHE_PROJECT_CONFIG));
86
- }
87
- candidates.push(path.join(ROOT, "figma-cache.config.js"));
88
- candidates.push(path.join(ROOT, ".figmacacherc.js"));
89
-
90
- const requireFromRoot = createRequire(path.join(ROOT, "package.json"));
91
-
92
- for (const absPath of candidates) {
93
- if (!fs.existsSync(absPath)) {
94
- continue;
95
- }
96
- try {
97
- const mod = requireFromRoot(absPath);
98
- const cfg = mod && mod.default ? mod.default : mod;
99
- memoProjectConfig = cfg && typeof cfg === "object" ? cfg : {};
100
- memoProjectConfigPath = absPath;
101
- return memoProjectConfig;
102
- } catch (err) {
103
- console.error(`[figma-cache] project config failed (${absPath}): ${err.message}`);
104
- }
105
- }
106
-
107
- memoProjectConfig = {};
108
- memoProjectConfigPath = null;
109
- return memoProjectConfig;
110
- }
111
-
112
- /**
113
- * After generic entry files exist, run optional hooks.postEnsure from project config.
114
- * Never throws; never changes process exit code of the caller.
115
- * @param {string} cacheKey
116
- * @param {object} item index item
117
- */
118
- function runPostEnsureHook(cacheKey, item) {
119
- if (!item || !item.paths) {
120
- return;
121
- }
122
- const cfg = loadProjectConfig();
123
- const hooks = cfg && cfg.hooks;
124
- if (!hooks || typeof hooks.postEnsure !== "function") {
125
- return;
126
- }
127
- const ctx = {
128
- cacheKey,
129
- fileKey: item.fileKey,
130
- nodeId: item.nodeId == null ? null : item.nodeId,
131
- scope: item.scope,
132
- url: item.url == null ? "" : String(item.url),
133
- source: item.source == null ? "" : String(item.source),
134
- syncedAt: item.syncedAt == null ? "" : String(item.syncedAt),
135
- completeness: Array.isArray(item.completeness) ? item.completeness : [],
136
- paths: {
137
- raw: item.paths.raw,
138
- spec: item.paths.spec,
139
- meta: item.paths.meta,
140
- stateMap: item.paths.stateMap,
141
- },
142
- root: ROOT,
143
- };
144
- try {
145
- hooks.postEnsure(ctx);
146
- } catch (err) {
147
- console.error(`[figma-cache] hooks.postEnsure: ${err.message}`);
148
- }
149
- }
106
+ const indexStore = createIndexStore({
107
+ fs,
108
+ CACHE_DIR,
109
+ INDEX_PATH,
110
+ SCHEMA_VERSION,
111
+ NORMALIZATION_VERSION,
112
+ });
113
+ const {
114
+ ensureCacheDir,
115
+ buildEmptyIndex,
116
+ normalizeIndexShape,
117
+ readIndex,
118
+ writeIndex,
119
+ getItem,
120
+ } = indexStore;
121
+
122
+ const projectConfigService = createProjectConfigService({
123
+ fs,
124
+ path,
125
+ ROOT,
126
+ createRequire,
127
+ resolveMaybeAbsolutePath,
128
+ normalizeSlash,
129
+ });
130
+ const { loadProjectConfig, runPostEnsureHook, getProjectConfigPath } =
131
+ projectConfigService;
132
+
133
+ const upsertService = createUpsertService({
134
+ URL,
135
+ NORMALIZATION_VERSION,
136
+ CACHE_BASE_FOR_STORAGE,
137
+ DEFAULT_COMPLETENESS,
138
+ normalizeCompletenessList,
139
+ normalizeIndexShape,
140
+ readIndex,
141
+ getItem,
142
+ writeIndex,
143
+ });
144
+ const { normalizeFigmaUrl, previewUpsertByUrl, upsertByUrl } = upsertService;
150
145
 
151
146
  function resolveFlowIdFromArgs(rest) {
152
147
  const flowArg = rest.find((x) => x.startsWith("--flow="));
@@ -159,479 +154,47 @@ function resolveFlowIdFromArgs(rest) {
159
154
  return "";
160
155
  }
161
156
 
162
- function ensureCacheDir() {
163
- if (!fs.existsSync(CACHE_DIR)) {
164
- fs.mkdirSync(CACHE_DIR, { recursive: true });
165
- }
166
- }
167
-
168
- function readIndex() {
169
- ensureCacheDir();
170
- if (!fs.existsSync(INDEX_PATH)) {
171
- return buildEmptyIndex();
172
- }
173
- const raw = fs.readFileSync(INDEX_PATH, "utf8");
174
- return normalizeIndexShape(JSON.parse(raw));
175
- }
176
-
177
- function buildEmptyIndex() {
178
- return {
179
- schemaVersion: SCHEMA_VERSION,
180
- version: 1,
181
- normalizationVersion: NORMALIZATION_VERSION,
182
- updatedAt: null,
183
- flows: {},
184
- items: {},
185
- };
186
- }
187
-
188
- function normalizeIndexShape(index) {
189
- if (!index || typeof index !== "object") {
190
- return buildEmptyIndex();
191
- }
192
- if (!index.schemaVersion) {
193
- index.schemaVersion = SCHEMA_VERSION;
194
- }
195
- if (!index.flows || typeof index.flows !== "object") {
196
- index.flows = {};
197
- }
198
- if (!index.items || typeof index.items !== "object") {
199
- index.items = {};
200
- }
201
- if (!index.version) {
202
- index.version = 1;
203
- }
204
- if (!index.normalizationVersion) {
205
- index.normalizationVersion = NORMALIZATION_VERSION;
206
- }
207
- return index;
208
- }
209
-
210
- function writeIndex(index) {
211
- const normalized = normalizeIndexShape(index);
212
- normalized.version = 1;
213
- normalized.schemaVersion = SCHEMA_VERSION;
214
- normalized.normalizationVersion = NORMALIZATION_VERSION;
215
- normalized.updatedAt = new Date().toISOString();
216
- fs.writeFileSync(INDEX_PATH, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
217
- }
218
-
219
- function sanitizeNodeId(nodeId) {
220
- return String(nodeId).replace(/:/g, "-");
221
- }
222
-
223
- function normalizeNodeIdValue(nodeId) {
224
- const raw = String(nodeId).trim();
225
- const dashPattern = /^(\d+)-(\d+)$/;
226
- if (dashPattern.test(raw)) {
227
- return raw.replace(dashPattern, "$1:$2");
228
- }
229
- return raw;
230
- }
231
-
232
- function normalizeFigmaUrl(inputUrl) {
233
- let parsed;
234
- try {
235
- parsed = new URL(inputUrl);
236
- } catch (error) {
237
- throw new Error(`非法 URL: ${inputUrl}`);
238
- }
239
-
240
- const hostOk = /(^|\.)figma\.com$/i.test(parsed.hostname);
241
- if (!hostOk) {
242
- throw new Error(`非 Figma 域名: ${parsed.hostname}`);
243
- }
244
-
245
- const parts = parsed.pathname.split("/").filter(Boolean);
246
- const routeType = parts[0];
247
- const fileKey = parts[1];
248
- if (!["file", "design"].includes(routeType) || !fileKey) {
249
- throw new Error(`无法从路径提取 fileKey: ${parsed.pathname}`);
250
- }
251
-
252
- const nodeIdRaw = parsed.searchParams.get("node-id");
253
- const nodeId = nodeIdRaw ? normalizeNodeIdValue(decodeURIComponent(nodeIdRaw)) : null;
254
- const isNodeScope = !!nodeId;
255
- const scope = isNodeScope ? "node" : "file";
256
- const cacheKey = isNodeScope ? `${fileKey}#${nodeId}` : `${fileKey}#__FILE__`;
257
-
258
- return {
259
- fileKey,
260
- nodeId,
261
- scope,
262
- cacheKey,
263
- normalizedUrl: isNodeScope
264
- ? `https://www.figma.com/file/${fileKey}/?node-id=${encodeURIComponent(nodeId)}`
265
- : `https://www.figma.com/file/${fileKey}/`,
266
- originalUrl: inputUrl,
267
- normalizationVersion: NORMALIZATION_VERSION,
268
- };
269
- }
270
-
271
- function buildPaths(normalized) {
272
- if (normalized.scope === "file") {
273
- return {
274
- meta: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/meta.json`,
275
- spec: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/spec.md`,
276
- stateMap: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/state-map.md`,
277
- raw: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/raw.json`,
278
- };
279
- }
280
-
281
- const safeNode = sanitizeNodeId(normalized.nodeId);
282
- return {
283
- meta: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/meta.json`,
284
- spec: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/spec.md`,
285
- stateMap: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/state-map.md`,
286
- raw: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/raw.json`,
287
- };
288
- }
289
-
290
- function getItem(index, cacheKey) {
291
- const normalized = normalizeIndexShape(index);
292
- return normalized.items && normalized.items[cacheKey] ? normalized.items[cacheKey] : null;
293
- }
294
-
295
- function upsertByUrl(inputUrl, extra) {
296
- const normalized = normalizeFigmaUrl(inputUrl);
297
- const index = normalizeIndexShape(readIndex());
298
- const oldItem = getItem(index, normalized.cacheKey);
299
- const mergedUrls = Array.from(
300
- new Set([...(oldItem ? oldItem.originalUrls || [] : []), normalized.originalUrl])
301
- );
302
- const now = new Date().toISOString();
303
-
304
- const item = {
305
- fileKey: normalized.fileKey,
306
- nodeId: normalized.nodeId,
307
- scope: normalized.scope,
308
- url: normalized.normalizedUrl,
309
- originalUrls: mergedUrls,
310
- normalizationVersion: NORMALIZATION_VERSION,
311
- paths: oldItem && oldItem.paths ? oldItem.paths : buildPaths(normalized),
312
- syncedAt: now,
313
- completeness: Array.isArray(extra.completeness)
314
- ? extra.completeness
315
- : oldItem && Array.isArray(oldItem.completeness)
316
- ? oldItem.completeness
317
- : [],
318
- source: extra.source || (oldItem && oldItem.source) || "manual",
319
- };
320
-
321
- index.items = index.items || {};
322
- index.items[normalized.cacheKey] = item;
323
- writeIndex(index);
324
- return { normalized, item };
325
- }
326
-
327
- function ensureFileWithDefault(relativePath, fallbackContent) {
328
- const absPath = resolveMaybeAbsolutePath(relativePath);
329
- const dir = path.dirname(absPath);
330
- if (!fs.existsSync(dir)) {
331
- fs.mkdirSync(dir, { recursive: true });
332
- }
333
- if (!fs.existsSync(absPath)) {
334
- fs.writeFileSync(absPath, fallbackContent, "utf8");
335
- }
336
- }
337
-
338
- function ensureEntryFiles(item) {
339
- ensureFileWithDefault(
340
- item.paths.meta,
341
- `${JSON.stringify(
342
- {
343
- fileKey: item.fileKey,
344
- nodeId: item.nodeId,
345
- scope: item.scope,
346
- source: item.source,
347
- syncedAt: item.syncedAt,
348
- },
349
- null,
350
- 2
351
- )}\n`
352
- );
353
- ensureFileWithDefault(
354
- item.paths.spec,
355
- `# Figma Spec\n\n- fileKey: ${item.fileKey}\n- scope: ${item.scope}\n- nodeId: ${item.nodeId || "N/A"}\n- source: ${item.source}\n- syncedAt: ${item.syncedAt}\n`
356
- );
357
- ensureFileWithDefault(
358
- item.paths.stateMap,
359
- `# State Map\n\n- TODO: 补充状态与交互映射。\n`
360
- );
361
- ensureFileWithDefault(item.paths.raw, "{}\n");
362
- }
363
-
364
- /**
365
- * Writes generic cache skeleton files, then invokes optional lifecycle hook.
366
- * @param {string} cacheKey
367
- * @param {object} item
368
- */
369
- function ensureEntryFilesAndHook(cacheKey, item) {
370
- ensureEntryFiles(item);
371
- runPostEnsureHook(cacheKey, item);
372
- }
373
-
374
- function validateIndex(index) {
375
- const errors = [];
376
- const normalized = normalizeIndexShape(index);
377
- const keys = Object.keys(normalized.items || {});
378
- keys.forEach((cacheKey) => {
379
- const item = normalized.items[cacheKey];
380
- const required = [
381
- "fileKey",
382
- "scope",
383
- "url",
384
- "originalUrls",
385
- "normalizationVersion",
386
- "paths",
387
- "syncedAt",
388
- "completeness",
389
- ];
390
- required.forEach((field) => {
391
- if (item[field] === undefined || item[field] === null) {
392
- errors.push(`${cacheKey}: 缺少字段 ${field}`);
393
- }
394
- });
395
- if (item.scope === "node" && !item.nodeId) {
396
- errors.push(`${cacheKey}: node 作用域必须包含 nodeId`);
397
- }
398
- });
399
-
400
- const flowKeys = Object.keys(normalized.flows || {});
401
- flowKeys.forEach((flowId) => {
402
- const flow = normalized.flows[flowId];
403
- if (!flow || typeof flow !== "object") {
404
- errors.push(`flow ${flowId}: 非法结构`);
405
- return;
406
- }
407
- if (!flow.id || flow.id !== flowId) {
408
- errors.push(`flow ${flowId}: id 字段缺失或不一致`);
409
- }
410
- if (!Array.isArray(flow.nodes)) {
411
- errors.push(`flow ${flowId}: nodes 必须是数组`);
412
- }
413
- if (!Array.isArray(flow.edges)) {
414
- errors.push(`flow ${flowId}: edges 必须是数组`);
415
- }
416
- if (Array.isArray(flow.edges)) {
417
- flow.edges.forEach((edge, idx) => {
418
- if (!edge || typeof edge !== "object") {
419
- errors.push(`flow ${flowId}: edge[${idx}] 非法`);
420
- return;
421
- }
422
- if (!edge.from || !edge.to) {
423
- errors.push(`flow ${flowId}: edge[${idx}] 缺少 from/to`);
424
- }
425
- if (!edge.type) {
426
- errors.push(`flow ${flowId}: edge[${idx}] 缺少 type`);
427
- }
428
- if (edge.from && !normalized.items[edge.from]) {
429
- errors.push(`flow ${flowId}: edge[${idx}] from 不存在于 items: ${edge.from}`);
430
- }
431
- if (edge.to && !normalized.items[edge.to]) {
432
- errors.push(`flow ${flowId}: edge[${idx}] to 不存在于 items: ${edge.to}`);
433
- }
434
- });
435
- }
436
- if (Array.isArray(flow.nodes)) {
437
- flow.nodes.forEach((cacheKey) => {
438
- if (!normalized.items[cacheKey]) {
439
- errors.push(`flow ${flowId}: nodes 引用不存在于 items: ${cacheKey}`);
440
- }
441
- });
442
- }
443
- });
444
-
445
- return errors;
446
- }
447
-
448
- /** @param {boolean} force 为 true 时覆盖已存在文件 */
449
- function copyCursorBootstrap(force) {
450
- const pairs = [
451
- { from: path.join("rules", "01-figma-cache-core.mdc"), to: path.join(".cursor", "rules", "01-figma-cache-core.mdc") },
452
- { from: path.join("rules", "02-figma-stack-adapter.mdc"), to: path.join(".cursor", "rules", "02-figma-stack-adapter.mdc") },
453
- { from: path.join("rules", "figma-local-cache-first.mdc"), to: path.join(".cursor", "rules", "figma-local-cache-first.mdc") },
454
- {
455
- from: path.join("skills", "figma-mcp-local-cache", "SKILL.md"),
456
- to: path.join(".cursor", "skills", "figma-mcp-local-cache", "SKILL.md"),
457
- },
458
- { from: "figma-cache.config.example.js", to: "figma-cache.config.example.js" },
459
- ];
460
- if (!fs.existsSync(CURSOR_BOOTSTRAP_DIR)) {
461
- console.error(
462
- `[figma-cache] cursor-bootstrap not found at ${normalizeSlash(CURSOR_BOOTSTRAP_DIR)} (broken package install?)`
463
- );
464
- process.exit(1);
465
- }
466
- let copied = 0;
467
- let skipped = 0;
468
- pairs.forEach(({ from: relFrom, to: relTo }) => {
469
- const absFrom = path.join(CURSOR_BOOTSTRAP_DIR, relFrom);
470
- const absTo = path.join(ROOT, relTo);
471
- if (!fs.existsSync(absFrom)) {
472
- console.error(`[figma-cache] missing template file: ${normalizeSlash(absFrom)}`);
473
- process.exit(1);
474
- }
475
- fs.mkdirSync(path.dirname(absTo), { recursive: true });
476
- if (fs.existsSync(absTo) && !force) {
477
- skipped += 1;
478
- return;
479
- }
480
- fs.copyFileSync(absFrom, absTo);
481
- copied += 1;
482
- });
483
-
484
- const agentSrc = path.join(CURSOR_BOOTSTRAP_DIR, "AGENT-SETUP-PROMPT.md");
485
- const agentDest = path.join(ROOT, "AGENT-SETUP-PROMPT.md");
486
- if (!fs.existsSync(agentSrc)) {
487
- console.error(`[figma-cache] missing ${normalizeSlash(agentSrc)}`);
488
- process.exit(1);
489
- }
490
- let agentBody = fs.readFileSync(agentSrc, "utf8");
491
- const npmPkg = readSelfNpmPackageName();
492
- agentBody = agentBody.replace(/\{\{NPM_PACKAGE_NAME\}\}/g, npmPkg);
493
- fs.writeFileSync(agentDest, agentBody, "utf8");
494
-
495
- const colleagueSrc = path.join(__dirname, "colleague-guide-zh.md");
496
- const colleagueDest = path.join(CACHE_DIR, "colleague-guide-zh.md");
497
- if (!fs.existsSync(colleagueSrc)) {
498
- console.error(`[figma-cache] missing ${normalizeSlash(colleagueSrc)} (broken package install?)`);
499
- process.exit(1);
500
- }
501
- const colleagueSameFile = path.resolve(colleagueSrc) === path.resolve(colleagueDest);
502
- if (!colleagueSameFile) {
503
- fs.mkdirSync(CACHE_DIR, { recursive: true });
504
- fs.copyFileSync(colleagueSrc, colleagueDest);
505
- }
506
-
507
- console.log(
508
- JSON.stringify(
509
- {
510
- ok: true,
511
- root: normalizeSlash(ROOT),
512
- copied,
513
- skipped,
514
- force: !!force,
515
- hint: skipped
516
- ? "Some template files were skipped (already exist). Re-run with --force to overwrite."
517
- : "Done.",
518
- agentPromptFile: normalizeSlash(agentDest),
519
- colleagueGuideFile: normalizeSlash(colleagueDest),
520
- colleagueGuideSynced: !colleagueSameFile,
521
- colleagueGuideNote: colleagueSameFile
522
- ? "colleague-guide-zh.md already at package path (toolchain dev tree); no copy."
523
- : "colleague-guide-zh.md refreshed under FIGMA_CACHE_DIR (default figma-cache/).",
524
- agentPromptNote:
525
- "AGENT-SETUP-PROMPT.md is refreshed every run. Next: @ it in Cursor; after Agent finishes, run npm run figma:cache:init (or npx figma-cache init if scripts are missing).",
526
- npmPackageName: npmPkg,
527
- },
528
- null,
529
- 2
530
- )
531
- );
532
- console.log(
533
- "\n" +
534
- "================================================================\n" +
535
- "下一步(请按顺序):\n" +
536
- "1) 在 Cursor 对话中输入 @AGENT-SETUP-PROMPT.md,并说明「按该文档执行」\n" +
537
- " (每次 cursor init 都会刷新该文件;无需再整篇粘贴。)\n" +
538
- "2) 待 Agent 完成后,在项目根初始化本地缓存索引:\n" +
539
- " npm run figma:cache:init\n" +
540
- " 若尚未补全 npm scripts,请改用:npx figma-cache init\n" +
541
- "================================================================\n"
542
- );
543
- }
544
-
545
- function collectMarkdownFiles(dir) {
546
- if (!fs.existsSync(dir)) {
157
+ function normalizeCompletenessList(input) {
158
+ if (!Array.isArray(input)) {
547
159
  return [];
548
160
  }
161
+ const seen = new Set();
549
162
  const output = [];
550
- const list = fs.readdirSync(dir, { withFileTypes: true });
551
- list.forEach((entry) => {
552
- const fullPath = path.join(dir, entry.name);
553
- if (entry.isDirectory()) {
554
- output.push(...collectMarkdownFiles(fullPath));
163
+ input.forEach((entry) => {
164
+ const value = String(entry || "").trim();
165
+ if (!value || seen.has(value)) {
555
166
  return;
556
167
  }
557
- if (entry.isFile() && entry.name.endsWith(".md")) {
558
- output.push(fullPath);
559
- }
168
+ seen.add(value);
169
+ output.push(value);
560
170
  });
561
171
  return output;
562
172
  }
563
173
 
564
- function extractFigmaUrls(content) {
565
- const pattern = /https:\/\/www\.figma\.com\/(?:file|design)\/[^\s)\]]+/g;
566
- return content.match(pattern) || [];
567
- }
568
-
569
- function backfillFromIterations() {
570
- const files = collectMarkdownFiles(ITERATIONS_DIR);
571
- let hit = 0;
572
- files.forEach((filePath) => {
573
- const content = fs.readFileSync(filePath, "utf8");
574
- const urls = extractFigmaUrls(content);
575
- urls.forEach((url) => {
576
- try {
577
- upsertByUrl(url, { source: "backfill" });
578
- hit += 1;
579
- } catch (error) {
580
- // 忽略无法解析的 URL
581
- }
582
- });
583
- });
584
- console.log(`Backfill done. scanned files=${files.length}, urls=${hit}`);
585
- }
586
-
587
- function slugifyFlowId(name) {
588
- const raw = String(name || "")
589
- .trim()
590
- .toLowerCase()
591
- .replace(/[^a-z0-9]+/g, "-")
592
- .replace(/^-+|-+$/g, "");
593
- return raw || `flow-${Date.now()}`;
594
- }
174
+ const entryFilesService = createEntryFilesService({
175
+ fs,
176
+ path,
177
+ resolveMaybeAbsolutePath,
178
+ normalizeCompletenessList,
179
+ completenessAllDimensions: COMPLETENESS_ALL_DIMENSIONS,
180
+ runPostEnsureHook,
181
+ });
182
+ const { ensureEntryFilesAndHook } = entryFilesService;
595
183
 
596
- function ensureFlow(index, flowId, meta) {
597
- const normalized = normalizeIndexShape(index);
598
- normalized.flows = normalized.flows || {};
599
- if (!normalized.flows[flowId]) {
600
- normalized.flows[flowId] = {
601
- id: flowId,
602
- title: meta && meta.title ? meta.title : flowId,
603
- description: meta && meta.description ? meta.description : "",
604
- createdAt: new Date().toISOString(),
605
- updatedAt: new Date().toISOString(),
606
- nodes: [],
607
- edges: [],
608
- assumptions: [],
609
- openQuestions: [],
184
+ function parseCompletenessFromArgs(args) {
185
+ const completenessArg = args.find((x) => x.startsWith("--completeness="));
186
+ if (!completenessArg) {
187
+ return {
188
+ completeness: [...DEFAULT_COMPLETENESS],
189
+ fromCliArg: false,
610
190
  };
611
191
  }
612
- return normalized.flows[flowId];
613
- }
614
-
615
- function upsertFlowNode(index, flowId, cacheKey) {
616
- const flow = ensureFlow(index, flowId, {});
617
- if (!flow.nodes.includes(cacheKey)) {
618
- flow.nodes.push(cacheKey);
619
- }
620
- flow.updatedAt = new Date().toISOString();
621
- }
622
-
623
- function addFlowEdge(index, flowId, fromKey, toKey, type, note) {
624
- const flow = ensureFlow(index, flowId, {});
625
- const edge = {
626
- id: `${fromKey}->${toKey}:${type}:${Date.now()}`,
627
- from: fromKey,
628
- to: toKey,
629
- type,
630
- note: note || "",
631
- createdAt: new Date().toISOString(),
192
+ return {
193
+ completeness: normalizeCompletenessList(
194
+ completenessArg.split("=").slice(1).join("=").split(","),
195
+ ),
196
+ fromCliArg: true,
632
197
  };
633
- flow.edges.push(edge);
634
- flow.updatedAt = new Date().toISOString();
635
198
  }
636
199
 
637
200
  /** @returns {string} */
@@ -685,137 +248,112 @@ function safeFileSize(absPath) {
685
248
  }
686
249
  }
687
250
 
688
- function parsePositiveIntOr(input, fallback) {
689
- const n = Number(input);
690
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
691
- }
692
-
693
- function buildBudgetReport(options) {
694
- const index = normalizeIndexShape(readIndex());
695
- const items = index.items || {};
696
- const keys = Object.keys(items);
697
- const filteredKeys = keys.filter((cacheKey) => {
698
- if (options.cacheKey && cacheKey !== options.cacheKey) {
699
- return false;
700
- }
701
- if (options.mcpOnly) {
702
- const item = items[cacheKey];
703
- return item && item.source === "figma-mcp";
251
+ function runUpsertLikeCommand(commandName, args, shouldEnsureFiles) {
252
+ const url = args[0];
253
+ const sourceArg = args.find((x) => x.startsWith("--source="));
254
+ const source = sourceArg ? sourceArg.split("=")[1] : "manual";
255
+ const allowSkeletonWithFigmaMcp = args.includes(
256
+ "--allow-skeleton-with-figma-mcp",
257
+ );
258
+ const { completeness } = parseCompletenessFromArgs(args);
259
+
260
+ const preview = previewUpsertByUrl(url, { source, completeness });
261
+ if (source === "figma-mcp") {
262
+ const mcpErrors = validateMcpRawEvidence(
263
+ preview.normalized.cacheKey,
264
+ preview.item,
265
+ completeness,
266
+ { allowSkeletonWithFigmaMcp },
267
+ {
268
+ fs,
269
+ path,
270
+ resolveMaybeAbsolutePath,
271
+ safeReadJson,
272
+ normalizeSlash,
273
+ normalizeCompletenessList,
274
+ completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
275
+ },
276
+ );
277
+ if (mcpErrors.length) {
278
+ console.error(
279
+ `${commandName} failed: source=figma-mcp but MCP raw evidence is incomplete`,
280
+ );
281
+ mcpErrors.forEach((err) => console.error(`- ${err}`));
282
+ process.exit(2);
704
283
  }
705
- return true;
706
- });
707
-
708
- const entries = filteredKeys.map((cacheKey) => {
709
- const item = items[cacheKey];
710
- const metaAbs = resolveMaybeAbsolutePath(item.paths.meta);
711
- const nodeDir = path.dirname(metaAbs);
712
- const mcpRawDir = path.join(nodeDir, "mcp-raw");
713
- const manifestAbs = path.join(mcpRawDir, "mcp-raw-manifest.json");
714
- const manifest = safeReadJson(manifestAbs);
715
- const filesMap = manifest && manifest.files && typeof manifest.files === "object" ? manifest.files : {};
716
- const fileEntries = Object.entries(filesMap);
717
- const mcpRawBytes = fileEntries.reduce((acc, [, fileName]) => {
718
- const abs = path.join(mcpRawDir, String(fileName));
719
- return acc + safeFileSize(abs);
720
- }, 0);
721
- const mcpRawFilesCount = fileEntries.length;
722
- const toolCalls = manifest && manifest.toolCalls && typeof manifest.toolCalls === "object" ? manifest.toolCalls : {};
723
- const toolCallCount = Object.values(toolCalls).reduce((acc, v) => {
724
- const count = v && typeof v === "object" ? Number(v.count) : 0;
725
- return acc + (Number.isFinite(count) ? count : 0);
726
- }, 0);
727
- const designContextFile =
728
- filesMap && Object.prototype.hasOwnProperty.call(filesMap, "get_design_context")
729
- ? String(filesMap.get_design_context)
730
- : "";
731
- const designContextChars = designContextFile
732
- ? safeFileSize(path.join(mcpRawDir, designContextFile))
733
- : 0;
284
+ }
734
285
 
735
- return {
736
- cacheKey,
737
- source: item.source || "manual",
738
- completeness: Array.isArray(item.completeness) ? item.completeness : [],
739
- hasMcpRawManifest: !!manifest,
740
- mcpRawFilesCount,
741
- mcpRawBytes,
742
- tokenProxyBytes: designContextChars,
743
- tokenProxyChars: designContextChars,
744
- toolCallCount,
745
- toolCalls,
746
- };
747
- });
286
+ const result = upsertByUrl(url, { source, completeness });
287
+ if (shouldEnsureFiles) {
288
+ ensureEntryFilesAndHook(result.normalized.cacheKey, result.item);
289
+ console.log(
290
+ JSON.stringify(
291
+ {
292
+ cacheKey: result.normalized.cacheKey,
293
+ ensured: true,
294
+ paths: result.item.paths,
295
+ },
296
+ null,
297
+ 2,
298
+ ),
299
+ );
300
+ return;
301
+ }
748
302
 
749
- entries.sort((a, b) => b.mcpRawBytes - a.mcpRawBytes);
750
- const limit = parsePositiveIntOr(options.limit, entries.length);
751
- const limitedEntries = entries.slice(0, limit);
752
- const totals = limitedEntries.reduce(
753
- (acc, e) => {
754
- acc.nodes += 1;
755
- acc.nodesWithMcpRaw += e.hasMcpRawManifest ? 1 : 0;
756
- acc.mcpRawBytes += e.mcpRawBytes;
757
- acc.tokenProxyBytes += e.tokenProxyBytes;
758
- acc.toolCallCount += e.toolCallCount;
759
- return acc;
760
- },
761
- {
762
- nodes: 0,
763
- nodesWithMcpRaw: 0,
764
- mcpRawBytes: 0,
765
- tokenProxyBytes: 0,
766
- toolCallCount: 0,
767
- }
303
+ console.log(
304
+ JSON.stringify(
305
+ {
306
+ cacheKey: result.normalized.cacheKey,
307
+ scope: result.item.scope,
308
+ syncedAt: result.item.syncedAt,
309
+ },
310
+ null,
311
+ 2,
312
+ ),
768
313
  );
769
-
770
- totals.tokenProxyChars = totals.tokenProxyBytes;
771
-
772
- return {
773
- generatedAt: new Date().toISOString(),
774
- filters: {
775
- cacheKey: options.cacheKey || null,
776
- mcpOnly: !!options.mcpOnly,
777
- limit,
778
- },
779
- totals,
780
- entries: limitedEntries,
781
- };
782
314
  }
783
-
784
315
  function run() {
785
316
  const [, , cmd, ...args] = process.argv;
786
317
  if (!cmd) {
787
318
  const ex = inferCliExample();
319
+ const defaultCompletenessText = DEFAULT_COMPLETENESS.join(",");
788
320
  console.log("Usage:");
789
- console.log(` (invoke examples: ${ex} | node bin/figma-cache.js | node figma-cache/figma-cache.js)`);
321
+ console.log(
322
+ ` (invoke examples: ${ex} | node bin/figma-cache.js | node figma-cache/figma-cache.js)`,
323
+ );
790
324
  console.log(` ${ex} normalize <figmaUrl>`);
791
325
  console.log(` ${ex} get <figmaUrl>`);
792
- console.log(` ${ex} upsert <figmaUrl> [--source=manual] [--completeness=a,b]`);
326
+ console.log(
327
+ ` ${ex} upsert <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
328
+ );
793
329
  console.log(` ${ex} validate`);
794
330
  console.log(` ${ex} stale [--days=14]`);
795
331
  console.log(` ${ex} backfill`);
796
- console.log(` ${ex} budget [--mcp-only] [--cacheKey=<fileKey#nodeId>] [--limit=50]`);
797
332
  console.log(
798
- ` ${ex} ensure <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp]`
333
+ ` ${ex} budget [--mcp-only] [--cacheKey=<fileKey#nodeId>] [--limit=50]`,
334
+ );
335
+ console.log(
336
+ ` ${ex} ensure <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
799
337
  );
800
338
  console.log(` ${ex} init`);
801
339
  console.log(` ${ex} config`);
802
340
  console.log(
803
- " (optional) figma-cache.config.js | .figmacacherc.js | FIGMA_CACHE_PROJECT_CONFIG hooks.postEnsure after ensure"
341
+ " (optional) figma-cache.config.js | .figmacacherc.js | FIGMA_CACHE_PROJECT_CONFIG 鈥?hooks.postEnsure after ensure",
804
342
  );
805
343
  console.log(` ${ex} flow init --id=<flowId> [--title=...]`);
806
344
  console.log(
807
- `${ex} flow add-node --flow=<flowId> <figmaUrl> [--ensure] [--source=manual] [--completeness=a,b]`
345
+ `${ex} flow add-node --flow=<flowId> <figmaUrl> [--ensure] [--source=manual] [--completeness=a,b]`,
808
346
  );
809
347
  console.log(
810
- `${ex} flow link --flow=<flowId> <fromUrl> <toUrl> --type=next_step [--note=...]`
348
+ `${ex} flow link --flow=<flowId> <fromUrl> <toUrl> --type=next_step [--note=...]`,
811
349
  );
812
350
  console.log(
813
- `${ex} flow chain --flow=<flowId> <url1> <url2> ... [--type=next_step|related]`
351
+ `${ex} flow chain --flow=<flowId> <url1> <url2> ... [--type=next_step|related]`,
814
352
  );
815
353
  console.log(` ${ex} flow show --flow=<flowId>`);
816
354
  console.log(` ${ex} flow mermaid --flow=<flowId>`);
817
355
  console.log(
818
- `${ex} cursor init [--force] # copy .cursor templates + figma-cache.config.example.js + refresh AGENT-SETUP-PROMPT.md + sync colleague-guide-zh.md into FIGMA_CACHE_DIR`
356
+ `${ex} cursor init [--force] # copy .cursor templates + ensure figma-cache.config.js (+ cleanup safe legacy example) + refresh AGENT-SETUP-PROMPT.md + sync colleague-guide-zh.md into FIGMA_CACHE_DIR`,
819
357
  );
820
358
  process.exit(1);
821
359
  }
@@ -827,7 +365,16 @@ function run() {
827
365
  process.exit(1);
828
366
  }
829
367
  const force = args.includes("--force");
830
- copyCursorBootstrap(force);
368
+ copyCursorBootstrap(force, {
369
+ fs,
370
+ path,
371
+ ROOT,
372
+ CACHE_DIR,
373
+ CURSOR_BOOTSTRAP_DIR,
374
+ normalizeSlash,
375
+ readSelfNpmPackageName,
376
+ packageDir: __dirname,
377
+ });
831
378
  return;
832
379
  }
833
380
 
@@ -851,78 +398,33 @@ function run() {
851
398
  item: item || null,
852
399
  },
853
400
  null,
854
- 2
855
- )
401
+ 2,
402
+ ),
856
403
  );
857
404
  return;
858
405
  }
859
406
 
860
407
  if (cmd === "upsert") {
861
- const url = args[0];
862
- const sourceArg = args.find((x) => x.startsWith("--source="));
863
- const completenessArg = args.find((x) => x.startsWith("--completeness="));
864
- const source = sourceArg ? sourceArg.split("=")[1] : "manual";
865
- const completeness = completenessArg
866
- ? completenessArg
867
- .split("=")[1]
868
- .split(",")
869
- .map((x) => x.trim())
870
- .filter(Boolean)
871
- : [];
872
- const result = upsertByUrl(url, { source, completeness });
873
- console.log(
874
- JSON.stringify(
875
- { cacheKey: result.normalized.cacheKey, scope: result.item.scope, syncedAt: result.item.syncedAt },
876
- null,
877
- 2
878
- )
879
- );
408
+ runUpsertLikeCommand("upsert", args, false);
880
409
  return;
881
410
  }
882
411
 
883
412
  if (cmd === "ensure") {
884
- const url = args[0];
885
- const sourceArg = args.find((x) => x.startsWith("--source="));
886
- const completenessArg = args.find((x) => x.startsWith("--completeness="));
887
- const allowSkeletonWithFigmaMcp = args.includes("--allow-skeleton-with-figma-mcp");
888
- const source = sourceArg ? sourceArg.split("=")[1] : "manual";
889
- const completeness = completenessArg
890
- ? completenessArg
891
- .split("=")[1]
892
- .split(",")
893
- .map((x) => x.trim())
894
- .filter(Boolean)
895
- : [];
896
- if (source === "figma-mcp" && !allowSkeletonWithFigmaMcp) {
897
- console.error(
898
- [
899
- "ensure 检测到 --source=figma-mcp,但当前 CLI 不会主动发起 MCP 请求。",
900
- "为避免“假成功”,已阻止本次写入。",
901
- "请先由 Agent/Figma MCP 拉取并写入 mcp-raw,再执行 upsert/ensure;",
902
- "若你只想生成本地骨架,请显式追加 --allow-skeleton-with-figma-mcp。",
903
- ].join("\n")
904
- );
905
- process.exit(2);
906
- }
907
- const result = upsertByUrl(url, { source, completeness });
908
- ensureEntryFilesAndHook(result.normalized.cacheKey, result.item);
909
- console.log(
910
- JSON.stringify(
911
- {
912
- cacheKey: result.normalized.cacheKey,
913
- ensured: true,
914
- paths: result.item.paths,
915
- },
916
- null,
917
- 2
918
- )
919
- );
413
+ runUpsertLikeCommand("ensure", args, true);
920
414
  return;
921
415
  }
922
-
923
416
  if (cmd === "validate") {
924
417
  const index = readIndex();
925
- const errors = validateIndex(index);
418
+ const errors = validateIndex(index, {
419
+ fs,
420
+ path,
421
+ normalizeIndexShape,
422
+ normalizeCompletenessList,
423
+ resolveMaybeAbsolutePath,
424
+ safeReadJson,
425
+ normalizeSlash,
426
+ completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
427
+ });
926
428
  if (!errors.length) {
927
429
  console.log("Validation passed.");
928
430
  return;
@@ -940,7 +442,14 @@ function run() {
940
442
  }
941
443
 
942
444
  if (cmd === "backfill") {
943
- backfillFromIterations();
445
+ backfillFromIterations(
446
+ { iterationsDir: ITERATIONS_DIR },
447
+ {
448
+ fs,
449
+ path,
450
+ upsertByUrl,
451
+ },
452
+ );
944
453
  return;
945
454
  }
946
455
 
@@ -948,9 +457,21 @@ function run() {
948
457
  const mcpOnly = args.includes("--mcp-only");
949
458
  const cacheKeyArg = args.find((x) => x.startsWith("--cacheKey="));
950
459
  const limitArg = args.find((x) => x.startsWith("--limit="));
951
- const cacheKey = cacheKeyArg ? cacheKeyArg.split("=").slice(1).join("=") : "";
460
+ const cacheKey = cacheKeyArg
461
+ ? cacheKeyArg.split("=").slice(1).join("=")
462
+ : "";
952
463
  const limit = limitArg ? limitArg.split("=")[1] : "";
953
- const report = buildBudgetReport({ mcpOnly, cacheKey, limit });
464
+ const report = buildBudgetReport(
465
+ { mcpOnly, cacheKey, limit },
466
+ {
467
+ path,
468
+ normalizeIndexShape,
469
+ readIndex,
470
+ resolveMaybeAbsolutePath,
471
+ safeReadJson,
472
+ safeFileSize,
473
+ },
474
+ );
954
475
  console.log(JSON.stringify(report, null, 2));
955
476
  return;
956
477
  }
@@ -967,17 +488,16 @@ function run() {
967
488
  iterationsDir: normalizeSlash(ITERATIONS_DIR),
968
489
  staleDays: DEFAULT_STALE_DAYS,
969
490
  defaultFlowId: DEFAULT_FLOW_ID || null,
491
+ defaultCompleteness: [...DEFAULT_COMPLETENESS],
970
492
  normalizationVersion: NORMALIZATION_VERSION,
971
- projectConfigPath: memoProjectConfigPath
972
- ? normalizeSlash(memoProjectConfigPath)
973
- : null,
493
+ projectConfigPath: getProjectConfigPath(),
974
494
  hooks: {
975
495
  postEnsure: !!(hooks && typeof hooks.postEnsure === "function"),
976
496
  },
977
497
  },
978
498
  null,
979
- 2
980
- )
499
+ 2,
500
+ ),
981
501
  );
982
502
  return;
983
503
  }
@@ -993,8 +513,8 @@ function run() {
993
513
  indexPath: normalizeSlash(INDEX_PATH),
994
514
  },
995
515
  null,
996
- 2
997
- )
516
+ 2,
517
+ ),
998
518
  );
999
519
  return;
1000
520
  }
@@ -1006,176 +526,25 @@ function run() {
1006
526
  indexPath: normalizeSlash(INDEX_PATH),
1007
527
  },
1008
528
  null,
1009
- 2
1010
- )
529
+ 2,
530
+ ),
1011
531
  );
1012
532
  return;
1013
533
  }
1014
534
 
1015
535
  if (cmd === "flow") {
1016
- const sub = args[0];
1017
- const rest = args.slice(1);
1018
- if (!sub) {
1019
- console.error("Missing flow subcommand");
1020
- process.exit(1);
1021
- }
1022
-
1023
- if (sub === "init") {
1024
- const idArg = rest.find((x) => x.startsWith("--id="));
1025
- const titleArg = rest.find((x) => x.startsWith("--title="));
1026
- const descArg = rest.find((x) => x.startsWith("--description="));
1027
- const flowId = idArg ? idArg.split("=")[1] : slugifyFlowId("flow");
1028
- const title = titleArg ? titleArg.split("=").slice(1).join("=") : flowId;
1029
- const description = descArg ? descArg.split("=").slice(1).join("=") : "";
1030
- const index = normalizeIndexShape(readIndex());
1031
- ensureFlow(index, flowId, { title, description });
1032
- writeIndex(index);
1033
- console.log(JSON.stringify({ flowId, created: true }, null, 2));
1034
- return;
1035
- }
1036
-
1037
- if (sub === "add-node") {
1038
- const flowId = resolveFlowIdFromArgs(rest);
1039
- if (!flowId) {
1040
- console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
1041
- process.exit(1);
1042
- }
1043
- const url = rest.find((x) => !x.startsWith("--"));
1044
- const ensureArg = rest.includes("--ensure");
1045
- const sourceArg = rest.find((x) => x.startsWith("--source="));
1046
- const completenessArg = rest.find((x) => x.startsWith("--completeness="));
1047
- const source = sourceArg ? sourceArg.split("=")[1] : "manual";
1048
- const completeness = completenessArg
1049
- ? completenessArg
1050
- .split("=")[1]
1051
- .split(",")
1052
- .map((x) => x.trim())
1053
- .filter(Boolean)
1054
- : [];
1055
- const index = normalizeIndexShape(readIndex());
1056
- const normalized = normalizeFigmaUrl(url);
1057
- if (!ensureArg && !getItem(index, normalized.cacheKey)) {
1058
- console.error(
1059
- `Missing cache item for ${normalized.cacheKey}. Run figma:cache:ensure first, or pass --ensure.`
1060
- );
1061
- process.exit(2);
1062
- }
1063
- if (ensureArg) {
1064
- upsertByUrl(url, { source, completeness });
1065
- const refreshed = normalizeIndexShape(readIndex());
1066
- const item = getItem(refreshed, normalized.cacheKey);
1067
- if (item) {
1068
- ensureEntryFilesAndHook(normalized.cacheKey, item);
1069
- }
1070
- Object.assign(index, refreshed);
1071
- }
1072
- upsertFlowNode(index, flowId, normalized.cacheKey);
1073
- writeIndex(index);
1074
- console.log(
1075
- JSON.stringify(
1076
- { flowId, cacheKey: normalized.cacheKey, added: true, ensured: ensureArg },
1077
- null,
1078
- 2
1079
- )
1080
- );
1081
- return;
1082
- }
1083
-
1084
- if (sub === "link") {
1085
- const flowId = resolveFlowIdFromArgs(rest);
1086
- const typeArg = rest.find((x) => x.startsWith("--type="));
1087
- const noteArg = rest.find((x) => x.startsWith("--note="));
1088
- const urls = rest.filter((x) => !x.startsWith("--"));
1089
- if (!flowId) {
1090
- console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
1091
- process.exit(1);
1092
- }
1093
- if (urls.length < 2) {
1094
- console.error("Missing <fromUrl> <toUrl>");
1095
- process.exit(1);
1096
- }
1097
- const type = typeArg ? typeArg.split("=")[1] : "related";
1098
- const note = noteArg ? noteArg.split("=").slice(1).join("=") : "";
1099
- const from = normalizeFigmaUrl(urls[0]).cacheKey;
1100
- const to = normalizeFigmaUrl(urls[1]).cacheKey;
1101
- const index = normalizeIndexShape(readIndex());
1102
- if (!getItem(index, from) || !getItem(index, to)) {
1103
- console.error("Missing cache item for from/to. Cache urls first with ensure/upsert.");
1104
- process.exit(2);
1105
- }
1106
- upsertFlowNode(index, flowId, from);
1107
- upsertFlowNode(index, flowId, to);
1108
- addFlowEdge(index, flowId, from, to, type, note);
1109
- writeIndex(index);
1110
- console.log(JSON.stringify({ flowId, from, to, type, linked: true }, null, 2));
1111
- return;
1112
- }
1113
-
1114
- if (sub === "chain") {
1115
- const flowId = resolveFlowIdFromArgs(rest);
1116
- const typeArg = rest.find((x) => x.startsWith("--type="));
1117
- const type = typeArg ? typeArg.split("=")[1] : "related";
1118
- const urls = rest.filter((x) => !x.startsWith("--"));
1119
- if (!flowId) {
1120
- console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
1121
- process.exit(1);
1122
- }
1123
- if (urls.length < 2) {
1124
- console.error("Need at least 2 urls");
1125
- process.exit(1);
1126
- }
1127
- const index = normalizeIndexShape(readIndex());
1128
- const keys = urls.map((u) => normalizeFigmaUrl(u).cacheKey);
1129
- keys.forEach((k) => {
1130
- if (!getItem(index, k)) {
1131
- console.error(`Missing cache item for ${k}. Ensure each url is cached first.`);
1132
- process.exit(2);
1133
- }
1134
- });
1135
- keys.forEach((k) => upsertFlowNode(index, flowId, k));
1136
- for (let i = 0; i < keys.length - 1; i += 1) {
1137
- addFlowEdge(index, flowId, keys[i], keys[i + 1], type, "");
1138
- }
1139
- writeIndex(index);
1140
- console.log(JSON.stringify({ flowId, chained: keys.length - 1, type }, null, 2));
1141
- return;
1142
- }
1143
-
1144
- if (sub === "show") {
1145
- const flowId = resolveFlowIdFromArgs(rest);
1146
- if (!flowId) {
1147
- console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
1148
- process.exit(1);
1149
- }
1150
- const index = normalizeIndexShape(readIndex());
1151
- const flow = index.flows[flowId];
1152
- console.log(JSON.stringify({ flowId, flow: flow || null }, null, 2));
1153
- return;
1154
- }
1155
-
1156
- if (sub === "mermaid") {
1157
- const flowId = resolveFlowIdFromArgs(rest);
1158
- if (!flowId) {
1159
- console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
1160
- process.exit(1);
1161
- }
1162
- const index = normalizeIndexShape(readIndex());
1163
- const flow = index.flows[flowId];
1164
- if (!flow) {
1165
- console.error(`Unknown flow: ${flowId}`);
1166
- process.exit(1);
1167
- }
1168
- const lines = ["flowchart LR"];
1169
- (flow.edges || []).forEach((edge) => {
1170
- const label = edge.type || "edge";
1171
- lines.push(` ${edge.from} -->|${label}| ${edge.to}`);
1172
- });
1173
- console.log(lines.join("\n"));
1174
- return;
1175
- }
1176
-
1177
- console.error(`Unknown flow subcommand: ${sub}`);
1178
- process.exit(1);
536
+ handleFlowCommand(args, {
537
+ resolveFlowIdFromArgs,
538
+ parseCompletenessFromArgs,
539
+ normalizeIndexShape,
540
+ readIndex,
541
+ writeIndex,
542
+ normalizeFigmaUrl,
543
+ getItem,
544
+ upsertByUrl,
545
+ ensureEntryFilesAndHook,
546
+ });
547
+ return;
1179
548
  }
1180
549
 
1181
550
  console.error(`Unknown command: ${cmd}`);