paintress 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { a as attachPersistent, c as lockPath, d as safeJoin, f as sanitizeProjectName, h as encodeLine, i as TOOLS, l as pidPath, m as LineDecoder, n as runStdioServer, o as isDaemonRunning, p as socketPath, s as sendRequest, u as resolveProjectDir } from "./server-ssrAxbTR.js";
3
- import { T as loadDoc, qt as VERSION, t as renderDocument } from "./render-CCxYNL6A.js";
2
+ import { A as presetPathFor, C as ensureProjectScaffold, D as listPresetGroups, E as listComponents, F as setProjectRoots, M as projectFontRegistry, N as readTextIfExists, O as listPresets, P as setPreset, S as componentsPathFor, T as findProjectRoot, _ as LineDecoder, a as ensureDaemon, b as screenshotPen, c as isDaemonRunning, d as lockPath, f as pidPath, g as socketPath, h as sanitizeProjectName, j as projectFilesInfo, k as notePathFor, l as queryDaemon, m as safeJoin, n as runStdioServer, o as reuseDaemon, p as resolveProjectDir, r as TOOLS, s as attachPersistent, u as sendRequest, v as encodeLine, w as findNodeIn, x as applyProjectPresets, y as findBrowser } from "./server-DBRmwE_v.js";
3
+ import { P as planFonts, T as loadDoc, rn as VERSION, t as renderDocument, z as isPresetType } from "./render-DWs1iQ0v.js";
4
4
  import "./index.js";
5
5
  import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
- import { homedir } from "node:os";
6
+ import { homedir, tmpdir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { dirname, extname, join, relative, resolve, sep } from "node:path";
9
+ import { randomUUID } from "node:crypto";
9
10
  import { createServer } from "node:net";
10
11
  import { createServer as createServer$1 } from "node:http";
11
12
  import { readFile } from "node:fs/promises";
@@ -92,7 +93,9 @@ var Registry = class {
92
93
  if (st.isDirectory()) {
93
94
  if (skip.has(name)) continue;
94
95
  walk(abs, relPath, depth + 1);
95
- } else if (DESIGN_EXT.has(extname(name).toLowerCase())) out.push(relPath);
96
+ } else if (DESIGN_EXT.has(extname(name).toLowerCase())) {
97
+ if (!name.startsWith("__")) out.push(relPath);
98
+ }
96
99
  }
97
100
  };
98
101
  walk(entry.rootReal, "", 0);
@@ -119,18 +122,24 @@ var Registry = class {
119
122
  var MarksStore = class {
120
123
  byProject = /* @__PURE__ */ new Map();
121
124
  seq = 0;
122
- /** 同一项目内同一 nodeId 复用既有短号;否则分配新号 */
125
+ /** 短号按「文件 + nodeId」去重:节点 id 只在文件内唯一,跨文件同 id 不能复用 */
126
+ key(project, nodeId, file) {
127
+ return `${project}\u0000${file ?? ""}\u0000${nodeId}`;
128
+ }
129
+ /** 同一项目内同一文件同一 nodeId 复用既有短号;否则分配新号 */
123
130
  add(input) {
124
131
  let bucket = this.byProject.get(input.project);
125
132
  if (!bucket) {
126
133
  bucket = /* @__PURE__ */ new Map();
127
134
  this.byProject.set(input.project, bucket);
128
135
  }
129
- const existing = bucket.get(input.nodeId);
136
+ const key = this.key(input.project, input.nodeId, input.file);
137
+ const existing = bucket.get(key);
130
138
  if (existing) {
131
139
  if (input.name !== void 0) existing.name = input.name;
132
140
  if (input.type !== void 0) existing.type = input.type;
133
141
  if (input.path !== void 0) existing.path = input.path;
142
+ if (input.file !== void 0) existing.file = input.file;
134
143
  existing.at = Date.now();
135
144
  return existing;
136
145
  }
@@ -138,21 +147,22 @@ var MarksStore = class {
138
147
  const mark = {
139
148
  shortId: this.seq,
140
149
  project: input.project,
150
+ ...input.file !== void 0 ? { file: input.file } : {},
141
151
  nodeId: input.nodeId,
142
152
  name: input.name,
143
153
  type: input.type,
144
154
  path: input.path ?? input.nodeId,
145
155
  at: Date.now()
146
156
  };
147
- bucket.set(input.nodeId, mark);
157
+ bucket.set(key, mark);
148
158
  return mark;
149
159
  }
150
160
  list(project) {
151
161
  const bucket = this.byProject.get(project);
152
162
  return bucket ? [...bucket.values()].sort((a, b) => a.shortId - b.shortId) : [];
153
163
  }
154
- get(project, nodeId) {
155
- return this.byProject.get(project)?.get(nodeId);
164
+ get(project, nodeId, file) {
165
+ return this.byProject.get(project)?.get(this.key(project, nodeId, file));
156
166
  }
157
167
  clear(project) {
158
168
  const bucket = this.byProject.get(project);
@@ -161,8 +171,16 @@ var MarksStore = class {
161
171
  this.byProject.delete(project);
162
172
  return n;
163
173
  }
164
- remove(project, nodeId) {
165
- return this.byProject.get(project)?.delete(nodeId) ?? false;
174
+ remove(project, nodeId, file) {
175
+ const bucket = this.byProject.get(project);
176
+ if (bucket === void 0) return false;
177
+ if (file !== void 0) return bucket.delete(this.key(project, nodeId, file));
178
+ let removed = false;
179
+ for (const [key, mark] of bucket) if (mark.nodeId === nodeId) {
180
+ bucket.delete(key);
181
+ removed = true;
182
+ }
183
+ return removed;
166
184
  }
167
185
  };
168
186
  // src/web/changes.ts
@@ -272,6 +290,9 @@ var WatchManager = class {
272
290
  watcher.on("change", () => {
273
291
  for (const cb of created.listeners) cb(absPath);
274
292
  });
293
+ watcher.on("add", () => {
294
+ for (const cb of created.listeners) cb(absPath);
295
+ });
275
296
  watcher.on("unlink", () => {
276
297
  for (const cb of created.listeners) cb(absPath);
277
298
  });
@@ -418,6 +439,10 @@ const MIME = {
418
439
  function mimeOf(filePath) {
419
440
  return MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream";
420
441
  }
442
+ /** 项目内资源 → /files URL(项目名可能带斜杠,按段编码) */
443
+ function filesUrl(project, relPath) {
444
+ return `/files/${project.split("/").map(encodeURIComponent).join("/")}/${relPath.split("/").filter((seg) => seg !== "").map(encodeURIComponent).join("/")}`;
445
+ }
421
446
  /** 定位前端产物目录:源码运行 / 构建后 / cwd 三种候选,找不到则回退精简视图 */
422
447
  function findUiDist() {
423
448
  const here = dirname(fileURLToPath(import.meta.url));
@@ -476,7 +501,7 @@ function acquireLock() {
476
501
  } catch {
477
502
  holder = 0;
478
503
  }
479
- if (isAlive(holder)) throw new Error(`paintress web 已在运行(pid ${holder})。如需重启请先 paintress web --stop`);
504
+ if (isAlive(holder)) throw new Error(`paintress web 已在运行(pid ${holder})。如需重启请先 paintress stop`);
480
505
  try {
481
506
  unlinkSync(p);
482
507
  } catch {}
@@ -522,10 +547,50 @@ function esc(s) {
522
547
  function isTrustedWrite(req) {
523
548
  return req.headers["x-paintress"] === "1";
524
549
  }
550
+ /**
551
+ * 属性面板用的变量表:项目预设打底、文档本地同名优先。
552
+ * themed 列表取第一个可显示的值(面板只做展示,不替人挑主题)。
553
+ */
554
+ function panelVars(fileAbs) {
555
+ const out = {};
556
+ const collect = (doc) => {
557
+ for (const [key, def] of Object.entries(doc?.variables ?? {})) {
558
+ const raw = def["value"];
559
+ const first = Array.isArray(raw) ? raw.map((e) => e !== null && typeof e === "object" ? e["value"] : void 0).find((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean") : raw;
560
+ if (typeof first === "string" || typeof first === "number" || typeof first === "boolean") out[key] = String(first);
561
+ }
562
+ };
563
+ try {
564
+ const p = presetPathFor(fileAbs);
565
+ if (p !== null) collect(loadDoc(p).doc);
566
+ collect(loadDoc(fileAbs).doc);
567
+ } catch {}
568
+ return out;
569
+ }
570
+ var FontReportStore = class {
571
+ byFile = /* @__PURE__ */ new Map();
572
+ set(project, path, report) {
573
+ this.byFile.set(`${project}\u0000${path}`, {
574
+ ...report,
575
+ project,
576
+ path,
577
+ at: Date.now()
578
+ });
579
+ }
580
+ get(project, path) {
581
+ return this.byFile.get(`${project}\u0000${path}`);
582
+ }
583
+ list(project) {
584
+ return [...this.byFile.values()].filter((r) => r.project === project);
585
+ }
586
+ };
525
587
  async function startDaemon(opts = {}) {
526
588
  const releaseLock = acquireLock();
527
589
  const registry = new Registry();
528
590
  const marks = new MarksStore();
591
+ /** daemon 自己也是 doc 层的一个调用方:注册根就是它的项目表 */
592
+ const syncProjectRoots = () => setProjectRoots(registry.all().map((p) => p.root));
593
+ const fontReports = new FontReportStore();
529
594
  const changes = new ChangesStore();
530
595
  const watchers = new WatchManager();
531
596
  const versions = new VersionChecker();
@@ -539,6 +604,8 @@ async function startDaemon(opts = {}) {
539
604
  const idleCheckMs = opts.idleCheckMs ?? 2e3;
540
605
  let idleSince = null;
541
606
  let closing = false;
607
+ /** 进行中的关停 Promise:close() 必须等它,不能立刻返回 */
608
+ let closingPromise = null;
542
609
  /** 最近一次 HTTP 活动:用来判定「浏览器还在不在」而不单靠 SSE 关闭事件 */
543
610
  let lastHttpAt = Date.now();
544
611
  /**
@@ -662,6 +729,7 @@ async function startDaemon(opts = {}) {
662
729
  ok: true,
663
730
  pong: true,
664
731
  pid: process.pid,
732
+ port,
665
733
  uptimeMs: Date.now() - startedAt,
666
734
  projects: registry.all().length
667
735
  });
@@ -675,11 +743,24 @@ async function startDaemon(opts = {}) {
675
743
  version: VERSION
676
744
  });
677
745
  break;
746
+ case "fonts":
747
+ reply({
748
+ ok: true,
749
+ reports: fontReports.list(req.project)
750
+ });
751
+ break;
678
752
  case "watch": {
679
753
  everHadProjects = true;
680
754
  const entry = registry.watch(req.project, req.dir, req.depth, req.owner === "agent" ? { owner: "agent" } : {});
681
755
  cancelAutoDetach(entry.name);
682
756
  watchers.watchDesigns(entry.rootReal, entry.depth, scheduleRegistryNotify);
757
+ try {
758
+ const created = ensureProjectScaffold(entry.rootReal).created;
759
+ if (created.length > 0 && !opts.quiet) process.stderr.write(`paintress web: 项目 ${entry.name} 初始化 ${created.map((f) => relative(entry.rootReal, f)).join("、")}\n`);
760
+ } catch (cause) {
761
+ process.stderr.write(`paintress web: 项目脚手架创建失败(${cause instanceof Error ? cause.message : String(cause)})\n`);
762
+ }
763
+ syncProjectRoots();
683
764
  reply({
684
765
  ok: true,
685
766
  watched: true,
@@ -691,6 +772,7 @@ async function startDaemon(opts = {}) {
691
772
  case "unwatch": {
692
773
  const before = registry.get(req.project);
693
774
  const removed = before === void 0 ? false : detachProject(before, "unwatch");
775
+ syncProjectRoots();
694
776
  reply({
695
777
  ok: true,
696
778
  unwatched: removed
@@ -884,6 +966,61 @@ async function startDaemon(opts = {}) {
884
966
  });
885
967
  return;
886
968
  }
969
+ if (segments[1] === "fonts" && segments.length === 2) {
970
+ const qProject = url.searchParams.get("project") ?? "";
971
+ const qFile = url.searchParams.get("path") ?? "";
972
+ if (req.method === "POST") {
973
+ const raw = await readBody();
974
+ let body;
975
+ try {
976
+ body = JSON.parse(raw);
977
+ } catch {
978
+ sendJson(400, {
979
+ ok: false,
980
+ error: "请求体不是合法 JSON"
981
+ });
982
+ return;
983
+ }
984
+ const project = typeof body["project"] === "string" ? body["project"] : qProject;
985
+ const path = typeof body["path"] === "string" ? body["path"] : qFile;
986
+ if (project === "" || path === "") {
987
+ sendJson(400, {
988
+ ok: false,
989
+ error: "缺少 project / path"
990
+ });
991
+ return;
992
+ }
993
+ if (!registry.get(project)) {
994
+ sendJson(404, {
995
+ ok: false,
996
+ error: `未注册的项目:${project}`
997
+ });
998
+ return;
999
+ }
1000
+ const checked = Array.isArray(body["checked"]) ? body["checked"].map(String) : [];
1001
+ const missing = Array.isArray(body["missing"]) ? body["missing"].map(String) : [];
1002
+ fontReports.set(project, path, {
1003
+ checked,
1004
+ missing,
1005
+ substituted: [],
1006
+ source: "viewer"
1007
+ });
1008
+ sendJson(200, { ok: true });
1009
+ return;
1010
+ }
1011
+ if (req.method !== "GET") {
1012
+ sendJson(405, {
1013
+ ok: false,
1014
+ error: "不支持的方法"
1015
+ });
1016
+ return;
1017
+ }
1018
+ sendJson(200, {
1019
+ ok: true,
1020
+ reports: fontReports.list(qProject)
1021
+ });
1022
+ return;
1023
+ }
887
1024
  if (segments[1] === "changes") {
888
1025
  const qProject = url.searchParams.get("project") ?? "";
889
1026
  const qFile = url.searchParams.get("path") ?? "";
@@ -895,6 +1032,132 @@ async function startDaemon(opts = {}) {
895
1032
  });
896
1033
  return;
897
1034
  }
1035
+ if (segments[1] === "export" && req.method === "GET") {
1036
+ const qProject = url.searchParams.get("project") ?? "";
1037
+ const qFile = url.searchParams.get("path") ?? "";
1038
+ const qBoard = url.searchParams.get("board") ?? "";
1039
+ const qScale = Number(url.searchParams.get("scale") ?? "1");
1040
+ if (qProject === "" || qFile === "" || qBoard === "") {
1041
+ sendJson(400, {
1042
+ ok: false,
1043
+ error: "缺少 project / path / board"
1044
+ });
1045
+ return;
1046
+ }
1047
+ if (qScale !== .5 && qScale !== 1 && qScale !== 2) {
1048
+ sendJson(400, {
1049
+ ok: false,
1050
+ error: "scale 只支持 0.5 / 1 / 2"
1051
+ });
1052
+ return;
1053
+ }
1054
+ const entry = registry.get(qProject);
1055
+ if (!entry) {
1056
+ sendJson(404, {
1057
+ ok: false,
1058
+ error: `未注册的项目:${qProject}`
1059
+ });
1060
+ return;
1061
+ }
1062
+ const abs = safeJoin(entry.rootReal, qFile);
1063
+ if (!abs) {
1064
+ sendJson(400, {
1065
+ ok: false,
1066
+ error: `路径不在项目内:${qFile}`
1067
+ });
1068
+ return;
1069
+ }
1070
+ if (findBrowser() === null) {
1071
+ sendJson(501, {
1072
+ ok: false,
1073
+ error: "本机没有 Chromium/Chrome,无法导出 PNG(可用 PTR_BROWSER 指定)"
1074
+ });
1075
+ return;
1076
+ }
1077
+ const tmpPng = join(tmpdir(), `paintress-export-${randomUUID()}.png`);
1078
+ try {
1079
+ const shot = await screenshotPen(abs, tmpPng, {
1080
+ board: qBoard,
1081
+ scale: qScale
1082
+ });
1083
+ const buf = await readFile(tmpPng);
1084
+ const raw = `${(qFile.split("/").pop() ?? qFile).replace(/\.[^.]+$/, "")}-${shot.board?.name !== void 0 && shot.board.name !== "" ? shot.board.name : shot.board?.id ?? qBoard}@${qScale}x.png`;
1085
+ const ascii = raw.replace(/[^A-Za-z0-9._@-]+/g, "_");
1086
+ res.writeHead(200, {
1087
+ "content-type": "image/png",
1088
+ "content-disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(raw)}`,
1089
+ "cache-control": "no-store",
1090
+ "x-paintress-scale": String(shot.scale)
1091
+ });
1092
+ res.end(buf);
1093
+ return;
1094
+ } catch (cause) {
1095
+ sendJson(400, {
1096
+ ok: false,
1097
+ error: cause instanceof Error ? cause.message : String(cause)
1098
+ });
1099
+ return;
1100
+ } finally {
1101
+ try {
1102
+ unlinkSync(tmpPng);
1103
+ } catch {}
1104
+ }
1105
+ }
1106
+ if (segments[1] === "node" && req.method === "GET") {
1107
+ const qProject = url.searchParams.get("project") ?? "";
1108
+ const qFile = url.searchParams.get("path") ?? "";
1109
+ const qId = url.searchParams.get("id") ?? "";
1110
+ if (qProject === "" || qFile === "" || qId === "") {
1111
+ sendJson(400, {
1112
+ ok: false,
1113
+ error: "缺少 project / path / id"
1114
+ });
1115
+ return;
1116
+ }
1117
+ const entry = registry.get(qProject);
1118
+ if (!entry) {
1119
+ sendJson(404, {
1120
+ ok: false,
1121
+ error: `未注册的项目:${qProject}`
1122
+ });
1123
+ return;
1124
+ }
1125
+ const abs = safeJoin(entry.rootReal, qFile);
1126
+ if (!abs) {
1127
+ sendJson(400, {
1128
+ ok: false,
1129
+ error: `路径不在项目内:${qFile}`
1130
+ });
1131
+ return;
1132
+ }
1133
+ let doc;
1134
+ try {
1135
+ doc = loadDoc(abs).doc;
1136
+ } catch (cause) {
1137
+ sendJson(404, {
1138
+ ok: false,
1139
+ error: cause instanceof Error ? cause.message : String(cause)
1140
+ });
1141
+ return;
1142
+ }
1143
+ const node = findNodeIn(doc.children, qId);
1144
+ if (node === null) {
1145
+ sendJson(404, {
1146
+ ok: false,
1147
+ error: `节点不存在:${qId}`
1148
+ });
1149
+ return;
1150
+ }
1151
+ entry.lastAccess = Date.now();
1152
+ const { children, ...fields } = node;
1153
+ sendJson(200, {
1154
+ ok: true,
1155
+ node: fields,
1156
+ childrenCount: Array.isArray(children) ? children.length : 0,
1157
+ vars: panelVars(abs)
1158
+ });
1159
+ return;
1160
+ }
898
1161
  if (segments[1] === "marks") {
899
1162
  const qProject = url.searchParams.get("project") ?? "";
900
1163
  if (req.method === "GET") {
@@ -937,6 +1200,7 @@ async function startDaemon(opts = {}) {
937
1200
  mark: marks.add({
938
1201
  project: projectIn,
939
1202
  nodeId,
1203
+ ...typeof body["file"] === "string" && body["file"] !== "" ? { file: body["file"] } : {},
940
1204
  ...typeof body["name"] === "string" ? { name: body["name"] } : {},
941
1205
  ...typeof body["type"] === "string" ? { type: body["type"] } : {},
942
1206
  ...typeof body["path"] === "string" ? { path: body["path"] } : {}
@@ -946,9 +1210,10 @@ async function startDaemon(opts = {}) {
946
1210
  }
947
1211
  if (req.method === "DELETE") {
948
1212
  const nodeId = url.searchParams.get("nodeId");
1213
+ const file = url.searchParams.get("file") ?? void 0;
949
1214
  sendJson(200, {
950
1215
  ok: true,
951
- cleared: nodeId ? marks.remove(qProject, nodeId) ? 1 : 0 : marks.clear(qProject)
1216
+ cleared: nodeId ? marks.remove(qProject, nodeId, file) ? 1 : 0 : marks.clear(qProject)
952
1217
  });
953
1218
  return;
954
1219
  }
@@ -958,6 +1223,192 @@ async function startDaemon(opts = {}) {
958
1223
  });
959
1224
  return;
960
1225
  }
1226
+ if (segments[1] === "notes" && req.method === "GET") {
1227
+ const name = url.searchParams.get("project") ?? "";
1228
+ const relPath = url.searchParams.get("path") ?? "";
1229
+ const entry = registry.get(name);
1230
+ if (!entry) {
1231
+ sendJson(404, {
1232
+ ok: false,
1233
+ error: `未注册的项目:${name}`
1234
+ });
1235
+ return;
1236
+ }
1237
+ const abs = safeJoin(entry.rootReal, relPath);
1238
+ const noteAbs = abs === null ? null : notePathFor(abs);
1239
+ if (abs === null || noteAbs === null) {
1240
+ sendJson(404, {
1241
+ ok: false,
1242
+ error: "文件不存在"
1243
+ });
1244
+ return;
1245
+ }
1246
+ let markdown;
1247
+ try {
1248
+ markdown = readTextIfExists(noteAbs);
1249
+ } catch (cause) {
1250
+ sendJson(413, {
1251
+ ok: false,
1252
+ error: cause instanceof Error ? cause.message : String(cause)
1253
+ });
1254
+ return;
1255
+ }
1256
+ if (markdown === null) {
1257
+ sendJson(200, {
1258
+ ok: true,
1259
+ exists: false
1260
+ });
1261
+ return;
1262
+ }
1263
+ sendJson(200, {
1264
+ ok: true,
1265
+ exists: true,
1266
+ path: relative(entry.rootReal, noteAbs).split(sep).join("/"),
1267
+ markdown
1268
+ });
1269
+ return;
1270
+ }
1271
+ if (segments[1] === "presets") {
1272
+ const qProject = url.searchParams.get("project") ?? "";
1273
+ if (req.method === "GET") {
1274
+ const entry = registry.get(qProject);
1275
+ if (!entry) {
1276
+ sendJson(404, {
1277
+ ok: false,
1278
+ error: `未注册的项目:${qProject}`
1279
+ });
1280
+ return;
1281
+ }
1282
+ const { path, items } = listPresets(entry.rootReal);
1283
+ sendJson(200, {
1284
+ ok: true,
1285
+ path,
1286
+ items,
1287
+ groups: listPresetGroups(entry.rootReal).groups
1288
+ });
1289
+ return;
1290
+ }
1291
+ if (req.method === "PUT" || req.method === "POST") {
1292
+ if (!isTrustedWrite(req)) {
1293
+ sendJson(403, {
1294
+ ok: false,
1295
+ error: "缺少 x-paintress 头"
1296
+ });
1297
+ return;
1298
+ }
1299
+ const raw = await readBody();
1300
+ let body;
1301
+ try {
1302
+ body = JSON.parse(raw);
1303
+ } catch {
1304
+ sendJson(400, {
1305
+ ok: false,
1306
+ error: "请求体不是合法 JSON"
1307
+ });
1308
+ return;
1309
+ }
1310
+ const name = qProject !== "" ? qProject : typeof body["project"] === "string" ? body["project"] : "";
1311
+ const entry = registry.get(name);
1312
+ if (!entry) {
1313
+ sendJson(404, {
1314
+ ok: false,
1315
+ error: `未注册的项目:${name}`
1316
+ });
1317
+ return;
1318
+ }
1319
+ const key = typeof body["key"] === "string" ? body["key"] : "";
1320
+ const value = body["value"];
1321
+ if (key === "" || typeof value !== "string" && typeof value !== "number") {
1322
+ sendJson(400, {
1323
+ ok: false,
1324
+ error: "缺少 key 或 value"
1325
+ });
1326
+ return;
1327
+ }
1328
+ const existing = listPresets(entry.rootReal).items.find((p) => p.key === key);
1329
+ const typeRaw = body["type"] ?? existing?.type;
1330
+ if (!isPresetType(typeRaw)) {
1331
+ sendJson(400, {
1332
+ ok: false,
1333
+ error: "type 必须是 color / radius / spacing / fontSize / font"
1334
+ });
1335
+ return;
1336
+ }
1337
+ const label = typeof body["name"] === "string" && body["name"] !== "" ? body["name"] : void 0;
1338
+ try {
1339
+ const items = setPreset(entry.rootReal, {
1340
+ key,
1341
+ type: typeRaw,
1342
+ value: String(value),
1343
+ ...label !== void 0 ? { name: label } : {}
1344
+ });
1345
+ notifyRegistry();
1346
+ sendJson(200, {
1347
+ ok: true,
1348
+ path: presetPathFor(entry.rootReal),
1349
+ items,
1350
+ groups: listPresetGroups(entry.rootReal).groups
1351
+ });
1352
+ } catch (cause) {
1353
+ sendJson(400, {
1354
+ ok: false,
1355
+ error: cause instanceof Error ? cause.message : String(cause)
1356
+ });
1357
+ }
1358
+ return;
1359
+ }
1360
+ sendJson(405, {
1361
+ ok: false,
1362
+ error: `不支持的方法:${req.method ?? "?"}`
1363
+ });
1364
+ return;
1365
+ }
1366
+ if (segments[1] === "components" && req.method === "GET") {
1367
+ const name = url.searchParams.get("project") ?? "";
1368
+ const entry = registry.get(name);
1369
+ if (!entry) {
1370
+ sendJson(404, {
1371
+ ok: false,
1372
+ error: `未注册的项目:${name}`
1373
+ });
1374
+ return;
1375
+ }
1376
+ const compPath = componentsPathFor(entry.rootReal);
1377
+ if (compPath === null) {
1378
+ sendJson(200, {
1379
+ ok: true,
1380
+ path: null,
1381
+ items: []
1382
+ });
1383
+ return;
1384
+ }
1385
+ const doc = applyProjectPresets(loadDoc(compPath).doc, compPath).doc;
1386
+ sendJson(200, {
1387
+ ok: true,
1388
+ path: compPath,
1389
+ items: listComponents(compPath).map((c) => {
1390
+ const node = findNodeIn(doc.children, c.id);
1391
+ if (node === null) return c;
1392
+ try {
1393
+ const single = renderDocument({
1394
+ ...doc,
1395
+ children: [node]
1396
+ }, {
1397
+ title: c.id,
1398
+ transparent: true
1399
+ });
1400
+ return {
1401
+ ...c,
1402
+ width: Math.round(single.canvas.width),
1403
+ height: Math.round(single.canvas.height)
1404
+ };
1405
+ } catch {
1406
+ return c;
1407
+ }
1408
+ })
1409
+ });
1410
+ return;
1411
+ }
961
1412
  sendJson(404, {
962
1413
  ok: false,
963
1414
  error: `未知 API:${url.pathname}`
@@ -1001,16 +1452,20 @@ async function startDaemon(opts = {}) {
1001
1452
  const token = Symbol("browser");
1002
1453
  entry.browsers.add(token);
1003
1454
  cancelAutoDetach(entry.name);
1004
- const release = watchers.acquire(abs, () => {
1455
+ const notifyChange = () => {
1005
1456
  res.write(`event: change\ndata: ${JSON.stringify({
1006
1457
  path: relPath,
1007
1458
  at: Date.now()
1008
1459
  })}\n\n`);
1009
- });
1460
+ };
1461
+ const releases = [watchers.acquire(abs, notifyChange)];
1462
+ releases.push(watchers.acquire(abs.replace(/\.(ptr|pen)$/i, ".md"), notifyChange));
1463
+ const presetAbs = presetPathFor(entry.rootReal);
1464
+ if (presetAbs !== null) releases.push(watchers.acquire(presetAbs, notifyChange));
1010
1465
  const keepAlive = setInterval(() => res.write(": ping\n\n"), 2e4);
1011
1466
  const done = () => {
1012
1467
  clearInterval(keepAlive);
1013
- release();
1468
+ for (const release of releases) release();
1014
1469
  entry.browsers.delete(token);
1015
1470
  maybeScheduleAutoDetach(entry.name);
1016
1471
  };
@@ -1032,10 +1487,21 @@ async function startDaemon(opts = {}) {
1032
1487
  return;
1033
1488
  }
1034
1489
  const loaded = loadDoc(abs);
1035
- const rendered = renderDocument(loaded.doc, { title: relPath });
1490
+ const merged = applyProjectPresets(loaded.doc, abs).doc;
1491
+ const { registry: fontRegistry } = projectFontRegistry(abs);
1492
+ const fontPlan = planFonts(fontRegistry, (source) => source.target === void 0 ? null : filesUrl(entry.name, source.target));
1493
+ const rendered = renderDocument(merged, {
1494
+ title: relPath,
1495
+ fonts: fontPlan,
1496
+ probeFonts: url.searchParams.get("embed") === "1"
1497
+ });
1036
1498
  const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "";
1037
1499
  const baseHref = `/files/${entry.name.split("/").map(encodeURIComponent).join("/")}/${dir.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${dir ? "/" : ""}`;
1038
1500
  let html = rendered.html.replace("<head>", `<head>\n<base href="${baseHref}">`);
1501
+ if (url.searchParams.get("embed") === "1") {
1502
+ const fontApi = `/api/fonts?project=${encodeURIComponent(entry.name)}&path=${encodeURIComponent(relPath)}`;
1503
+ html = html.replace("</head>", `<script>window.PAINTRESS_FONT_URL=${JSON.stringify(fontApi)}<\/script>\n</head>`);
1504
+ }
1039
1505
  if (url.searchParams.get("embed") !== "1") {
1040
1506
  const liveScript = `<script>\n(function(){\n var es = new EventSource('/events?project=${encodeURIComponent(entry.name)}&path=${encodeURIComponent(relPath)}');\n es.addEventListener('change', function(){ location.reload(); });\n})();\n<\/script>`;
1041
1507
  html = html.replace("</body>", `${liveScript}</body>`);
@@ -1043,6 +1509,38 @@ async function startDaemon(opts = {}) {
1043
1509
  send(200, html);
1044
1510
  return;
1045
1511
  }
1512
+ if (segments[0] === "render-component") {
1513
+ const project = url.searchParams.get("project") ?? "";
1514
+ const id = url.searchParams.get("id") ?? "";
1515
+ const entry = registry.get(project);
1516
+ if (!entry) {
1517
+ send(404, "project not found", "text/plain; charset=utf-8");
1518
+ return;
1519
+ }
1520
+ const compPath = componentsPathFor(entry.rootReal);
1521
+ if (compPath === null || id === "") {
1522
+ send(404, "component not found", "text/plain; charset=utf-8");
1523
+ return;
1524
+ }
1525
+ const doc = applyProjectPresets(loadDoc(compPath).doc, compPath).doc;
1526
+ const node = findNodeIn(doc.children, id);
1527
+ if (node === null) {
1528
+ send(404, "component not found", "text/plain; charset=utf-8");
1529
+ return;
1530
+ }
1531
+ const single = {
1532
+ ...doc,
1533
+ children: [node]
1534
+ };
1535
+ const { registry: fontRegistry } = projectFontRegistry(compPath);
1536
+ const fontPlan = planFonts(fontRegistry, (source) => source.target === void 0 ? null : filesUrl(entry.name, source.target));
1537
+ send(200, renderDocument(single, {
1538
+ title: id,
1539
+ transparent: true,
1540
+ fonts: fontPlan
1541
+ }).html);
1542
+ return;
1543
+ }
1046
1544
  if (segments[0] === "files" && segments.length >= 3) {
1047
1545
  const hit = resolveProjectPrefix(registry, segments.slice(1));
1048
1546
  if (hit === null) {
@@ -1056,10 +1554,27 @@ async function startDaemon(opts = {}) {
1056
1554
  send(404, "file not found", "text/plain; charset=utf-8");
1057
1555
  return;
1058
1556
  }
1557
+ const ext = extname(abs).toLowerCase();
1558
+ const isFont = ext === ".woff" || ext === ".woff2" || ext === ".ttf" || ext === ".otf";
1559
+ if (isFont) try {
1560
+ if (statSync(abs).size > 33554432) {
1561
+ sendJson(413, {
1562
+ ok: false,
1563
+ error: "字体文件超过 32 MB,拒绝加载"
1564
+ });
1565
+ return;
1566
+ }
1567
+ } catch {
1568
+ sendJson(404, {
1569
+ ok: false,
1570
+ error: "file not found"
1571
+ });
1572
+ return;
1573
+ }
1059
1574
  const buf = await readFile(abs);
1060
1575
  res.writeHead(200, {
1061
1576
  "content-type": mimeOf(abs),
1062
- "cache-control": "no-cache"
1577
+ "cache-control": isFont ? "public, max-age=31536000, immutable" : "no-cache"
1063
1578
  });
1064
1579
  res.end(buf);
1065
1580
  return;
@@ -1171,30 +1686,39 @@ async function startDaemon(opts = {}) {
1171
1686
  changes.prune();
1172
1687
  }, idleCheckMs);
1173
1688
  idleTimer.unref();
1174
- async function shutdown(reason) {
1175
- if (closing) return;
1689
+ /**
1690
+ * 关停:幂等且**可等待**。
1691
+ * 已经在关停中时必须返回同一个 Promise —— 否则 `close()` 会立刻 resolve,
1692
+ * 调用方以为锁已释放,紧接着 `startDaemon` 就撞上「已在运行」。
1693
+ * (v0.6.0 的 release 测试里就是这么连环挂的:idle 关停还在飞,close() 先返回了。)
1694
+ */
1695
+ function shutdown(reason) {
1696
+ if (closingPromise !== null) return closingPromise;
1176
1697
  closing = true;
1177
- if (!opts.quiet) process.stderr.write(`paintress web: 退出(${reason})\n`);
1178
- clearInterval(idleTimer);
1179
- if (registryTimer !== null) clearTimeout(registryTimer);
1180
- for (const timer of autoDetachTimers.values()) clearTimeout(timer);
1181
- autoDetachTimers.clear();
1182
- for (const client of registryClients) try {
1183
- client.write(`event: shutdown\ndata: ${JSON.stringify({
1184
- reason,
1185
- at: Date.now()
1186
- })}\n\n`);
1187
- client.end();
1188
- } catch {}
1189
- registryClients.clear();
1190
- for (const client of controlClients) client.end();
1191
- controlClients.clear();
1192
- await watchers.closeAll();
1193
- await new Promise((resolve) => sockServer.close(() => resolve()));
1194
- const httpClosed = new Promise((resolve) => httpServer.close(() => resolve()));
1195
- httpServer.closeAllConnections();
1196
- await httpClosed;
1197
- releaseLock();
1698
+ closingPromise = (async () => {
1699
+ if (!opts.quiet) process.stderr.write(`paintress web: 退出(${reason})\n`);
1700
+ clearInterval(idleTimer);
1701
+ if (registryTimer !== null) clearTimeout(registryTimer);
1702
+ for (const timer of autoDetachTimers.values()) clearTimeout(timer);
1703
+ autoDetachTimers.clear();
1704
+ for (const client of registryClients) try {
1705
+ client.write(`event: shutdown\ndata: ${JSON.stringify({
1706
+ reason,
1707
+ at: Date.now()
1708
+ })}\n\n`);
1709
+ client.end();
1710
+ } catch {}
1711
+ registryClients.clear();
1712
+ for (const client of controlClients) client.end();
1713
+ controlClients.clear();
1714
+ await watchers.closeAll();
1715
+ await new Promise((resolve) => sockServer.close(() => resolve()));
1716
+ const httpClosed = new Promise((resolve) => httpServer.close(() => resolve()));
1717
+ httpServer.closeAllConnections();
1718
+ await httpClosed;
1719
+ releaseLock();
1720
+ })();
1721
+ return closingPromise;
1198
1722
  }
1199
1723
  return {
1200
1724
  port,
@@ -1503,6 +2027,7 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
1503
2027
  "scale",
1504
2028
  "port",
1505
2029
  "idle",
2030
+ "foreground",
1506
2031
  "daemon",
1507
2032
  "dry-run",
1508
2033
  "opencode-layout",
@@ -1514,14 +2039,29 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
1514
2039
  "gap",
1515
2040
  "force",
1516
2041
  "board",
2042
+ "parent",
2043
+ "apply",
2044
+ "min-uses",
2045
+ "key",
2046
+ "group",
2047
+ "prop",
2048
+ "value",
2049
+ "preview",
2050
+ "note",
2051
+ "metrics",
2052
+ "embed-fonts",
1517
2053
  NO_OPEN_FLAG
1518
2054
  ]);
2055
+ /** `daemon` 只是历史别名:默认行为已经是后台,留着不报错 */
1519
2056
  const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
1520
2057
  "regex",
1521
2058
  "reusable",
2059
+ "foreground",
1522
2060
  "daemon",
1523
2061
  "dry-run",
1524
2062
  "force",
2063
+ "apply",
2064
+ "embed-fonts",
1525
2065
  NO_OPEN_FLAG
1526
2066
  ]);
1527
2067
  /** 纯函数:argv → 命令 + 位置参数 + 标志(可单测) */
@@ -1570,6 +2110,14 @@ function flagNum(f, key) {
1570
2110
  if (!Number.isInteger(n) || n < 0) throw new Error(`--${key} 需要非负整数,收到:${v}`);
1571
2111
  return n;
1572
2112
  }
2113
+ /** 连续数值参数(截图倍率 0.2–2);与整数参数分开,不能把 0.8 当成非法 */
2114
+ function flagDecimal(f, key) {
2115
+ const v = flagStr(f, key);
2116
+ if (v === void 0) return void 0;
2117
+ const n = Number(v);
2118
+ if (!Number.isFinite(n)) throw new Error(`--${key} 需要数字,收到:${v}`);
2119
+ return n;
2120
+ }
1573
2121
  function parseJson(text, what) {
1574
2122
  try {
1575
2123
  return JSON.parse(text);
@@ -1583,6 +2131,23 @@ async function call(tool, args) {
1583
2131
  return await def.run(args);
1584
2132
  }
1585
2133
  /** 入口:返回退出码(可单测) */
2134
+ /** 当前目录所在项目里两个关键文件的现状(只报告,不创建) */
2135
+ function cwdDesignSystem() {
2136
+ try {
2137
+ const root = findProjectRoot(process.cwd());
2138
+ return root === null ? null : projectFilesInfo(root);
2139
+ } catch {
2140
+ return null;
2141
+ }
2142
+ }
2143
+ /** CLI 进程也要用「注册根」解析项目文件:从 daemon 拉一次项目表即可 */
2144
+ async function syncProjectRoots() {
2145
+ if (!isDaemonRunning()) return;
2146
+ try {
2147
+ const res = await sendRequest({ kind: "status" }, 2e3);
2148
+ if (res.ok && "projects" in res && Array.isArray(res.projects)) setProjectRoots(res.projects.map((p) => p.root));
2149
+ } catch {}
2150
+ }
1586
2151
  async function main(argv) {
1587
2152
  if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
1588
2153
  console.log(`paintress ${VERSION}`);
@@ -1590,10 +2155,11 @@ async function main(argv) {
1590
2155
  }
1591
2156
  const parsed = parseArgv(argv);
1592
2157
  if (!parsed) {
1593
- console.error("用法:paintress <mcp|web|ping|status|watch|unwatch|attach|stop|new|info|query|get|update|insert|delete|move|batch|render|shot|validate|install|doctor|mcp-config> ...(版本:paintress --version)");
2158
+ console.error("用法:paintress <mcp|web|ping|status|watch|unwatch|attach|stop|new|info|query|get|update|insert|delete|move|batch|render|shot|validate|audit|establish|adjust|install|doctor|mcp-config> ...(版本:paintress --version)");
1594
2159
  return 2;
1595
2160
  }
1596
2161
  const { cmd, positionals, flags } = parsed;
2162
+ await syncProjectRoots();
1597
2163
  const [file, a1, a2] = positionals;
1598
2164
  try {
1599
2165
  if (cmd === "mcp") {
@@ -1605,7 +2171,7 @@ async function main(argv) {
1605
2171
  console.log(JSON.stringify({
1606
2172
  ok: false,
1607
2173
  running: false,
1608
- hint: "未运行:paintress web --daemon"
2174
+ hint: "未运行:paintress web"
1609
2175
  }));
1610
2176
  return 1;
1611
2177
  }
@@ -1747,39 +2313,59 @@ async function main(argv) {
1747
2313
  const port = flagNum(flags, "port");
1748
2314
  const idle = flagNum(flags, "idle");
1749
2315
  const noOpen = flags[NO_OPEN_FLAG] === true || process.env["PAINTRESS_NO_OPEN"] === "1";
1750
- if (flags["daemon"] === true) {
1751
- const { spawn } = await import("node:child_process");
1752
- const args = [process.argv[1] ?? "", "web"];
1753
- if (port !== void 0) args.push("--port", String(port));
1754
- if (idle !== void 0) args.push("--idle", String(idle));
1755
- if (noOpen) args.push("--no-open");
1756
- const child = spawn(process.execPath, args, {
1757
- detached: true,
1758
- stdio: "ignore"
2316
+ const url = (p) => `http://localhost:${p}`;
2317
+ const running = await reuseDaemon(port);
2318
+ if (running !== null) {
2319
+ console.log(JSON.stringify({
2320
+ ok: true,
2321
+ daemon: true,
2322
+ reused: true,
2323
+ pid: running.pid,
2324
+ port: running.port,
2325
+ http: url(running.port),
2326
+ designSystem: cwdDesignSystem()
2327
+ }, null, 2));
2328
+ return 0;
2329
+ }
2330
+ if (flags["foreground"] !== true) {
2331
+ const res = await ensureDaemon({
2332
+ ...port !== void 0 ? { port } : {},
2333
+ ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
2334
+ noOpen
1759
2335
  });
1760
- child.unref();
1761
- const deadline = Date.now() + 5e3;
1762
- while (Date.now() < deadline) {
1763
- if (isDaemonRunning()) {
1764
- const res = await sendRequest({ kind: "ping" });
1765
- console.log(JSON.stringify({
1766
- daemon: true,
1767
- launcherPid: child.pid,
1768
- ...res
1769
- }, null, 2));
1770
- return 0;
1771
- }
1772
- await new Promise((r) => setTimeout(r, 100));
1773
- }
1774
- throw new Error("后台启动超时(5s 内未出现控制面 socket)");
2336
+ console.log(JSON.stringify({
2337
+ ok: true,
2338
+ daemon: true,
2339
+ reused: !res.started,
2340
+ pid: res.pid,
2341
+ port: res.port,
2342
+ http: url(res.port),
2343
+ designSystem: cwdDesignSystem()
2344
+ }, null, 2));
2345
+ return 0;
1775
2346
  }
1776
- const handle = await startDaemon({
1777
- ...port !== void 0 ? { port } : {},
1778
- ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
1779
- emptyGraceMs: 6e4,
1780
- updateCheck: true
1781
- });
1782
- const http = `http://localhost:${handle.port}`;
2347
+ let handle;
2348
+ try {
2349
+ handle = await startDaemon({
2350
+ ...port !== void 0 ? { port } : {},
2351
+ ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
2352
+ emptyGraceMs: 6e4,
2353
+ updateCheck: true
2354
+ });
2355
+ } catch (cause) {
2356
+ const again = await queryDaemon();
2357
+ if (again === null) throw cause;
2358
+ console.log(JSON.stringify({
2359
+ ok: true,
2360
+ daemon: true,
2361
+ reused: true,
2362
+ pid: again.pid,
2363
+ port: again.port,
2364
+ http: url(again.port)
2365
+ }, null, 2));
2366
+ return 0;
2367
+ }
2368
+ const http = url(handle.port);
1783
2369
  console.log(JSON.stringify({
1784
2370
  ok: true,
1785
2371
  pid: handle.pid,
@@ -1787,7 +2373,7 @@ async function main(argv) {
1787
2373
  http
1788
2374
  }, null, 2));
1789
2375
  if (!noOpen) {
1790
- const { openBrowser } = await import("./server-ssrAxbTR.js").then((n) => n.r);
2376
+ const { openBrowser } = await import("./server-DBRmwE_v.js").then((n) => n.i);
1791
2377
  openBrowser(`${http}/`);
1792
2378
  }
1793
2379
  await new Promise((resolve) => {
@@ -1796,8 +2382,160 @@ async function main(argv) {
1796
2382
  });
1797
2383
  return 0;
1798
2384
  }
1799
- if (!file) throw new Error("缺少 <file> 参数");
1800
2385
  let out;
2386
+ if (cmd === "preset") {
2387
+ const action = file ?? "list";
2388
+ if (action === "list") out = await call("paintress_preset", {
2389
+ action: "list",
2390
+ ...a1 !== void 0 ? { path: a1 } : {}
2391
+ });
2392
+ else if (action === "groups") {
2393
+ const full = await call("paintress_preset", {
2394
+ action: "list",
2395
+ ...a1 !== void 0 ? { path: a1 } : {}
2396
+ });
2397
+ out = {
2398
+ ok: true,
2399
+ path: full.path ?? null,
2400
+ groups: full.groups ?? {}
2401
+ };
2402
+ } else if (action === "seed") out = await call("paintress_preset", {
2403
+ action: "seed",
2404
+ ...a1 !== void 0 ? { path: a1 } : {},
2405
+ ...flags["apply"] === true ? { apply: true } : {}
2406
+ });
2407
+ else if (action === "set-group") {
2408
+ if (!a1 || !a2 || !positionals[3] || !positionals[4]) throw new Error("用法:paintress preset set-group <dir> <组名> <target> '<props-json>'");
2409
+ let props;
2410
+ try {
2411
+ props = JSON.parse(positionals[4]);
2412
+ } catch {
2413
+ throw new Error(`props 不是合法 JSON:${positionals[4]}`);
2414
+ }
2415
+ out = await call("paintress_preset", {
2416
+ action: "set-group",
2417
+ path: a1,
2418
+ group: a2,
2419
+ target: positionals[3],
2420
+ props
2421
+ });
2422
+ } else if (action === "remove-group") {
2423
+ if (!a1 || !a2) throw new Error("用法:paintress preset remove-group <dir> <组名>");
2424
+ out = await call("paintress_preset", {
2425
+ action: "remove-group",
2426
+ path: a1,
2427
+ group: a2
2428
+ });
2429
+ } else if (action === "set") {
2430
+ if (!a1 || !a2 || !positionals[3] || !positionals[4]) throw new Error("用法:paintress preset set <dir> <key> <color|radius|spacing|fontSize|font> <value> [--name 名称]");
2431
+ out = await call("paintress_preset", {
2432
+ action: "set",
2433
+ path: a1,
2434
+ key: a2,
2435
+ type: positionals[3],
2436
+ value: positionals[4],
2437
+ ...flagStr(flags, "name") !== void 0 ? { name: flagStr(flags, "name") } : {}
2438
+ });
2439
+ } else if (action === "remove") {
2440
+ if (!a1 || !a2) throw new Error("用法:paintress preset remove <dir> <key>");
2441
+ out = await call("paintress_preset", {
2442
+ action: "remove",
2443
+ path: a1,
2444
+ key: a2
2445
+ });
2446
+ } else throw new Error(`未知 preset 动作:${action}(list / set / remove / seed / set-group / remove-group)`);
2447
+ console.log(JSON.stringify(out, null, 2));
2448
+ return 0;
2449
+ }
2450
+ if (cmd === "component") {
2451
+ const action = file ?? "list";
2452
+ if (action === "list") out = await call("paintress_component", {
2453
+ action: "list",
2454
+ ...a1 !== void 0 ? { path: a1 } : {}
2455
+ });
2456
+ else if (action === "insert") {
2457
+ if (!a1 || !a2) throw new Error("用法:paintress component insert <file> <id> [--parent p] [--index n]");
2458
+ out = await call("paintress_component", {
2459
+ action: "insert",
2460
+ path: a1,
2461
+ id: a2,
2462
+ ...flagStr(flags, "parent") !== void 0 ? { parent: flagStr(flags, "parent") } : {},
2463
+ ...flagNum(flags, "index") !== void 0 ? { index: flagNum(flags, "index") } : {}
2464
+ });
2465
+ } else if (action === "add") {
2466
+ if (!a1 || !a2) throw new Error("用法:paintress component add <file> <id> [--name 名称] [--parent p] [--index n]");
2467
+ out = await call("paintress_component", {
2468
+ action: "add",
2469
+ path: a1,
2470
+ id: a2,
2471
+ ...flagStr(flags, "name") !== void 0 ? { name: flagStr(flags, "name") } : {},
2472
+ ...flagStr(flags, "parent") !== void 0 ? { parent: flagStr(flags, "parent") } : {},
2473
+ ...flagNum(flags, "index") !== void 0 ? { index: flagNum(flags, "index") } : {}
2474
+ });
2475
+ } else throw new Error(`未知 component 动作:${action}(list / insert / add)`);
2476
+ console.log(JSON.stringify(out, null, 2));
2477
+ return 0;
2478
+ }
2479
+ if (cmd === "font") {
2480
+ const action = file ?? "list";
2481
+ if (action === "list") out = await call("paintress_font", {
2482
+ action: "list",
2483
+ ...a1 !== void 0 ? { path: a1 } : {}
2484
+ });
2485
+ else if (action === "remove") {
2486
+ if (!a1 || !a2) throw new Error("用法:paintress font remove <dir> <family>");
2487
+ out = await call("paintress_font", {
2488
+ action: "remove",
2489
+ path: a1,
2490
+ family: a2
2491
+ });
2492
+ } else if (action === "default") {
2493
+ if (!a1 || !a2) throw new Error("用法:paintress font default <dir> <token-or-family>");
2494
+ out = await call("paintress_font", {
2495
+ action: "default",
2496
+ path: a1,
2497
+ value: a2
2498
+ });
2499
+ } else if (action === "add") {
2500
+ if (!a1 || !a2 || !positionals[3]) throw new Error("用法:paintress font add <dir> <family> <web|css|file|system|bundle> [source] [--preview x] [--note n] [--metrics '{\"sizeAdjust\":\"96%\"}']");
2501
+ const metricsRaw = flagStr(flags, "metrics");
2502
+ let metrics;
2503
+ if (metricsRaw !== void 0) try {
2504
+ metrics = JSON.parse(metricsRaw);
2505
+ } catch {
2506
+ throw new Error(`metrics 不是合法 JSON:${metricsRaw}`);
2507
+ }
2508
+ const preview = flagStr(flags, "preview");
2509
+ const note = flagStr(flags, "note");
2510
+ out = await call("paintress_font", {
2511
+ action: "add",
2512
+ path: a1,
2513
+ family: a2,
2514
+ kind: positionals[3],
2515
+ ...positionals[4] !== void 0 ? { source: positionals[4] } : {},
2516
+ ...preview !== void 0 ? { preview } : {},
2517
+ ...note !== void 0 ? { note } : {},
2518
+ ...metrics !== void 0 ? { metrics } : {}
2519
+ });
2520
+ } else throw new Error(`未知 font 动作:${action}(list / add / remove / default)`);
2521
+ console.log(JSON.stringify(out, null, 2));
2522
+ return 0;
2523
+ }
2524
+ if (cmd === "project") {
2525
+ out = await call("paintress_project", { ...file !== void 0 ? { path: file } : {} });
2526
+ console.log(JSON.stringify(out, null, 2));
2527
+ return 0;
2528
+ }
2529
+ if (cmd === "convert") {
2530
+ out = await call("paintress_convert", {
2531
+ path: file,
2532
+ ...a1 !== void 0 ? { out: a1 } : {},
2533
+ ...flags["force"] === true ? { overwrite: true } : {}
2534
+ });
2535
+ console.log(JSON.stringify(out, null, 2));
2536
+ return 0;
2537
+ }
2538
+ if (!file) throw new Error("缺少 <file> 参数");
1801
2539
  switch (cmd) {
1802
2540
  case "new": {
1803
2541
  const name = flagStr(flags, "name");
@@ -1887,9 +2625,36 @@ async function main(argv) {
1887
2625
  if (!a1) throw new Error("缺少 <out.html> 参数");
1888
2626
  out = await call("paintress_render", {
1889
2627
  path: file,
1890
- out: a1
2628
+ out: a1,
2629
+ ...flags["embed-fonts"] === true ? { embedFonts: true } : {}
2630
+ });
2631
+ break;
2632
+ case "audit":
2633
+ out = await call("paintress_audit", { path: file });
2634
+ break;
2635
+ case "establish":
2636
+ out = await call("paintress_establish", {
2637
+ path: file,
2638
+ ...flags["apply"] === true ? { apply: true } : {},
2639
+ ...flagNum(flags, "min-uses") !== void 0 ? { minUses: flagNum(flags, "min-uses") } : {}
1891
2640
  });
1892
2641
  break;
2642
+ case "adjust": {
2643
+ const key = flagStr(flags, "key");
2644
+ const group = flagStr(flags, "group");
2645
+ const prop = flagStr(flags, "prop");
2646
+ const value = flagStr(flags, "value");
2647
+ if (value === void 0) throw new Error("adjust 需要 --value");
2648
+ out = await call("paintress_adjust", {
2649
+ path: file,
2650
+ value,
2651
+ ...key !== void 0 ? { key } : {},
2652
+ ...group !== void 0 ? { group } : {},
2653
+ ...prop !== void 0 ? { prop } : {},
2654
+ ...flags["apply"] === true ? { apply: true } : {}
2655
+ });
2656
+ break;
2657
+ }
1893
2658
  case "shot": {
1894
2659
  if (!a1) throw new Error("缺少 <out.png> 参数");
1895
2660
  if (flags["board"] === true) throw new Error("--board 需要画板 id 或 name");
@@ -1897,7 +2662,7 @@ async function main(argv) {
1897
2662
  out = await call("paintress_screenshot", {
1898
2663
  path: file,
1899
2664
  out: a1,
1900
- ...flagNum(flags, "scale") !== void 0 ? { scale: flagNum(flags, "scale") } : {},
2665
+ ...flagDecimal(flags, "scale") !== void 0 ? { scale: flagDecimal(flags, "scale") } : {},
1901
2666
  ...board !== void 0 ? { board } : {}
1902
2667
  });
1903
2668
  break;