paintress 0.6.0 → 0.7.1

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,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { C as isPresetType, D as presetPathFor, E as notePathFor, O as readTextIfExists, S as findNodeIn, T as listPresets, _ as LineDecoder, a as ensureDaemon, b as componentsPathFor, c as isDaemonRunning, d as lockPath, f as pidPath, g as socketPath, h as sanitizeProjectName, k as setPreset, 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 listComponents, x as ensureProjectScaffold, y as applyProjectPresets } from "./server-DZRNNWac.js";
3
- import { T as loadDoc, qt as VERSION, t as renderDocument } from "./render-BSf62388.js";
2
+ import { A as listComponents, C as findBrowser, D as ensureProjectScaffold, E as componentsPathFor, F as projectFilesInfo, I as projectFontRegistry, L as readTextIfExists, M as listPresets, N as notePathFor, O as findNodeIn, P as presetPathFor, R as setPreset, S as encodeLine, T as applyProjectPresets, _ as resolveProjectDir, a as defaultRouteIpv4, b as socketPath, c as windowsSystem32, d as attachPersistent, f as isDaemonRunning, g as pidPath, h as lockPath, j as listPresetGroups, k as findProjectRoot, l as ensureDaemon, m as sendRequest, n as runStdioServer, o as interopEnabled, p as queryDaemon, r as TOOLS, s as isWsl, u as reuseDaemon, v as safeJoin, w as screenshotPen, x as LineDecoder, y as sanitizeProjectName, z as setProjectRoots } from "./server-Buuoq3kq.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";
11
+ import { spawn } from "node:child_process";
10
12
  import { createServer as createServer$1 } from "node:http";
11
13
  import { readFile } from "node:fs/promises";
12
14
  import chokidar from "chokidar";
@@ -121,18 +123,24 @@ var Registry = class {
121
123
  var MarksStore = class {
122
124
  byProject = /* @__PURE__ */ new Map();
123
125
  seq = 0;
124
- /** 同一项目内同一 nodeId 复用既有短号;否则分配新号 */
126
+ /** 短号按「文件 + nodeId」去重:节点 id 只在文件内唯一,跨文件同 id 不能复用 */
127
+ key(project, nodeId, file) {
128
+ return `${project}\u0000${file ?? ""}\u0000${nodeId}`;
129
+ }
130
+ /** 同一项目内同一文件同一 nodeId 复用既有短号;否则分配新号 */
125
131
  add(input) {
126
132
  let bucket = this.byProject.get(input.project);
127
133
  if (!bucket) {
128
134
  bucket = /* @__PURE__ */ new Map();
129
135
  this.byProject.set(input.project, bucket);
130
136
  }
131
- const existing = bucket.get(input.nodeId);
137
+ const key = this.key(input.project, input.nodeId, input.file);
138
+ const existing = bucket.get(key);
132
139
  if (existing) {
133
140
  if (input.name !== void 0) existing.name = input.name;
134
141
  if (input.type !== void 0) existing.type = input.type;
135
142
  if (input.path !== void 0) existing.path = input.path;
143
+ if (input.file !== void 0) existing.file = input.file;
136
144
  existing.at = Date.now();
137
145
  return existing;
138
146
  }
@@ -140,21 +148,22 @@ var MarksStore = class {
140
148
  const mark = {
141
149
  shortId: this.seq,
142
150
  project: input.project,
151
+ ...input.file !== void 0 ? { file: input.file } : {},
143
152
  nodeId: input.nodeId,
144
153
  name: input.name,
145
154
  type: input.type,
146
155
  path: input.path ?? input.nodeId,
147
156
  at: Date.now()
148
157
  };
149
- bucket.set(input.nodeId, mark);
158
+ bucket.set(key, mark);
150
159
  return mark;
151
160
  }
152
161
  list(project) {
153
162
  const bucket = this.byProject.get(project);
154
163
  return bucket ? [...bucket.values()].sort((a, b) => a.shortId - b.shortId) : [];
155
164
  }
156
- get(project, nodeId) {
157
- return this.byProject.get(project)?.get(nodeId);
165
+ get(project, nodeId, file) {
166
+ return this.byProject.get(project)?.get(this.key(project, nodeId, file));
158
167
  }
159
168
  clear(project) {
160
169
  const bucket = this.byProject.get(project);
@@ -163,8 +172,16 @@ var MarksStore = class {
163
172
  this.byProject.delete(project);
164
173
  return n;
165
174
  }
166
- remove(project, nodeId) {
167
- return this.byProject.get(project)?.delete(nodeId) ?? false;
175
+ remove(project, nodeId, file) {
176
+ const bucket = this.byProject.get(project);
177
+ if (bucket === void 0) return false;
178
+ if (file !== void 0) return bucket.delete(this.key(project, nodeId, file));
179
+ let removed = false;
180
+ for (const [key, mark] of bucket) if (mark.nodeId === nodeId) {
181
+ bucket.delete(key);
182
+ removed = true;
183
+ }
184
+ return removed;
168
185
  }
169
186
  };
170
187
  // src/web/changes.ts
@@ -399,8 +416,368 @@ var VersionChecker = class {
399
416
  };
400
417
  }
401
418
  };
419
+ // src/web/bind.ts
420
+ /**
421
+ * 监听地址的选取策略(端口阶梯)。
422
+ *
423
+ * 为什么需要它(2026-11-15 实测):WSL2 的 `localhost` 转发由宿主侧 `wslrelay.exe`
424
+ * 落地 —— Windows 上绑不上这个端口,转发就不存在,宿主浏览器只会看到「连接失败」。
425
+ * 最常见的一类不可绑是 **Hyper-V 保留端口段**(`netsh int ipv4 show excludedportrange`),
426
+ * 而保留段每次开机在动态端口范围内重新划分,位置会漂:同一个默认端口今天能开、明天不能开。
427
+ * 这一类故障在 WSL 内部完全看不出来(daemon 自己 `curl 127.0.0.1` 永远是 200)。
428
+ *
429
+ * 因此顺序是「宿主预检、真正 listen、最后复核」:
430
+ * 1. 宿主体检(一次 interop 调用):哪些候选端口在 Windows 上绑得上;
431
+ * 2. 真正 listen:WSL 内冲突在这里暴露,失败才换下一个候选;
432
+ * 3. 端到端复核:从 Windows 侧取 `/api/ping`,校验 nonce —— 只信身份,不信 200。
433
+ *
434
+ * 阶梯:127.0.0.1 × [6033, 6133, 6233] → 0.0.0.0 × [6033, 6133, 6233],最多 6 次。
435
+ * 兜底的 0.0.0.0 之所以有效:Windows 直连 WSL 网卡地址不经过 wslrelay,端口被宿主保留也不影响
436
+ * (实测保留端口上 localhost 连不通、WSL IP 返回 200)。
437
+ */
438
+ /** 默认端口:60 号肉身成圣远征队 + 33 号远征队 */
439
+ const DEFAULT_PORT = 6033;
440
+ /** 跳级候选(顺序即优先级) */
441
+ const PORT_CANDIDATES = [
442
+ DEFAULT_PORT,
443
+ 6133,
444
+ 6233
445
+ ];
446
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
447
+ /** 单个候选的探测预算:loopback 要走 wslrelay 接管(实测 300ms–1.06s),all 是直连 */
448
+ const PROBE_BUDGET_MS = {
449
+ loopback: 2e3,
450
+ all: 600
451
+ };
452
+ const PROBE_INTERVAL_MS = 150;
453
+ /** 单次 Windows 进程调用的硬超时;端到端探测会按剩余预算收紧它 */
454
+ const INTEROP_TIMEOUT_MS = 4e3;
455
+ function inRange(ranges, port) {
456
+ return ranges.some(([start, end]) => port >= start && port <= end);
457
+ }
458
+ const hostOf = (mode) => mode === "loopback" ? "127.0.0.1" : "0.0.0.0";
459
+ function candidatesFor(opts) {
460
+ const pref = opts.bind ?? "auto";
461
+ if (opts.requestedPort !== void 0) {
462
+ const mode = pref === "all" ? "all" : "loopback";
463
+ return [{
464
+ port: opts.requestedPort,
465
+ host: hostOf(mode),
466
+ bind: mode
467
+ }];
468
+ }
469
+ return (pref === "auto" ? ["loopback", "all"] : [pref]).flatMap((mode) => (opts.ports ?? PORT_CANDIDATES).map((port) => ({
470
+ port,
471
+ host: hostOf(mode),
472
+ bind: mode
473
+ })));
474
+ }
475
+ /** 运行一条 Windows 命令,收集 stdout(超时/不存在都返回 null) */
476
+ async function runWindows(exe, args, timeoutMs = INTEROP_TIMEOUT_MS) {
477
+ return new Promise((resolve) => {
478
+ let out = "";
479
+ const child = spawn(exe, args, { stdio: [
480
+ "ignore",
481
+ "pipe",
482
+ "ignore"
483
+ ] });
484
+ const timer = setTimeout(() => {
485
+ child.kill("SIGKILL");
486
+ resolve(null);
487
+ }, timeoutMs);
488
+ child.stdout?.on("data", (chunk) => {
489
+ out += chunk.toString("utf8");
490
+ });
491
+ child.once("error", () => {
492
+ clearTimeout(timer);
493
+ resolve(null);
494
+ });
495
+ child.once("close", () => {
496
+ clearTimeout(timer);
497
+ resolve(out);
498
+ });
499
+ });
500
+ }
501
+ function system32() {
502
+ if (!interopEnabled()) return null;
503
+ return windowsSystem32();
504
+ }
505
+ /**
506
+ * 宿主体检:一次 PowerShell 调用同时拿回「保留段」与「候选端口可否独占绑定」。
507
+ * 为什么不分开调:每次 interop 都是新进程(热 70–260ms,冷启动可达 2s),启动路径上能省则省。
508
+ */
509
+ async function windowsReport(ports) {
510
+ const sys32 = system32();
511
+ if (sys32 === null) return null;
512
+ const ps = `${sys32}/WindowsPowerShell/v1.0/powershell.exe`;
513
+ if (!existsSync(ps)) return null;
514
+ const raw = await runWindows(ps, [
515
+ "-NoProfile",
516
+ "-NonInteractive",
517
+ "-Command",
518
+ [
519
+ "$ex = @()",
520
+ "$null = netsh.exe int ipv4 show excludedportrange protocol=tcp | ForEach-Object { if ($_ -match '^\\s*(\\d+)\\s+(\\d+)\\s*$') { $ex += ,@([int]$Matches[1],[int]$Matches[2]) } }",
521
+ "$b = @{}",
522
+ `foreach ($p in @(${ports.join(",")})) {`,
523
+ " $s = New-Object System.Net.Sockets.Socket([System.Net.Sockets.AddressFamily]::InterNetwork,[System.Net.Sockets.SocketType]::Stream,[System.Net.Sockets.ProtocolType]::Tcp)",
524
+ " try { $s.ExclusiveAddressUse = $true",
525
+ " $s.Bind((New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Loopback,$p)))",
526
+ " $b[\"$p\"] = $true } catch { $b[\"$p\"] = $false } finally { $s.Close() }",
527
+ "}",
528
+ "@{ excluded = $ex; bindable = $b } | ConvertTo-Json -Compress -Depth 4"
529
+ ].join("; ")
530
+ ]);
531
+ if (raw === null) return null;
532
+ try {
533
+ const parsed = JSON.parse(raw.trim());
534
+ const excluded = Array.isArray(parsed.excluded) ? parsed.excluded.filter((r) => Array.isArray(r) && r.length === 2).map((r) => [Number(r[0]), Number(r[1])]) : [];
535
+ const bindable = {};
536
+ if (parsed.bindable !== null && typeof parsed.bindable === "object") for (const [k, v] of Object.entries(parsed.bindable)) bindable[k] = v === true;
537
+ return {
538
+ excluded: excluded.filter(([s, e]) => Number.isFinite(s) && Number.isFinite(e)),
539
+ bindable
540
+ };
541
+ } catch {
542
+ return null;
543
+ }
544
+ }
545
+ /**
546
+ * 端到端探测:从 Windows 侧取 `/api/ping`,正文里必须出现本次 nonce。
547
+ * 只认身份不认状态码 —— 端口上蹲着别的程序(甚至另一个 distro 的 paintress)时,200 也是假的。
548
+ */
549
+ async function windowsProbe(url, budgetMs) {
550
+ const sys32 = system32();
551
+ if (sys32 === null) return false;
552
+ let nonce = "";
553
+ try {
554
+ nonce = new URL(url).searchParams.get("nonce") ?? "";
555
+ } catch {
556
+ return false;
557
+ }
558
+ if (nonce === "") return false;
559
+ const deadline = Date.now() + budgetMs;
560
+ const curl = `${sys32}/curl.exe`;
561
+ if (existsSync(curl)) {
562
+ while (Date.now() < deadline) {
563
+ const remaining = deadline - Date.now();
564
+ const out = await runWindows(curl, [
565
+ "-s",
566
+ "--noproxy",
567
+ "*",
568
+ "-m",
569
+ "2",
570
+ url
571
+ ], Math.max(1, remaining));
572
+ if (out !== null && out.includes(nonce)) return true;
573
+ await sleep(PROBE_INTERVAL_MS);
574
+ }
575
+ return false;
576
+ }
577
+ const ps = `${sys32}/WindowsPowerShell/v1.0/powershell.exe`;
578
+ if (!existsSync(ps)) return false;
579
+ const script = `try { (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri '${url}').Content } catch { '' }`;
580
+ while (Date.now() < deadline) {
581
+ const remaining = deadline - Date.now();
582
+ const out = await runWindows(ps, [
583
+ "-NoProfile",
584
+ "-NonInteractive",
585
+ "-Command",
586
+ script
587
+ ], Math.max(1, remaining));
588
+ if (out !== null && out.includes(nonce)) return true;
589
+ await sleep(PROBE_INTERVAL_MS);
590
+ }
591
+ return false;
592
+ }
593
+ function listenOn(server, endpoint) {
594
+ return new Promise((resolve, reject) => {
595
+ const onError = (err) => {
596
+ server.off("listening", onListening);
597
+ reject(err);
598
+ };
599
+ const onListening = () => {
600
+ server.off("error", onError);
601
+ resolve();
602
+ };
603
+ server.once("error", onError);
604
+ server.once("listening", onListening);
605
+ server.listen(endpoint.port, endpoint.host);
606
+ });
607
+ }
608
+ function closeQuietly(server) {
609
+ try {
610
+ server.closeAllConnections?.();
611
+ server.close();
612
+ } catch {}
613
+ }
614
+ function ladderError(attempts, explicit) {
615
+ const reason = {
616
+ ok: "ok",
617
+ "in-use": "端口已被占用",
618
+ "windows-reserved": "落在 Windows 保留端口段",
619
+ "windows-unreachable": "Windows 侧连不上(转发未建立)"
620
+ };
621
+ const rows = attempts.map((a) => ` ${a.bind === "all" ? "0.0.0.0" : "127.0.0.1"}:${a.port} ${reason[a.result]}${a.detail ? `(${a.detail})` : ""}`);
622
+ const hint = explicit ? "显式 --port 不跳级:换一个端口(--port <n>),或在 WSL 里用 --bind all 绑 0.0.0.0 让宿主直连。" : "可用 --port <n> 指定端口、--bind all 直接绑 0.0.0.0,或把 Windows 动态端口段恢复默认(见 README「端口与宿主访问」)。";
623
+ return /* @__PURE__ */ new Error(`paintress web 没有可用端口,${attempts.length} 个候选全部失败:\n${rows.join("\n")}\n${hint}`);
624
+ }
625
+ /** 宿主预检:能提前判死的就别去 listen(保留段 / 宿主占用)。返回 null = 继续尝试 */
626
+ function predictSkip(cand, report, wsl, log) {
627
+ if (!wsl || cand.bind !== "loopback" || report === null) return null;
628
+ if (inRange(report.excluded, cand.port)) {
629
+ log(`${cand.port} 落在 Windows 保留端口段,跳过`);
630
+ return {
631
+ port: cand.port,
632
+ bind: cand.bind,
633
+ result: "windows-reserved"
634
+ };
635
+ }
636
+ if (report.bindable[String(cand.port)] === false) {
637
+ log(`${cand.port} 在 Windows 侧绑不上(已被占用或已被转发),跳过`);
638
+ return {
639
+ port: cand.port,
640
+ bind: cand.bind,
641
+ result: "in-use",
642
+ detail: "Windows 侧不可绑(已被占用或已转发)"
643
+ };
644
+ }
645
+ return null;
646
+ }
647
+ /**
648
+ * 端到端复核:从宿主侧取 `/api/ping`,只认本次进程的 nonce。
649
+ * 返回 'unreachable' 表示「本机起得来、宿主连不上」—— 正是 WSL 转发没落地的那种失败。
650
+ */
651
+ async function verifyCandidate(cand, ctx) {
652
+ if (!ctx.wsl) return "skipped";
653
+ if (!ctx.canProbe) return "unavailable";
654
+ let target = null;
655
+ if (cand.bind === "loopback") target = `127.0.0.1:${cand.port}`;
656
+ else if (ctx.ipv4 !== null) target = `${ctx.ipv4}:${cand.port}`;
657
+ if (target === null) return "unreachable";
658
+ return await ctx.probe(`http://${target}/api/ping?nonce=${ctx.nonce}`, PROBE_BUDGET_MS[cand.bind]) ? "ok" : "unreachable";
659
+ }
660
+ /**
661
+ * 走完阶梯并返回**已经 listen 成功且复核通过**的 server。
662
+ * 调用方只需要接管返回值,不必关心中间换过几个候选。
663
+ */
664
+ async function bindWithFallback(opts) {
665
+ const seams = opts.seams ?? {};
666
+ const log = opts.log ?? (() => {});
667
+ const explicit = opts.requestedPort !== void 0;
668
+ const ephemeral = opts.requestedPort === 0;
669
+ const wsl = !ephemeral && (seams.isWsl ?? isWsl)();
670
+ const requestedPort = opts.requestedPort ?? opts.ports?.[0] ?? 6033;
671
+ const plan = candidatesFor(opts);
672
+ const attempts = [];
673
+ const ipv4 = (seams.hostIpv4 ?? defaultRouteIpv4)();
674
+ const probe = seams.probe ?? windowsProbe;
675
+ const canProbe = wsl && (seams.probe !== void 0 || interopEnabled() && windowsSystem32() !== null);
676
+ let report = null;
677
+ if (canProbe && plan.some((c) => c.bind === "loopback")) {
678
+ report = await (seams.report ?? windowsReport)(plan.filter((c) => c.bind === "loopback").map((c) => c.port));
679
+ if (report === null) log("Windows 侧体检失败,跳过宿主预检(仍做端到端复核)");
680
+ } else if (wsl && !canProbe) log("Windows interop 不可用:跳过宿主预检与复核");
681
+ const probeCtx = {
682
+ wsl,
683
+ canProbe,
684
+ ipv4,
685
+ nonce: opts.nonce,
686
+ probe
687
+ };
688
+ for (const cand of plan) {
689
+ if (wsl && cand.bind === "all" && ipv4 === null) {
690
+ attempts.push({
691
+ port: cand.port,
692
+ bind: cand.bind,
693
+ result: "windows-unreachable",
694
+ detail: "找不到默认路由网卡的 IPv4 地址"
695
+ });
696
+ log(`${cand.port} (all) 没有可供宿主访问的网卡地址,换下一个`);
697
+ continue;
698
+ }
699
+ const skip = predictSkip(cand, report, wsl, log);
700
+ if (skip !== null) {
701
+ attempts.push(skip);
702
+ continue;
703
+ }
704
+ const server = opts.create();
705
+ try {
706
+ await listenOn(server, cand);
707
+ } catch (cause) {
708
+ const code = cause.code;
709
+ if (code === "EADDRINUSE" || code === "EACCES") {
710
+ attempts.push({
711
+ port: cand.port,
712
+ bind: cand.bind,
713
+ result: "in-use",
714
+ detail: String(code)
715
+ });
716
+ log(`${cand.port} (${cand.bind}) 本机不可用(${code}),换下一个`);
717
+ continue;
718
+ }
719
+ throw cause;
720
+ }
721
+ const verdict = await verifyCandidate(cand, probeCtx);
722
+ if (verdict === "unreachable") {
723
+ closeQuietly(server);
724
+ attempts.push({
725
+ port: cand.port,
726
+ bind: cand.bind,
727
+ result: "windows-unreachable"
728
+ });
729
+ log(`${cand.port} (${cand.bind}) 已监听但 Windows 侧连不上,换下一个`);
730
+ continue;
731
+ }
732
+ attempts.push({
733
+ port: cand.port,
734
+ bind: cand.bind,
735
+ result: "ok"
736
+ });
737
+ const resolved = server.address();
738
+ const actualPort = typeof resolved === "object" && resolved !== null ? resolved.port : cand.port;
739
+ const host = cand.bind === "loopback" ? "localhost" : ipv4 ?? cand.host;
740
+ const access = {
741
+ url: `http://${host}:${actualPort}/`,
742
+ host,
743
+ bind: cand.bind,
744
+ windows: verdict,
745
+ jumped: !ephemeral && actualPort !== requestedPort,
746
+ requestedPort: ephemeral ? actualPort : requestedPort,
747
+ attempts
748
+ };
749
+ if (access.jumped) log(`端口 ${requestedPort} 不可用,已改用 ${actualPort}`);
750
+ if (cand.bind === "all") log(`已绑定 0.0.0.0:${actualPort} —— 宿主浏览器用 ${access.url}(localhost 走不通)`);
751
+ return {
752
+ server,
753
+ endpoint: {
754
+ ...cand,
755
+ port: actualPort
756
+ },
757
+ access
758
+ };
759
+ }
760
+ throw ladderError(attempts, explicit);
761
+ }
762
+ async function diagnoseNetwork(seams = {}) {
763
+ const wsl = (seams.isWsl ?? isWsl)();
764
+ const ports = [...PORT_CANDIDATES];
765
+ const report = wsl ? await (seams.report ?? windowsReport)(ports) : null;
766
+ return {
767
+ wsl,
768
+ interop: interopEnabled(),
769
+ system32: windowsSystem32(),
770
+ hostIpv4: (seams.hostIpv4 ?? defaultRouteIpv4)(),
771
+ defaultPort: DEFAULT_PORT,
772
+ candidates: ports.map((port) => ({
773
+ port,
774
+ excluded: report === null ? false : inRange(report.excluded, port),
775
+ windowsBindable: report === null ? null : report.bindable[String(port)] === true
776
+ })),
777
+ report
778
+ };
779
+ }
402
780
  // src/web/daemon.ts
403
- const DEFAULT_PORT = 3316;
404
781
  const MIME = {
405
782
  ".html": "text/html; charset=utf-8",
406
783
  ".js": "text/javascript; charset=utf-8",
@@ -423,6 +800,10 @@ const MIME = {
423
800
  function mimeOf(filePath) {
424
801
  return MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream";
425
802
  }
803
+ /** 项目内资源 → /files URL(项目名可能带斜杠,按段编码) */
804
+ function filesUrl(project, relPath) {
805
+ return `/files/${project.split("/").map(encodeURIComponent).join("/")}/${relPath.split("/").filter((seg) => seg !== "").map(encodeURIComponent).join("/")}`;
806
+ }
426
807
  /** 定位前端产物目录:源码运行 / 构建后 / cwd 三种候选,找不到则回退精简视图 */
427
808
  function findUiDist() {
428
809
  const here = dirname(fileURLToPath(import.meta.url));
@@ -527,14 +908,60 @@ function esc(s) {
527
908
  function isTrustedWrite(req) {
528
909
  return req.headers["x-paintress"] === "1";
529
910
  }
911
+ /**
912
+ * 属性面板用的变量表:项目预设打底、文档本地同名优先。
913
+ * themed 列表取第一个可显示的值(面板只做展示,不替人挑主题)。
914
+ */
915
+ function panelVars(fileAbs) {
916
+ const out = {};
917
+ const collect = (doc) => {
918
+ for (const [key, def] of Object.entries(doc?.variables ?? {})) {
919
+ const raw = def["value"];
920
+ 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;
921
+ if (typeof first === "string" || typeof first === "number" || typeof first === "boolean") out[key] = String(first);
922
+ }
923
+ };
924
+ try {
925
+ const p = presetPathFor(fileAbs);
926
+ if (p !== null) collect(loadDoc(p).doc);
927
+ collect(loadDoc(fileAbs).doc);
928
+ } catch {}
929
+ return out;
930
+ }
931
+ var FontReportStore = class {
932
+ byFile = /* @__PURE__ */ new Map();
933
+ set(project, path, report) {
934
+ this.byFile.set(`${project}\u0000${path}`, {
935
+ ...report,
936
+ project,
937
+ path,
938
+ at: Date.now()
939
+ });
940
+ }
941
+ get(project, path) {
942
+ return this.byFile.get(`${project}\u0000${path}`);
943
+ }
944
+ list(project) {
945
+ return [...this.byFile.values()].filter((r) => r.project === project);
946
+ }
947
+ };
530
948
  async function startDaemon(opts = {}) {
531
949
  const releaseLock = acquireLock();
532
950
  const registry = new Registry();
533
951
  const marks = new MarksStore();
952
+ /** daemon 自己也是 doc 层的一个调用方:注册根就是它的项目表 */
953
+ const syncProjectRoots = () => setProjectRoots(registry.all().map((p) => p.root));
954
+ const fontReports = new FontReportStore();
534
955
  const changes = new ChangesStore();
535
956
  const watchers = new WatchManager();
536
957
  const versions = new VersionChecker();
537
958
  const startedAt = Date.now();
959
+ /** 身份串:宿主侧探测必须回显它,避免「端口上蹲着别的程序」被当成成功 */
960
+ const instanceNonce = randomUUID();
961
+ /** 绑定结果;ping 只能发生在 listen 之后(socket 服务更晚),先用占位值初始化 */
962
+ let port = 0;
963
+ let accessInfo = null;
964
+ let endpoint = null;
538
965
  const idleTimeoutMs = opts.idleTimeoutMs ?? 6e4;
539
966
  const emptyGraceMs = opts.emptyGraceMs ?? Math.min(idleTimeoutMs, 3e3);
540
967
  const emptyBrowserMs = opts.emptyBrowserMs ?? 3e4;
@@ -671,7 +1098,8 @@ async function startDaemon(opts = {}) {
671
1098
  pid: process.pid,
672
1099
  port,
673
1100
  uptimeMs: Date.now() - startedAt,
674
- projects: registry.all().length
1101
+ projects: registry.all().length,
1102
+ ...accessInfo !== null ? { access: accessInfo } : {}
675
1103
  });
676
1104
  break;
677
1105
  case "status":
@@ -683,6 +1111,12 @@ async function startDaemon(opts = {}) {
683
1111
  version: VERSION
684
1112
  });
685
1113
  break;
1114
+ case "fonts":
1115
+ reply({
1116
+ ok: true,
1117
+ reports: fontReports.list(req.project)
1118
+ });
1119
+ break;
686
1120
  case "watch": {
687
1121
  everHadProjects = true;
688
1122
  const entry = registry.watch(req.project, req.dir, req.depth, req.owner === "agent" ? { owner: "agent" } : {});
@@ -694,6 +1128,7 @@ async function startDaemon(opts = {}) {
694
1128
  } catch (cause) {
695
1129
  process.stderr.write(`paintress web: 项目脚手架创建失败(${cause instanceof Error ? cause.message : String(cause)})\n`);
696
1130
  }
1131
+ syncProjectRoots();
697
1132
  reply({
698
1133
  ok: true,
699
1134
  watched: true,
@@ -705,6 +1140,7 @@ async function startDaemon(opts = {}) {
705
1140
  case "unwatch": {
706
1141
  const before = registry.get(req.project);
707
1142
  const removed = before === void 0 ? false : detachProject(before, "unwatch");
1143
+ syncProjectRoots();
708
1144
  reply({
709
1145
  ok: true,
710
1146
  unwatched: removed
@@ -797,7 +1233,7 @@ async function startDaemon(opts = {}) {
797
1233
  };
798
1234
  const sockServer = createServer(handleClient);
799
1235
  const uiDist = findUiDist();
800
- const httpServer = createServer$1(async (req, res) => {
1236
+ const handleHttp = async (req, res) => {
801
1237
  lastHttpAt = Date.now();
802
1238
  const url = new URL(req.url ?? "/", "http://localhost");
803
1239
  const segments = url.pathname.split("/").filter((s) => s !== "");
@@ -823,6 +1259,17 @@ async function startDaemon(opts = {}) {
823
1259
  };
824
1260
  try {
825
1261
  if (segments[0] === "api") {
1262
+ if (segments[1] === "ping" && segments.length === 2) {
1263
+ sendJson(200, {
1264
+ ok: true,
1265
+ pong: true,
1266
+ pid: process.pid,
1267
+ nonce: instanceNonce,
1268
+ port,
1269
+ bind: endpoint === null ? "loopback" : endpoint.bind
1270
+ });
1271
+ return;
1272
+ }
826
1273
  if (segments[1] === "projects" && segments.length === 2) {
827
1274
  sendJson(200, {
828
1275
  ok: true,
@@ -898,6 +1345,61 @@ async function startDaemon(opts = {}) {
898
1345
  });
899
1346
  return;
900
1347
  }
1348
+ if (segments[1] === "fonts" && segments.length === 2) {
1349
+ const qProject = url.searchParams.get("project") ?? "";
1350
+ const qFile = url.searchParams.get("path") ?? "";
1351
+ if (req.method === "POST") {
1352
+ const raw = await readBody();
1353
+ let body;
1354
+ try {
1355
+ body = JSON.parse(raw);
1356
+ } catch {
1357
+ sendJson(400, {
1358
+ ok: false,
1359
+ error: "请求体不是合法 JSON"
1360
+ });
1361
+ return;
1362
+ }
1363
+ const project = typeof body["project"] === "string" ? body["project"] : qProject;
1364
+ const path = typeof body["path"] === "string" ? body["path"] : qFile;
1365
+ if (project === "" || path === "") {
1366
+ sendJson(400, {
1367
+ ok: false,
1368
+ error: "缺少 project / path"
1369
+ });
1370
+ return;
1371
+ }
1372
+ if (!registry.get(project)) {
1373
+ sendJson(404, {
1374
+ ok: false,
1375
+ error: `未注册的项目:${project}`
1376
+ });
1377
+ return;
1378
+ }
1379
+ const checked = Array.isArray(body["checked"]) ? body["checked"].map(String) : [];
1380
+ const missing = Array.isArray(body["missing"]) ? body["missing"].map(String) : [];
1381
+ fontReports.set(project, path, {
1382
+ checked,
1383
+ missing,
1384
+ substituted: [],
1385
+ source: "viewer"
1386
+ });
1387
+ sendJson(200, { ok: true });
1388
+ return;
1389
+ }
1390
+ if (req.method !== "GET") {
1391
+ sendJson(405, {
1392
+ ok: false,
1393
+ error: "不支持的方法"
1394
+ });
1395
+ return;
1396
+ }
1397
+ sendJson(200, {
1398
+ ok: true,
1399
+ reports: fontReports.list(qProject)
1400
+ });
1401
+ return;
1402
+ }
901
1403
  if (segments[1] === "changes") {
902
1404
  const qProject = url.searchParams.get("project") ?? "";
903
1405
  const qFile = url.searchParams.get("path") ?? "";
@@ -909,6 +1411,132 @@ async function startDaemon(opts = {}) {
909
1411
  });
910
1412
  return;
911
1413
  }
1414
+ if (segments[1] === "export" && req.method === "GET") {
1415
+ const qProject = url.searchParams.get("project") ?? "";
1416
+ const qFile = url.searchParams.get("path") ?? "";
1417
+ const qBoard = url.searchParams.get("board") ?? "";
1418
+ const qScale = Number(url.searchParams.get("scale") ?? "1");
1419
+ if (qProject === "" || qFile === "" || qBoard === "") {
1420
+ sendJson(400, {
1421
+ ok: false,
1422
+ error: "缺少 project / path / board"
1423
+ });
1424
+ return;
1425
+ }
1426
+ if (qScale !== .5 && qScale !== 1 && qScale !== 2) {
1427
+ sendJson(400, {
1428
+ ok: false,
1429
+ error: "scale 只支持 0.5 / 1 / 2"
1430
+ });
1431
+ return;
1432
+ }
1433
+ const entry = registry.get(qProject);
1434
+ if (!entry) {
1435
+ sendJson(404, {
1436
+ ok: false,
1437
+ error: `未注册的项目:${qProject}`
1438
+ });
1439
+ return;
1440
+ }
1441
+ const abs = safeJoin(entry.rootReal, qFile);
1442
+ if (!abs) {
1443
+ sendJson(400, {
1444
+ ok: false,
1445
+ error: `路径不在项目内:${qFile}`
1446
+ });
1447
+ return;
1448
+ }
1449
+ if (findBrowser() === null) {
1450
+ sendJson(501, {
1451
+ ok: false,
1452
+ error: "本机没有 Chromium/Chrome,无法导出 PNG(可用 PTR_BROWSER 指定)"
1453
+ });
1454
+ return;
1455
+ }
1456
+ const tmpPng = join(tmpdir(), `paintress-export-${randomUUID()}.png`);
1457
+ try {
1458
+ const shot = await screenshotPen(abs, tmpPng, {
1459
+ board: qBoard,
1460
+ scale: qScale
1461
+ });
1462
+ const buf = await readFile(tmpPng);
1463
+ 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`;
1464
+ const ascii = raw.replace(/[^A-Za-z0-9._@-]+/g, "_");
1465
+ res.writeHead(200, {
1466
+ "content-type": "image/png",
1467
+ "content-disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(raw)}`,
1468
+ "cache-control": "no-store",
1469
+ "x-paintress-scale": String(shot.scale)
1470
+ });
1471
+ res.end(buf);
1472
+ return;
1473
+ } catch (cause) {
1474
+ sendJson(400, {
1475
+ ok: false,
1476
+ error: cause instanceof Error ? cause.message : String(cause)
1477
+ });
1478
+ return;
1479
+ } finally {
1480
+ try {
1481
+ unlinkSync(tmpPng);
1482
+ } catch {}
1483
+ }
1484
+ }
1485
+ if (segments[1] === "node" && req.method === "GET") {
1486
+ const qProject = url.searchParams.get("project") ?? "";
1487
+ const qFile = url.searchParams.get("path") ?? "";
1488
+ const qId = url.searchParams.get("id") ?? "";
1489
+ if (qProject === "" || qFile === "" || qId === "") {
1490
+ sendJson(400, {
1491
+ ok: false,
1492
+ error: "缺少 project / path / id"
1493
+ });
1494
+ return;
1495
+ }
1496
+ const entry = registry.get(qProject);
1497
+ if (!entry) {
1498
+ sendJson(404, {
1499
+ ok: false,
1500
+ error: `未注册的项目:${qProject}`
1501
+ });
1502
+ return;
1503
+ }
1504
+ const abs = safeJoin(entry.rootReal, qFile);
1505
+ if (!abs) {
1506
+ sendJson(400, {
1507
+ ok: false,
1508
+ error: `路径不在项目内:${qFile}`
1509
+ });
1510
+ return;
1511
+ }
1512
+ let doc;
1513
+ try {
1514
+ doc = loadDoc(abs).doc;
1515
+ } catch (cause) {
1516
+ sendJson(404, {
1517
+ ok: false,
1518
+ error: cause instanceof Error ? cause.message : String(cause)
1519
+ });
1520
+ return;
1521
+ }
1522
+ const node = findNodeIn(doc.children, qId);
1523
+ if (node === null) {
1524
+ sendJson(404, {
1525
+ ok: false,
1526
+ error: `节点不存在:${qId}`
1527
+ });
1528
+ return;
1529
+ }
1530
+ entry.lastAccess = Date.now();
1531
+ const { children, ...fields } = node;
1532
+ sendJson(200, {
1533
+ ok: true,
1534
+ node: fields,
1535
+ childrenCount: Array.isArray(children) ? children.length : 0,
1536
+ vars: panelVars(abs)
1537
+ });
1538
+ return;
1539
+ }
912
1540
  if (segments[1] === "marks") {
913
1541
  const qProject = url.searchParams.get("project") ?? "";
914
1542
  if (req.method === "GET") {
@@ -951,6 +1579,7 @@ async function startDaemon(opts = {}) {
951
1579
  mark: marks.add({
952
1580
  project: projectIn,
953
1581
  nodeId,
1582
+ ...typeof body["file"] === "string" && body["file"] !== "" ? { file: body["file"] } : {},
954
1583
  ...typeof body["name"] === "string" ? { name: body["name"] } : {},
955
1584
  ...typeof body["type"] === "string" ? { type: body["type"] } : {},
956
1585
  ...typeof body["path"] === "string" ? { path: body["path"] } : {}
@@ -960,9 +1589,10 @@ async function startDaemon(opts = {}) {
960
1589
  }
961
1590
  if (req.method === "DELETE") {
962
1591
  const nodeId = url.searchParams.get("nodeId");
1592
+ const file = url.searchParams.get("file") ?? void 0;
963
1593
  sendJson(200, {
964
1594
  ok: true,
965
- cleared: nodeId ? marks.remove(qProject, nodeId) ? 1 : 0 : marks.clear(qProject)
1595
+ cleared: nodeId ? marks.remove(qProject, nodeId, file) ? 1 : 0 : marks.clear(qProject)
966
1596
  });
967
1597
  return;
968
1598
  }
@@ -1032,7 +1662,8 @@ async function startDaemon(opts = {}) {
1032
1662
  sendJson(200, {
1033
1663
  ok: true,
1034
1664
  path,
1035
- items
1665
+ items,
1666
+ groups: listPresetGroups(entry.rootReal).groups
1036
1667
  });
1037
1668
  return;
1038
1669
  }
@@ -1078,7 +1709,7 @@ async function startDaemon(opts = {}) {
1078
1709
  if (!isPresetType(typeRaw)) {
1079
1710
  sendJson(400, {
1080
1711
  ok: false,
1081
- error: "type 必须是 color 或 radius"
1712
+ error: "type 必须是 color / radius / spacing / fontSize / font"
1082
1713
  });
1083
1714
  return;
1084
1715
  }
@@ -1094,7 +1725,8 @@ async function startDaemon(opts = {}) {
1094
1725
  sendJson(200, {
1095
1726
  ok: true,
1096
1727
  path: presetPathFor(entry.rootReal),
1097
- items
1728
+ items,
1729
+ groups: listPresetGroups(entry.rootReal).groups
1098
1730
  });
1099
1731
  } catch (cause) {
1100
1732
  sendJson(400, {
@@ -1234,10 +1866,21 @@ async function startDaemon(opts = {}) {
1234
1866
  return;
1235
1867
  }
1236
1868
  const loaded = loadDoc(abs);
1237
- const rendered = renderDocument(applyProjectPresets(loaded.doc, abs).doc, { title: relPath });
1869
+ const merged = applyProjectPresets(loaded.doc, abs).doc;
1870
+ const { registry: fontRegistry } = projectFontRegistry(abs);
1871
+ const fontPlan = planFonts(fontRegistry, (source) => source.target === void 0 ? null : filesUrl(entry.name, source.target));
1872
+ const rendered = renderDocument(merged, {
1873
+ title: relPath,
1874
+ fonts: fontPlan,
1875
+ probeFonts: url.searchParams.get("embed") === "1"
1876
+ });
1238
1877
  const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "";
1239
1878
  const baseHref = `/files/${entry.name.split("/").map(encodeURIComponent).join("/")}/${dir.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${dir ? "/" : ""}`;
1240
1879
  let html = rendered.html.replace("<head>", `<head>\n<base href="${baseHref}">`);
1880
+ if (url.searchParams.get("embed") === "1") {
1881
+ const fontApi = `/api/fonts?project=${encodeURIComponent(entry.name)}&path=${encodeURIComponent(relPath)}`;
1882
+ html = html.replace("</head>", `<script>window.PAINTRESS_FONT_URL=${JSON.stringify(fontApi)}<\/script>\n</head>`);
1883
+ }
1241
1884
  if (url.searchParams.get("embed") !== "1") {
1242
1885
  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>`;
1243
1886
  html = html.replace("</body>", `${liveScript}</body>`);
@@ -1264,12 +1907,16 @@ async function startDaemon(opts = {}) {
1264
1907
  send(404, "component not found", "text/plain; charset=utf-8");
1265
1908
  return;
1266
1909
  }
1267
- send(200, renderDocument({
1910
+ const single = {
1268
1911
  ...doc,
1269
1912
  children: [node]
1270
- }, {
1913
+ };
1914
+ const { registry: fontRegistry } = projectFontRegistry(compPath);
1915
+ const fontPlan = planFonts(fontRegistry, (source) => source.target === void 0 ? null : filesUrl(entry.name, source.target));
1916
+ send(200, renderDocument(single, {
1271
1917
  title: id,
1272
- transparent: true
1918
+ transparent: true,
1919
+ fonts: fontPlan
1273
1920
  }).html);
1274
1921
  return;
1275
1922
  }
@@ -1286,10 +1933,27 @@ async function startDaemon(opts = {}) {
1286
1933
  send(404, "file not found", "text/plain; charset=utf-8");
1287
1934
  return;
1288
1935
  }
1936
+ const ext = extname(abs).toLowerCase();
1937
+ const isFont = ext === ".woff" || ext === ".woff2" || ext === ".ttf" || ext === ".otf";
1938
+ if (isFont) try {
1939
+ if (statSync(abs).size > 33554432) {
1940
+ sendJson(413, {
1941
+ ok: false,
1942
+ error: "字体文件超过 32 MB,拒绝加载"
1943
+ });
1944
+ return;
1945
+ }
1946
+ } catch {
1947
+ sendJson(404, {
1948
+ ok: false,
1949
+ error: "file not found"
1950
+ });
1951
+ return;
1952
+ }
1289
1953
  const buf = await readFile(abs);
1290
1954
  res.writeHead(200, {
1291
1955
  "content-type": mimeOf(abs),
1292
- "cache-control": "no-cache"
1956
+ "cache-control": isFont ? "public, max-age=31536000, immutable" : "no-cache"
1293
1957
  });
1294
1958
  res.end(buf);
1295
1959
  return;
@@ -1323,7 +1987,8 @@ async function startDaemon(opts = {}) {
1323
1987
  const info = registry.info(p);
1324
1988
  return `<div class="card"><h2 style="margin:0 0 4px"><a href="${`/${p.name.split("/").map(encodeURIComponent).join("/")}`}">${esc(p.label)}</a></h2><div class="meta">${esc(info.root)} · ${info.fileCount} 个设计稿 · agent ${info.agentConns} · 浏览器 ${info.browserConns}</div></div>`;
1325
1989
  }).join("");
1326
- send(200, layout("项目", `<h1>paintress</h1><div class="meta">pid ${process.pid} · 端口 ${opts.port ?? DEFAULT_PORT} · 运行 ${Math.round((Date.now() - startedAt) / 1e3)}s</div><p class="meta">前端未构建(<code>ui/dist</code> 不存在),当前为精简视图。</p>` + (items || "<p class=\"empty\">还没有注册任何项目。用 <code>paintress watch &lt;name&gt; &lt;dir&gt;</code> 注册。</p>")));
1990
+ const boundHost = endpoint === null ? "127.0.0.1" : endpoint.host;
1991
+ send(200, layout("项目", `<h1>paintress</h1><div class="meta">pid ${process.pid} · 端口 ${port} · 绑定 ${boundHost} · 运行 ${Math.round((Date.now() - startedAt) / 1e3)}s</div><p class="meta">前端未构建(<code>ui/dist</code> 不存在),当前为精简视图。</p>` + (items || "<p class=\"empty\">还没有注册任何项目。用 <code>paintress watch &lt;name&gt; &lt;dir&gt;</code> 注册。</p>")));
1327
1992
  return;
1328
1993
  }
1329
1994
  const hit = resolveProjectPrefix(registry, segments);
@@ -1343,27 +2008,40 @@ async function startDaemon(opts = {}) {
1343
2008
  } catch (cause) {
1344
2009
  send(500, layout("错误", `<h1>500</h1><pre>${esc(cause instanceof Error ? cause.message : String(cause))}</pre>`));
1345
2010
  }
1346
- });
1347
- const desiredPort = opts.port ?? DEFAULT_PORT;
1348
- const port = await new Promise((resolve, reject) => {
1349
- httpServer.once("error", (err) => {
1350
- reject(err.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`端口 ${desiredPort} 已被占用;可用 --port 指定其他端口`) : err);
1351
- });
1352
- httpServer.listen(desiredPort, "127.0.0.1", () => {
1353
- const addr = httpServer.address();
1354
- resolve(typeof addr === "object" && addr !== null ? addr.port : desiredPort);
2011
+ };
2012
+ let bound;
2013
+ try {
2014
+ bound = await bindWithFallback({
2015
+ create: () => createServer$1(handleHttp),
2016
+ nonce: instanceNonce,
2017
+ ...opts.port !== void 0 ? { requestedPort: opts.port } : {},
2018
+ ...opts.bind !== void 0 ? { bind: opts.bind } : {},
2019
+ ...opts.quiet === true ? {} : { log: (line) => process.stderr.write(`paintress web: ${line}\n`) }
1355
2020
  });
1356
- });
2021
+ } catch (cause) {
2022
+ releaseLock();
2023
+ throw cause;
2024
+ }
2025
+ const httpServer = bound.server;
2026
+ port = bound.endpoint.port;
2027
+ endpoint = bound.endpoint;
2028
+ accessInfo = bound.access;
1357
2029
  const sock = socketPath();
1358
2030
  try {
1359
2031
  if (existsSync(sock)) unlinkSync(sock);
1360
2032
  } catch {}
1361
- await new Promise((resolve, reject) => {
1362
- sockServer.once("error", reject);
1363
- sockServer.listen(sock, () => {
1364
- resolve();
2033
+ try {
2034
+ await new Promise((resolve, reject) => {
2035
+ sockServer.once("error", reject);
2036
+ sockServer.listen(sock, () => {
2037
+ resolve();
2038
+ });
1365
2039
  });
1366
- });
2040
+ } catch (cause) {
2041
+ httpServer.close();
2042
+ releaseLock();
2043
+ throw cause;
2044
+ }
1367
2045
  /**
1368
2046
  * 生命周期策略:控制权在 agent 侧,人不需要管进程在不在。
1369
2047
  *
@@ -1439,7 +2117,16 @@ async function startDaemon(opts = {}) {
1439
2117
  port,
1440
2118
  pid: process.pid,
1441
2119
  close: () => shutdown("close"),
1442
- registry
2120
+ registry,
2121
+ access: accessInfo ?? {
2122
+ url: `http://localhost:${port}/`,
2123
+ host: "localhost",
2124
+ bind: "loopback",
2125
+ windows: "skipped",
2126
+ jumped: false,
2127
+ requestedPort: opts.port ?? 6033,
2128
+ attempts: []
2129
+ }
1443
2130
  };
1444
2131
  }
1445
2132
  // src/integrations/clients.ts
@@ -1741,7 +2428,9 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
1741
2428
  "index",
1742
2429
  "scale",
1743
2430
  "port",
2431
+ "bind",
1744
2432
  "idle",
2433
+ "foreground",
1745
2434
  "daemon",
1746
2435
  "dry-run",
1747
2436
  "opencode-layout",
@@ -1754,14 +2443,28 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
1754
2443
  "force",
1755
2444
  "board",
1756
2445
  "parent",
2446
+ "apply",
2447
+ "min-uses",
2448
+ "key",
2449
+ "group",
2450
+ "prop",
2451
+ "value",
2452
+ "preview",
2453
+ "note",
2454
+ "metrics",
2455
+ "embed-fonts",
1757
2456
  NO_OPEN_FLAG
1758
2457
  ]);
2458
+ /** `daemon` 只是历史别名:默认行为已经是后台,留着不报错 */
1759
2459
  const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
1760
2460
  "regex",
1761
2461
  "reusable",
2462
+ "foreground",
1762
2463
  "daemon",
1763
2464
  "dry-run",
1764
2465
  "force",
2466
+ "apply",
2467
+ "embed-fonts",
1765
2468
  NO_OPEN_FLAG
1766
2469
  ]);
1767
2470
  /** 纯函数:argv → 命令 + 位置参数 + 标志(可单测) */
@@ -1799,6 +2502,29 @@ function currentLaunchCommand() {
1799
2502
  realpath: (p) => realpathSync(p)
1800
2503
  });
1801
2504
  }
2505
+ /** CLI 输出的 `http` 字段保持历史形态(不带结尾斜杠);`access.url` 仍带斜杠作为基地址 */
2506
+ function httpOf(access) {
2507
+ return access.url.replace(/\/$/, "");
2508
+ }
2509
+ /** `paintress doctor` 的「当前 daemon 实况」:没跑就是 null,不拉起进程 */
2510
+ async function daemonSnapshot() {
2511
+ const info = await queryDaemon(500);
2512
+ if (info === null) return null;
2513
+ return {
2514
+ pid: info.pid,
2515
+ port: info.port,
2516
+ url: info.access.url,
2517
+ bind: info.access.bind,
2518
+ windows: info.access.windows,
2519
+ jumped: info.access.jumped
2520
+ };
2521
+ }
2522
+ /** `--bind` 只接受三种取值:auto(默认,走端口阶梯)、loopback、all */
2523
+ function parseBind(v) {
2524
+ if (v === void 0) return void 0;
2525
+ if (v === "auto" || v === "loopback" || v === "all") return v;
2526
+ throw new Error(`--bind 只接受 auto|loopback|all,收到:${v}`);
2527
+ }
1802
2528
  function flagStr(f, key) {
1803
2529
  const v = f[key];
1804
2530
  return typeof v === "string" && v !== "" ? v : void 0;
@@ -1810,6 +2536,14 @@ function flagNum(f, key) {
1810
2536
  if (!Number.isInteger(n) || n < 0) throw new Error(`--${key} 需要非负整数,收到:${v}`);
1811
2537
  return n;
1812
2538
  }
2539
+ /** 连续数值参数(截图倍率 0.2–2);与整数参数分开,不能把 0.8 当成非法 */
2540
+ function flagDecimal(f, key) {
2541
+ const v = flagStr(f, key);
2542
+ if (v === void 0) return void 0;
2543
+ const n = Number(v);
2544
+ if (!Number.isFinite(n)) throw new Error(`--${key} 需要数字,收到:${v}`);
2545
+ return n;
2546
+ }
1813
2547
  function parseJson(text, what) {
1814
2548
  try {
1815
2549
  return JSON.parse(text);
@@ -1823,6 +2557,23 @@ async function call(tool, args) {
1823
2557
  return await def.run(args);
1824
2558
  }
1825
2559
  /** 入口:返回退出码(可单测) */
2560
+ /** 当前目录所在项目里两个关键文件的现状(只报告,不创建) */
2561
+ function cwdDesignSystem() {
2562
+ try {
2563
+ const root = findProjectRoot(process.cwd());
2564
+ return root === null ? null : projectFilesInfo(root);
2565
+ } catch {
2566
+ return null;
2567
+ }
2568
+ }
2569
+ /** CLI 进程也要用「注册根」解析项目文件:从 daemon 拉一次项目表即可 */
2570
+ async function syncProjectRoots() {
2571
+ if (!isDaemonRunning()) return;
2572
+ try {
2573
+ const res = await sendRequest({ kind: "status" }, 2e3);
2574
+ if (res.ok && "projects" in res && Array.isArray(res.projects)) setProjectRoots(res.projects.map((p) => p.root));
2575
+ } catch {}
2576
+ }
1826
2577
  async function main(argv) {
1827
2578
  if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
1828
2579
  console.log(`paintress ${VERSION}`);
@@ -1830,10 +2581,11 @@ async function main(argv) {
1830
2581
  }
1831
2582
  const parsed = parseArgv(argv);
1832
2583
  if (!parsed) {
1833
- 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)");
2584
+ 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)");
1834
2585
  return 2;
1835
2586
  }
1836
2587
  const { cmd, positionals, flags } = parsed;
2588
+ await syncProjectRoots();
1837
2589
  const [file, a1, a2] = positionals;
1838
2590
  try {
1839
2591
  if (cmd === "mcp") {
@@ -1845,7 +2597,7 @@ async function main(argv) {
1845
2597
  console.log(JSON.stringify({
1846
2598
  ok: false,
1847
2599
  running: false,
1848
- hint: "未运行:paintress web --daemon"
2600
+ hint: "未运行:paintress web"
1849
2601
  }));
1850
2602
  return 1;
1851
2603
  }
@@ -1956,7 +2708,13 @@ async function main(argv) {
1956
2708
  dryRun
1957
2709
  });
1958
2710
  }
1959
- console.log(JSON.stringify({
2711
+ console.log(JSON.stringify(cmd === "doctor" ? {
2712
+ ok: true,
2713
+ launch,
2714
+ results,
2715
+ net: await diagnoseNetwork(),
2716
+ daemon: await daemonSnapshot()
2717
+ } : {
1960
2718
  ok: true,
1961
2719
  launch,
1962
2720
  results
@@ -1986,9 +2744,9 @@ async function main(argv) {
1986
2744
  if (cmd === "web") {
1987
2745
  const port = flagNum(flags, "port");
1988
2746
  const idle = flagNum(flags, "idle");
2747
+ const bind = parseBind(flagStr(flags, "bind"));
1989
2748
  const noOpen = flags[NO_OPEN_FLAG] === true || process.env["PAINTRESS_NO_OPEN"] === "1";
1990
- const url = (p) => `http://localhost:${p}`;
1991
- const running = await reuseDaemon(port);
2749
+ const running = await reuseDaemon(port, bind);
1992
2750
  if (running !== null) {
1993
2751
  console.log(JSON.stringify({
1994
2752
  ok: true,
@@ -1996,13 +2754,16 @@ async function main(argv) {
1996
2754
  reused: true,
1997
2755
  pid: running.pid,
1998
2756
  port: running.port,
1999
- http: url(running.port)
2757
+ http: httpOf(running.access),
2758
+ access: running.access,
2759
+ designSystem: cwdDesignSystem()
2000
2760
  }, null, 2));
2001
2761
  return 0;
2002
2762
  }
2003
- if (flags["daemon"] === true) {
2763
+ if (flags["foreground"] !== true) {
2004
2764
  const res = await ensureDaemon({
2005
2765
  ...port !== void 0 ? { port } : {},
2766
+ ...bind !== void 0 ? { bind } : {},
2006
2767
  ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
2007
2768
  noOpen
2008
2769
  });
@@ -2012,7 +2773,9 @@ async function main(argv) {
2012
2773
  reused: !res.started,
2013
2774
  pid: res.pid,
2014
2775
  port: res.port,
2015
- http: url(res.port)
2776
+ http: httpOf(res.access),
2777
+ access: res.access,
2778
+ designSystem: cwdDesignSystem()
2016
2779
  }, null, 2));
2017
2780
  return 0;
2018
2781
  }
@@ -2020,6 +2783,7 @@ async function main(argv) {
2020
2783
  try {
2021
2784
  handle = await startDaemon({
2022
2785
  ...port !== void 0 ? { port } : {},
2786
+ ...bind !== void 0 ? { bind } : {},
2023
2787
  ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
2024
2788
  emptyGraceMs: 6e4,
2025
2789
  updateCheck: true
@@ -2033,20 +2797,21 @@ async function main(argv) {
2033
2797
  reused: true,
2034
2798
  pid: again.pid,
2035
2799
  port: again.port,
2036
- http: url(again.port)
2800
+ http: httpOf(again.access),
2801
+ access: again.access
2037
2802
  }, null, 2));
2038
2803
  return 0;
2039
2804
  }
2040
- const http = url(handle.port);
2041
2805
  console.log(JSON.stringify({
2042
2806
  ok: true,
2043
2807
  pid: handle.pid,
2044
2808
  port: handle.port,
2045
- http
2809
+ http: httpOf(handle.access),
2810
+ access: handle.access
2046
2811
  }, null, 2));
2047
2812
  if (!noOpen) {
2048
- const { openBrowser } = await import("./server-DZRNNWac.js").then((n) => n.i);
2049
- openBrowser(`${http}/`);
2813
+ const { openBrowser } = await import("./server-Buuoq3kq.js").then((n) => n.i);
2814
+ openBrowser(handle.access.url);
2050
2815
  }
2051
2816
  await new Promise((resolve) => {
2052
2817
  process.once("SIGINT", () => void handle.close().then(() => resolve()));
@@ -2061,8 +2826,45 @@ async function main(argv) {
2061
2826
  action: "list",
2062
2827
  ...a1 !== void 0 ? { path: a1 } : {}
2063
2828
  });
2064
- else if (action === "set") {
2065
- if (!a1 || !a2 || !positionals[3] || !positionals[4]) throw new Error("用法:paintress preset set <dir> <key> <color|radius> <value> [--name 名称]");
2829
+ else if (action === "groups") {
2830
+ const full = await call("paintress_preset", {
2831
+ action: "list",
2832
+ ...a1 !== void 0 ? { path: a1 } : {}
2833
+ });
2834
+ out = {
2835
+ ok: true,
2836
+ path: full.path ?? null,
2837
+ groups: full.groups ?? {}
2838
+ };
2839
+ } else if (action === "seed") out = await call("paintress_preset", {
2840
+ action: "seed",
2841
+ ...a1 !== void 0 ? { path: a1 } : {},
2842
+ ...flags["apply"] === true ? { apply: true } : {}
2843
+ });
2844
+ else if (action === "set-group") {
2845
+ if (!a1 || !a2 || !positionals[3] || !positionals[4]) throw new Error("用法:paintress preset set-group <dir> <组名> <target> '<props-json>'");
2846
+ let props;
2847
+ try {
2848
+ props = JSON.parse(positionals[4]);
2849
+ } catch {
2850
+ throw new Error(`props 不是合法 JSON:${positionals[4]}`);
2851
+ }
2852
+ out = await call("paintress_preset", {
2853
+ action: "set-group",
2854
+ path: a1,
2855
+ group: a2,
2856
+ target: positionals[3],
2857
+ props
2858
+ });
2859
+ } else if (action === "remove-group") {
2860
+ if (!a1 || !a2) throw new Error("用法:paintress preset remove-group <dir> <组名>");
2861
+ out = await call("paintress_preset", {
2862
+ action: "remove-group",
2863
+ path: a1,
2864
+ group: a2
2865
+ });
2866
+ } else if (action === "set") {
2867
+ if (!a1 || !a2 || !positionals[3] || !positionals[4]) throw new Error("用法:paintress preset set <dir> <key> <color|radius|spacing|fontSize|font> <value> [--name 名称]");
2066
2868
  out = await call("paintress_preset", {
2067
2869
  action: "set",
2068
2870
  path: a1,
@@ -2078,7 +2880,7 @@ async function main(argv) {
2078
2880
  path: a1,
2079
2881
  key: a2
2080
2882
  });
2081
- } else throw new Error(`未知 preset 动作:${action}(list / set / remove)`);
2883
+ } else throw new Error(`未知 preset 动作:${action}(list / set / remove / seed / set-group / remove-group)`);
2082
2884
  console.log(JSON.stringify(out, null, 2));
2083
2885
  return 0;
2084
2886
  }
@@ -2097,7 +2899,76 @@ async function main(argv) {
2097
2899
  ...flagStr(flags, "parent") !== void 0 ? { parent: flagStr(flags, "parent") } : {},
2098
2900
  ...flagNum(flags, "index") !== void 0 ? { index: flagNum(flags, "index") } : {}
2099
2901
  });
2100
- } else throw new Error(`未知 component 动作:${action}(list / insert)`);
2902
+ } else if (action === "add") {
2903
+ if (!a1 || !a2) throw new Error("用法:paintress component add <file> <id> [--name 名称] [--parent p] [--index n]");
2904
+ out = await call("paintress_component", {
2905
+ action: "add",
2906
+ path: a1,
2907
+ id: a2,
2908
+ ...flagStr(flags, "name") !== void 0 ? { name: flagStr(flags, "name") } : {},
2909
+ ...flagStr(flags, "parent") !== void 0 ? { parent: flagStr(flags, "parent") } : {},
2910
+ ...flagNum(flags, "index") !== void 0 ? { index: flagNum(flags, "index") } : {}
2911
+ });
2912
+ } else throw new Error(`未知 component 动作:${action}(list / insert / add)`);
2913
+ console.log(JSON.stringify(out, null, 2));
2914
+ return 0;
2915
+ }
2916
+ if (cmd === "font") {
2917
+ const action = file ?? "list";
2918
+ if (action === "list") out = await call("paintress_font", {
2919
+ action: "list",
2920
+ ...a1 !== void 0 ? { path: a1 } : {}
2921
+ });
2922
+ else if (action === "remove") {
2923
+ if (!a1 || !a2) throw new Error("用法:paintress font remove <dir> <family>");
2924
+ out = await call("paintress_font", {
2925
+ action: "remove",
2926
+ path: a1,
2927
+ family: a2
2928
+ });
2929
+ } else if (action === "default") {
2930
+ if (!a1 || !a2) throw new Error("用法:paintress font default <dir> <token-or-family>");
2931
+ out = await call("paintress_font", {
2932
+ action: "default",
2933
+ path: a1,
2934
+ value: a2
2935
+ });
2936
+ } else if (action === "add") {
2937
+ 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%\"}']");
2938
+ const metricsRaw = flagStr(flags, "metrics");
2939
+ let metrics;
2940
+ if (metricsRaw !== void 0) try {
2941
+ metrics = JSON.parse(metricsRaw);
2942
+ } catch {
2943
+ throw new Error(`metrics 不是合法 JSON:${metricsRaw}`);
2944
+ }
2945
+ const preview = flagStr(flags, "preview");
2946
+ const note = flagStr(flags, "note");
2947
+ out = await call("paintress_font", {
2948
+ action: "add",
2949
+ path: a1,
2950
+ family: a2,
2951
+ kind: positionals[3],
2952
+ ...positionals[4] !== void 0 ? { source: positionals[4] } : {},
2953
+ ...preview !== void 0 ? { preview } : {},
2954
+ ...note !== void 0 ? { note } : {},
2955
+ ...metrics !== void 0 ? { metrics } : {}
2956
+ });
2957
+ } else throw new Error(`未知 font 动作:${action}(list / add / remove / default)`);
2958
+ console.log(JSON.stringify(out, null, 2));
2959
+ return 0;
2960
+ }
2961
+ if (cmd === "project") {
2962
+ out = await call("paintress_project", { ...file !== void 0 ? { path: file } : {} });
2963
+ console.log(JSON.stringify(out, null, 2));
2964
+ return 0;
2965
+ }
2966
+ if (cmd === "convert") {
2967
+ out = await call("paintress_convert", {
2968
+ path: file,
2969
+ ...a1 !== void 0 ? { out: a1 } : {},
2970
+ ...flags["force"] === true ? { overwrite: true } : {}
2971
+ });
2101
2972
  console.log(JSON.stringify(out, null, 2));
2102
2973
  return 0;
2103
2974
  }
@@ -2191,9 +3062,36 @@ async function main(argv) {
2191
3062
  if (!a1) throw new Error("缺少 <out.html> 参数");
2192
3063
  out = await call("paintress_render", {
2193
3064
  path: file,
2194
- out: a1
3065
+ out: a1,
3066
+ ...flags["embed-fonts"] === true ? { embedFonts: true } : {}
3067
+ });
3068
+ break;
3069
+ case "audit":
3070
+ out = await call("paintress_audit", { path: file });
3071
+ break;
3072
+ case "establish":
3073
+ out = await call("paintress_establish", {
3074
+ path: file,
3075
+ ...flags["apply"] === true ? { apply: true } : {},
3076
+ ...flagNum(flags, "min-uses") !== void 0 ? { minUses: flagNum(flags, "min-uses") } : {}
2195
3077
  });
2196
3078
  break;
3079
+ case "adjust": {
3080
+ const key = flagStr(flags, "key");
3081
+ const group = flagStr(flags, "group");
3082
+ const prop = flagStr(flags, "prop");
3083
+ const value = flagStr(flags, "value");
3084
+ if (value === void 0) throw new Error("adjust 需要 --value");
3085
+ out = await call("paintress_adjust", {
3086
+ path: file,
3087
+ value,
3088
+ ...key !== void 0 ? { key } : {},
3089
+ ...group !== void 0 ? { group } : {},
3090
+ ...prop !== void 0 ? { prop } : {},
3091
+ ...flags["apply"] === true ? { apply: true } : {}
3092
+ });
3093
+ break;
3094
+ }
2197
3095
  case "shot": {
2198
3096
  if (!a1) throw new Error("缺少 <out.png> 参数");
2199
3097
  if (flags["board"] === true) throw new Error("--board 需要画板 id 或 name");
@@ -2201,7 +3099,7 @@ async function main(argv) {
2201
3099
  out = await call("paintress_screenshot", {
2202
3100
  path: file,
2203
3101
  out: a1,
2204
- ...flagNum(flags, "scale") !== void 0 ? { scale: flagNum(flags, "scale") } : {},
3102
+ ...flagDecimal(flags, "scale") !== void 0 ? { scale: flagDecimal(flags, "scale") } : {},
2205
3103
  ...board !== void 0 ? { board } : {}
2206
3104
  });
2207
3105
  break;