paintress 0.5.1 → 0.6.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,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { a as isDaemonRunning, c as pidPath, d as sanitizeProjectName, f as socketPath, i as attachPersistent, l as resolveProjectDir, m as encodeLine, n as runStdioServer, o as sendRequest, p as LineDecoder, r as TOOLS, s as lockPath, u as safeJoin } from "./server-vPylVJZf.js";
3
- import { T as loadDoc, qt as VERSION, t as renderDocument } from "./render-CzpO1cOe.js";
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";
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
6
  import { homedir } from "node:os";
@@ -35,6 +35,7 @@ var Registry = class {
35
35
  }
36
36
  const entry = {
37
37
  name,
38
+ label: name,
38
39
  root,
39
40
  rootReal,
40
41
  depth: wanted,
@@ -91,7 +92,9 @@ var Registry = class {
91
92
  if (st.isDirectory()) {
92
93
  if (skip.has(name)) continue;
93
94
  walk(abs, relPath, depth + 1);
94
- } else if (DESIGN_EXT.has(extname(name).toLowerCase())) out.push(relPath);
95
+ } else if (DESIGN_EXT.has(extname(name).toLowerCase())) {
96
+ if (!name.startsWith("__")) out.push(relPath);
97
+ }
95
98
  }
96
99
  };
97
100
  walk(entry.rootReal, "", 0);
@@ -100,6 +103,7 @@ var Registry = class {
100
103
  info(entry) {
101
104
  return {
102
105
  name: entry.name,
106
+ label: entry.label,
103
107
  root: entry.root,
104
108
  depth: entry.depth,
105
109
  fileCount: this.listDesigns(entry).length,
@@ -270,6 +274,9 @@ var WatchManager = class {
270
274
  watcher.on("change", () => {
271
275
  for (const cb of created.listeners) cb(absPath);
272
276
  });
277
+ watcher.on("add", () => {
278
+ for (const cb of created.listeners) cb(absPath);
279
+ });
273
280
  watcher.on("unlink", () => {
274
281
  for (const cb of created.listeners) cb(absPath);
275
282
  });
@@ -427,6 +434,23 @@ function findUiDist() {
427
434
  for (const c of candidates) if (existsSync(join(c, "index.html"))) return c;
428
435
  return null;
429
436
  }
437
+ /**
438
+ * 从 URL 路径段里按最长前缀解析项目。
439
+ * 项目 key 现在是目录层级本身(`c-thun/raybend`),所以 `/c-thun/raybend`、
440
+ * `/render/c-thun/raybend/design/editor.pen` 都能在不需要 %2F 转义的情况下命中。
441
+ * 单段名(`paintress watch myname dir`)同样适用。
442
+ */
443
+ function resolveProjectPrefix(registry, segments, maxDepth = 3) {
444
+ for (let n = Math.min(maxDepth, segments.length); n >= 1; n--) {
445
+ const key = segments.slice(0, n).map((s) => decodeURIComponent(s)).join("/");
446
+ const entry = registry.get(key);
447
+ if (entry) return {
448
+ entry,
449
+ rest: segments.slice(n)
450
+ };
451
+ }
452
+ return null;
453
+ }
430
454
  function isAlive(pid) {
431
455
  if (!Number.isInteger(pid) || pid <= 0) return false;
432
456
  try {
@@ -457,7 +481,7 @@ function acquireLock() {
457
481
  } catch {
458
482
  holder = 0;
459
483
  }
460
- if (isAlive(holder)) throw new Error(`paintress web 已在运行(pid ${holder})。如需重启请先 paintress web --stop`);
484
+ if (isAlive(holder)) throw new Error(`paintress web 已在运行(pid ${holder})。如需重启请先 paintress stop`);
461
485
  try {
462
486
  unlinkSync(p);
463
487
  } catch {}
@@ -512,9 +536,18 @@ async function startDaemon(opts = {}) {
512
536
  const versions = new VersionChecker();
513
537
  const startedAt = Date.now();
514
538
  const idleTimeoutMs = opts.idleTimeoutMs ?? 6e4;
515
- const idleCheckMs = opts.idleCheckMs ?? 15e3;
539
+ const emptyGraceMs = opts.emptyGraceMs ?? Math.min(idleTimeoutMs, 3e3);
540
+ const emptyBrowserMs = opts.emptyBrowserMs ?? 3e4;
541
+ const manualEmptyMs = opts.manualEmptyMs ?? 6e5;
542
+ /** 本次启动有没有过项目:决定「空仓 + 浏览器开着」是等接入还是收工 */
543
+ let everHadProjects = false;
544
+ const idleCheckMs = opts.idleCheckMs ?? 2e3;
516
545
  let idleSince = null;
517
546
  let closing = false;
547
+ /** 进行中的关停 Promise:close() 必须等它,不能立刻返回 */
548
+ let closingPromise = null;
549
+ /** 最近一次 HTTP 活动:用来判定「浏览器还在不在」而不单靠 SSE 关闭事件 */
550
+ let lastHttpAt = Date.now();
518
551
  /**
519
552
  * 注册表订阅者(浏览器开着的查看器窗口)。
520
553
  * 项目列表变化时推 `projects` 事件,避免用户按 F5 才看到新项目。
@@ -522,11 +555,20 @@ async function startDaemon(opts = {}) {
522
555
  const registryClients = /* @__PURE__ */ new Set();
523
556
  /** 控制面长连接:shutdown 时必须主动结束,否则 server.close() 会一直等 agent attach */
524
557
  const controlClients = /* @__PURE__ */ new Set();
558
+ /** agent attach 总数 / 浏览器连接总数(注册表 SSE + 设计稿 SSE) */
559
+ const agentCount = () => registry.all().reduce((n, p) => n + p.agents.size, 0);
560
+ const browserCount = () => registryClients.size + registry.all().reduce((n, p) => n + p.browsers.size, 0);
525
561
  /** 将被关闭的长连接数:agent attach + 设计稿 SSE + 项目列表 SSE */
526
562
  const connectionCount = () => registryClients.size + registry.all().reduce((n, p) => n + p.agents.size + p.browsers.size, 0);
563
+ /**
564
+ * 推注册表快照。事件带全量项目信息:前端不用再拉一次接口,项目增删能同一帧完成,
565
+ * 动画也因此不会晚一步。列表很小(每个项目只扫一次 shallow 目录),频繁推不心疼。
566
+ */
527
567
  const notifyRegistry = () => {
528
568
  const payload = JSON.stringify({
529
- projects: registry.all().length,
569
+ projects: registry.all().map((p) => registry.info(p)),
570
+ agents: agentCount(),
571
+ browsers: browserCount(),
530
572
  at: Date.now()
531
573
  });
532
574
  for (const res of registryClients) try {
@@ -555,7 +597,7 @@ async function startDaemon(opts = {}) {
555
597
  * - 浏览器 SSE 开着时永不退场
556
598
  * - 人手动 watch 的项目(owner=manual)不参与自动退场
557
599
  */
558
- const autoDetachMs = opts.autoDetachMs ?? 45e3;
600
+ const autoDetachMs = opts.autoDetachMs ?? 5e3;
559
601
  const autoDetachTimers = /* @__PURE__ */ new Map();
560
602
  function cancelAutoDetach(name) {
561
603
  const timer = autoDetachTimers.get(name);
@@ -627,6 +669,7 @@ async function startDaemon(opts = {}) {
627
669
  ok: true,
628
670
  pong: true,
629
671
  pid: process.pid,
672
+ port,
630
673
  uptimeMs: Date.now() - startedAt,
631
674
  projects: registry.all().length
632
675
  });
@@ -641,9 +684,16 @@ async function startDaemon(opts = {}) {
641
684
  });
642
685
  break;
643
686
  case "watch": {
687
+ everHadProjects = true;
644
688
  const entry = registry.watch(req.project, req.dir, req.depth, req.owner === "agent" ? { owner: "agent" } : {});
645
689
  cancelAutoDetach(entry.name);
646
690
  watchers.watchDesigns(entry.rootReal, entry.depth, scheduleRegistryNotify);
691
+ try {
692
+ const created = ensureProjectScaffold(entry.rootReal).created;
693
+ if (created.length > 0 && !opts.quiet) process.stderr.write(`paintress web: 项目 ${entry.name} 初始化 ${created.map((f) => relative(entry.rootReal, f)).join("、")}\n`);
694
+ } catch (cause) {
695
+ process.stderr.write(`paintress web: 项目脚手架创建失败(${cause instanceof Error ? cause.message : String(cause)})\n`);
696
+ }
647
697
  reply({
648
698
  ok: true,
649
699
  watched: true,
@@ -676,6 +726,7 @@ async function startDaemon(opts = {}) {
676
726
  attached: true,
677
727
  project: req.project
678
728
  });
729
+ notifyRegistry();
679
730
  break;
680
731
  }
681
732
  case "detach":
@@ -747,6 +798,7 @@ async function startDaemon(opts = {}) {
747
798
  const sockServer = createServer(handleClient);
748
799
  const uiDist = findUiDist();
749
800
  const httpServer = createServer$1(async (req, res) => {
801
+ lastHttpAt = Date.now();
750
802
  const url = new URL(req.url ?? "/", "http://localhost");
751
803
  const segments = url.pathname.split("/").filter((s) => s !== "");
752
804
  const send = (code, body, type = "text/html; charset=utf-8") => {
@@ -793,7 +845,7 @@ async function startDaemon(opts = {}) {
793
845
  });
794
846
  return;
795
847
  }
796
- if (segments[1] === "projects" && segments.length === 3 && req.method === "DELETE") {
848
+ if (segments[1] === "projects" && req.method === "DELETE") {
797
849
  if (!isTrustedWrite(req)) {
798
850
  sendJson(403, {
799
851
  ok: false,
@@ -920,6 +972,190 @@ async function startDaemon(opts = {}) {
920
972
  });
921
973
  return;
922
974
  }
975
+ if (segments[1] === "notes" && req.method === "GET") {
976
+ const name = url.searchParams.get("project") ?? "";
977
+ const relPath = url.searchParams.get("path") ?? "";
978
+ const entry = registry.get(name);
979
+ if (!entry) {
980
+ sendJson(404, {
981
+ ok: false,
982
+ error: `未注册的项目:${name}`
983
+ });
984
+ return;
985
+ }
986
+ const abs = safeJoin(entry.rootReal, relPath);
987
+ const noteAbs = abs === null ? null : notePathFor(abs);
988
+ if (abs === null || noteAbs === null) {
989
+ sendJson(404, {
990
+ ok: false,
991
+ error: "文件不存在"
992
+ });
993
+ return;
994
+ }
995
+ let markdown;
996
+ try {
997
+ markdown = readTextIfExists(noteAbs);
998
+ } catch (cause) {
999
+ sendJson(413, {
1000
+ ok: false,
1001
+ error: cause instanceof Error ? cause.message : String(cause)
1002
+ });
1003
+ return;
1004
+ }
1005
+ if (markdown === null) {
1006
+ sendJson(200, {
1007
+ ok: true,
1008
+ exists: false
1009
+ });
1010
+ return;
1011
+ }
1012
+ sendJson(200, {
1013
+ ok: true,
1014
+ exists: true,
1015
+ path: relative(entry.rootReal, noteAbs).split(sep).join("/"),
1016
+ markdown
1017
+ });
1018
+ return;
1019
+ }
1020
+ if (segments[1] === "presets") {
1021
+ const qProject = url.searchParams.get("project") ?? "";
1022
+ if (req.method === "GET") {
1023
+ const entry = registry.get(qProject);
1024
+ if (!entry) {
1025
+ sendJson(404, {
1026
+ ok: false,
1027
+ error: `未注册的项目:${qProject}`
1028
+ });
1029
+ return;
1030
+ }
1031
+ const { path, items } = listPresets(entry.rootReal);
1032
+ sendJson(200, {
1033
+ ok: true,
1034
+ path,
1035
+ items
1036
+ });
1037
+ return;
1038
+ }
1039
+ if (req.method === "PUT" || req.method === "POST") {
1040
+ if (!isTrustedWrite(req)) {
1041
+ sendJson(403, {
1042
+ ok: false,
1043
+ error: "缺少 x-paintress 头"
1044
+ });
1045
+ return;
1046
+ }
1047
+ const raw = await readBody();
1048
+ let body;
1049
+ try {
1050
+ body = JSON.parse(raw);
1051
+ } catch {
1052
+ sendJson(400, {
1053
+ ok: false,
1054
+ error: "请求体不是合法 JSON"
1055
+ });
1056
+ return;
1057
+ }
1058
+ const name = qProject !== "" ? qProject : typeof body["project"] === "string" ? body["project"] : "";
1059
+ const entry = registry.get(name);
1060
+ if (!entry) {
1061
+ sendJson(404, {
1062
+ ok: false,
1063
+ error: `未注册的项目:${name}`
1064
+ });
1065
+ return;
1066
+ }
1067
+ const key = typeof body["key"] === "string" ? body["key"] : "";
1068
+ const value = body["value"];
1069
+ if (key === "" || typeof value !== "string" && typeof value !== "number") {
1070
+ sendJson(400, {
1071
+ ok: false,
1072
+ error: "缺少 key 或 value"
1073
+ });
1074
+ return;
1075
+ }
1076
+ const existing = listPresets(entry.rootReal).items.find((p) => p.key === key);
1077
+ const typeRaw = body["type"] ?? existing?.type;
1078
+ if (!isPresetType(typeRaw)) {
1079
+ sendJson(400, {
1080
+ ok: false,
1081
+ error: "type 必须是 color 或 radius"
1082
+ });
1083
+ return;
1084
+ }
1085
+ const label = typeof body["name"] === "string" && body["name"] !== "" ? body["name"] : void 0;
1086
+ try {
1087
+ const items = setPreset(entry.rootReal, {
1088
+ key,
1089
+ type: typeRaw,
1090
+ value: String(value),
1091
+ ...label !== void 0 ? { name: label } : {}
1092
+ });
1093
+ notifyRegistry();
1094
+ sendJson(200, {
1095
+ ok: true,
1096
+ path: presetPathFor(entry.rootReal),
1097
+ items
1098
+ });
1099
+ } catch (cause) {
1100
+ sendJson(400, {
1101
+ ok: false,
1102
+ error: cause instanceof Error ? cause.message : String(cause)
1103
+ });
1104
+ }
1105
+ return;
1106
+ }
1107
+ sendJson(405, {
1108
+ ok: false,
1109
+ error: `不支持的方法:${req.method ?? "?"}`
1110
+ });
1111
+ return;
1112
+ }
1113
+ if (segments[1] === "components" && req.method === "GET") {
1114
+ const name = url.searchParams.get("project") ?? "";
1115
+ const entry = registry.get(name);
1116
+ if (!entry) {
1117
+ sendJson(404, {
1118
+ ok: false,
1119
+ error: `未注册的项目:${name}`
1120
+ });
1121
+ return;
1122
+ }
1123
+ const compPath = componentsPathFor(entry.rootReal);
1124
+ if (compPath === null) {
1125
+ sendJson(200, {
1126
+ ok: true,
1127
+ path: null,
1128
+ items: []
1129
+ });
1130
+ return;
1131
+ }
1132
+ const doc = applyProjectPresets(loadDoc(compPath).doc, compPath).doc;
1133
+ sendJson(200, {
1134
+ ok: true,
1135
+ path: compPath,
1136
+ items: listComponents(compPath).map((c) => {
1137
+ const node = findNodeIn(doc.children, c.id);
1138
+ if (node === null) return c;
1139
+ try {
1140
+ const single = renderDocument({
1141
+ ...doc,
1142
+ children: [node]
1143
+ }, {
1144
+ title: c.id,
1145
+ transparent: true
1146
+ });
1147
+ return {
1148
+ ...c,
1149
+ width: Math.round(single.canvas.width),
1150
+ height: Math.round(single.canvas.height)
1151
+ };
1152
+ } catch {
1153
+ return c;
1154
+ }
1155
+ })
1156
+ });
1157
+ return;
1158
+ }
923
1159
  sendJson(404, {
924
1160
  ok: false,
925
1161
  error: `未知 API:${url.pathname}`
@@ -963,16 +1199,20 @@ async function startDaemon(opts = {}) {
963
1199
  const token = Symbol("browser");
964
1200
  entry.browsers.add(token);
965
1201
  cancelAutoDetach(entry.name);
966
- const release = watchers.acquire(abs, () => {
1202
+ const notifyChange = () => {
967
1203
  res.write(`event: change\ndata: ${JSON.stringify({
968
1204
  path: relPath,
969
1205
  at: Date.now()
970
1206
  })}\n\n`);
971
- });
1207
+ };
1208
+ const releases = [watchers.acquire(abs, notifyChange)];
1209
+ releases.push(watchers.acquire(abs.replace(/\.(ptr|pen)$/i, ".md"), notifyChange));
1210
+ const presetAbs = presetPathFor(entry.rootReal);
1211
+ if (presetAbs !== null) releases.push(watchers.acquire(presetAbs, notifyChange));
972
1212
  const keepAlive = setInterval(() => res.write(": ping\n\n"), 2e4);
973
1213
  const done = () => {
974
1214
  clearInterval(keepAlive);
975
- release();
1215
+ for (const release of releases) release();
976
1216
  entry.browsers.delete(token);
977
1217
  maybeScheduleAutoDetach(entry.name);
978
1218
  };
@@ -980,23 +1220,23 @@ async function startDaemon(opts = {}) {
980
1220
  return;
981
1221
  }
982
1222
  if (segments[0] === "render" && segments.length >= 3) {
983
- const name = decodeURIComponent(segments[1] ?? "");
984
- const entry = registry.get(name);
985
- if (!entry) {
1223
+ const hit = resolveProjectPrefix(registry, segments.slice(1));
1224
+ if (hit === null || hit.rest.length === 0) {
986
1225
  send(404, "project not found", "text/plain; charset=utf-8");
987
1226
  return;
988
1227
  }
1228
+ const { entry } = hit;
989
1229
  entry.lastAccess = Date.now();
990
- const relPath = segments.slice(2).map(decodeURIComponent).join("/");
1230
+ const relPath = hit.rest.map(decodeURIComponent).join("/");
991
1231
  const abs = safeJoin(entry.rootReal, relPath);
992
1232
  if (!abs) {
993
1233
  send(404, "file not found", "text/plain; charset=utf-8");
994
1234
  return;
995
1235
  }
996
1236
  const loaded = loadDoc(abs);
997
- const rendered = renderDocument(loaded.doc, { title: relPath });
1237
+ const rendered = renderDocument(applyProjectPresets(loaded.doc, abs).doc, { title: relPath });
998
1238
  const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "";
999
- const baseHref = `/files/${encodeURIComponent(entry.name)}/${dir.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${dir ? "/" : ""}`;
1239
+ const baseHref = `/files/${entry.name.split("/").map(encodeURIComponent).join("/")}/${dir.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${dir ? "/" : ""}`;
1000
1240
  let html = rendered.html.replace("<head>", `<head>\n<base href="${baseHref}">`);
1001
1241
  if (url.searchParams.get("embed") !== "1") {
1002
1242
  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>`;
@@ -1005,14 +1245,42 @@ async function startDaemon(opts = {}) {
1005
1245
  send(200, html);
1006
1246
  return;
1007
1247
  }
1008
- if (segments[0] === "files" && segments.length >= 3) {
1009
- const name = decodeURIComponent(segments[1] ?? "");
1010
- const entry = registry.get(name);
1248
+ if (segments[0] === "render-component") {
1249
+ const project = url.searchParams.get("project") ?? "";
1250
+ const id = url.searchParams.get("id") ?? "";
1251
+ const entry = registry.get(project);
1011
1252
  if (!entry) {
1012
1253
  send(404, "project not found", "text/plain; charset=utf-8");
1013
1254
  return;
1014
1255
  }
1015
- const relPath = segments.slice(2).map(decodeURIComponent).join("/");
1256
+ const compPath = componentsPathFor(entry.rootReal);
1257
+ if (compPath === null || id === "") {
1258
+ send(404, "component not found", "text/plain; charset=utf-8");
1259
+ return;
1260
+ }
1261
+ const doc = applyProjectPresets(loadDoc(compPath).doc, compPath).doc;
1262
+ const node = findNodeIn(doc.children, id);
1263
+ if (node === null) {
1264
+ send(404, "component not found", "text/plain; charset=utf-8");
1265
+ return;
1266
+ }
1267
+ send(200, renderDocument({
1268
+ ...doc,
1269
+ children: [node]
1270
+ }, {
1271
+ title: id,
1272
+ transparent: true
1273
+ }).html);
1274
+ return;
1275
+ }
1276
+ if (segments[0] === "files" && segments.length >= 3) {
1277
+ const hit = resolveProjectPrefix(registry, segments.slice(1));
1278
+ if (hit === null) {
1279
+ send(404, "project not found", "text/plain; charset=utf-8");
1280
+ return;
1281
+ }
1282
+ const { entry } = hit;
1283
+ const relPath = hit.rest.map(decodeURIComponent).join("/");
1016
1284
  const abs = safeJoin(entry.rootReal, relPath);
1017
1285
  if (!abs) {
1018
1286
  send(404, "file not found", "text/plain; charset=utf-8");
@@ -1053,21 +1321,21 @@ async function startDaemon(opts = {}) {
1053
1321
  if (segments.length === 0) {
1054
1322
  const items = registry.all().map((p) => {
1055
1323
  const info = registry.info(p);
1056
- return `<div class="card"><h2 style="margin:0 0 4px"><a href="/${encodeURIComponent(p.name)}">${esc(p.name)}</a></h2><div class="meta">${esc(info.root)} · ${info.fileCount} 个设计稿 · agent ${info.agentConns} · 浏览器 ${info.browserConns}</div></div>`;
1324
+ 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>`;
1057
1325
  }).join("");
1058
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>")));
1059
1327
  return;
1060
1328
  }
1061
- const projectName = decodeURIComponent(segments[0] ?? "");
1062
- const entry = registry.get(projectName);
1063
- if (!entry) {
1064
- send(404, layout("未找到", `<h1>404</h1><p>未注册的项目:<code>${esc(projectName)}</code></p><p><a href="/">返回</a></p>`));
1329
+ const hit = resolveProjectPrefix(registry, segments);
1330
+ if (hit === null) {
1331
+ send(404, layout("未找到", `<h1>404</h1><p>未注册的项目:<code>${esc(segments.join("/"))}</code></p><p><a href="/">返回</a></p>`));
1065
1332
  return;
1066
1333
  }
1334
+ const { entry } = hit;
1067
1335
  entry.lastAccess = Date.now();
1068
- if (segments.length === 1) {
1336
+ if (hit.rest.length === 0) {
1069
1337
  const files = registry.listDesigns(entry);
1070
- const list = files.map((f) => `<li><a href="/render/${encodeURIComponent(entry.name)}/${f.split("/").map(encodeURIComponent).join("/")}">${esc(f)}</a></li>`).join("");
1338
+ const list = files.map((f) => `<li><a href="/render/${entry.name.split("/").map(encodeURIComponent).join("/")}/${f.split("/").map(encodeURIComponent).join("/")}">${esc(f)}</a></li>`).join("");
1071
1339
  send(200, layout(entry.name, `<h1>${esc(entry.name)}</h1><div class="meta">${esc(entry.root)} · depth ${entry.depth}</div><h2>设计稿(${files.length})</h2>` + (files.length ? `<ul>${list}</ul>` : "<p class=\"empty\">目录下没有 .ptr / .pen 文件</p>") + "<p><a href=\"/\">← 项目列表</a></p>"));
1072
1340
  return;
1073
1341
  }
@@ -1096,37 +1364,76 @@ async function startDaemon(opts = {}) {
1096
1364
  resolve();
1097
1365
  });
1098
1366
  });
1367
+ /**
1368
+ * 生命周期策略:控制权在 agent 侧,人不需要管进程在不在。
1369
+ *
1370
+ * - 有 agent attach → 永远活着(agent 正在干活)。
1371
+ * - 没有 agent,但浏览器开着:
1372
+ * · 还有项目 → 活着;项目归零 → 给前端 10s 倒计时自己走(服务端 emptyBrowserMs 兜底)。
1373
+ * - 没有 agent 也没有浏览器:
1374
+ * · 还有手动 watch 的项目 → idleTimeoutMs 后关;
1375
+ * · 一个项目都没有 → 立刻(emptyGraceMs)关。
1376
+ *
1377
+ * 修的是旧逻辑的真 bug:旧代码只在「项目全部 idle」时计数,自动拉起的 daemon
1378
+ * 又写死 `--idle 1h`,于是浏览器早就关了、进程还能挂一小时。
1379
+ */
1099
1380
  const idleTimer = setInterval(() => {
1100
- if (registry.all().length === 0) idleSince = idleSince ?? Date.now();
1101
- else if (registry.all().every((p) => registry.isIdle(p))) idleSince = idleSince ?? Date.now();
1102
- else {
1381
+ const now = Date.now();
1382
+ const projects = registry.all().length;
1383
+ const agents = agentCount();
1384
+ const browsing = browserCount() > 0 && now - lastHttpAt < 3e4;
1385
+ let threshold;
1386
+ if (agents > 0) {
1103
1387
  idleSince = null;
1388
+ changes.prune();
1104
1389
  return;
1105
1390
  }
1106
- if (idleSince !== null && Date.now() - idleSince >= idleTimeoutMs) shutdown("idle");
1391
+ if (browsing) {
1392
+ if (projects > 0) {
1393
+ idleSince = null;
1394
+ changes.prune();
1395
+ return;
1396
+ }
1397
+ threshold = everHadProjects ? emptyBrowserMs : manualEmptyMs;
1398
+ } else threshold = projects > 0 ? idleTimeoutMs : emptyGraceMs;
1399
+ idleSince = idleSince ?? now;
1400
+ if (now - idleSince >= threshold) shutdown(projects > 0 ? "idle" : "无项目");
1107
1401
  changes.prune();
1108
1402
  }, idleCheckMs);
1109
1403
  idleTimer.unref();
1110
- async function shutdown(reason) {
1111
- if (closing) return;
1404
+ /**
1405
+ * 关停:幂等且**可等待**。
1406
+ * 已经在关停中时必须返回同一个 Promise —— 否则 `close()` 会立刻 resolve,
1407
+ * 调用方以为锁已释放,紧接着 `startDaemon` 就撞上「已在运行」。
1408
+ * (v0.6.0 的 release 测试里就是这么连环挂的:idle 关停还在飞,close() 先返回了。)
1409
+ */
1410
+ function shutdown(reason) {
1411
+ if (closingPromise !== null) return closingPromise;
1112
1412
  closing = true;
1113
- if (!opts.quiet) process.stderr.write(`paintress web: 退出(${reason})\n`);
1114
- clearInterval(idleTimer);
1115
- if (registryTimer !== null) clearTimeout(registryTimer);
1116
- for (const timer of autoDetachTimers.values()) clearTimeout(timer);
1117
- autoDetachTimers.clear();
1118
- for (const client of registryClients) try {
1119
- client.end();
1120
- } catch {}
1121
- registryClients.clear();
1122
- for (const client of controlClients) client.end();
1123
- controlClients.clear();
1124
- await watchers.closeAll();
1125
- await new Promise((resolve) => sockServer.close(() => resolve()));
1126
- const httpClosed = new Promise((resolve) => httpServer.close(() => resolve()));
1127
- httpServer.closeAllConnections();
1128
- await httpClosed;
1129
- releaseLock();
1413
+ closingPromise = (async () => {
1414
+ if (!opts.quiet) process.stderr.write(`paintress web: 退出(${reason})\n`);
1415
+ clearInterval(idleTimer);
1416
+ if (registryTimer !== null) clearTimeout(registryTimer);
1417
+ for (const timer of autoDetachTimers.values()) clearTimeout(timer);
1418
+ autoDetachTimers.clear();
1419
+ for (const client of registryClients) try {
1420
+ client.write(`event: shutdown\ndata: ${JSON.stringify({
1421
+ reason,
1422
+ at: Date.now()
1423
+ })}\n\n`);
1424
+ client.end();
1425
+ } catch {}
1426
+ registryClients.clear();
1427
+ for (const client of controlClients) client.end();
1428
+ controlClients.clear();
1429
+ await watchers.closeAll();
1430
+ await new Promise((resolve) => sockServer.close(() => resolve()));
1431
+ const httpClosed = new Promise((resolve) => httpServer.close(() => resolve()));
1432
+ httpServer.closeAllConnections();
1433
+ await httpClosed;
1434
+ releaseLock();
1435
+ })();
1436
+ return closingPromise;
1130
1437
  }
1131
1438
  return {
1132
1439
  port,
@@ -1423,6 +1730,7 @@ function readConfig(path) {
1423
1730
  return existsSync(path) ? readFileSync(path, "utf8") : null;
1424
1731
  }
1425
1732
  // src/cli.ts
1733
+ const NO_OPEN_FLAG = "no-open";
1426
1734
  const FLAG_KEYS = /* @__PURE__ */ new Set([
1427
1735
  "type",
1428
1736
  "name",
@@ -1444,14 +1752,17 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
1444
1752
  "padding",
1445
1753
  "gap",
1446
1754
  "force",
1447
- "board"
1755
+ "board",
1756
+ "parent",
1757
+ NO_OPEN_FLAG
1448
1758
  ]);
1449
1759
  const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
1450
1760
  "regex",
1451
1761
  "reusable",
1452
1762
  "daemon",
1453
1763
  "dry-run",
1454
- "force"
1764
+ "force",
1765
+ NO_OPEN_FLAG
1455
1766
  ]);
1456
1767
  /** 纯函数:argv → 命令 + 位置参数 + 标志(可单测) */
1457
1768
  function parseArgv(argv) {
@@ -1556,10 +1867,11 @@ async function main(argv) {
1556
1867
  if (cmd === "watch") {
1557
1868
  if (!file || !a1) throw new Error("用法:paintress watch <name> <dir> [--depth 1..6]");
1558
1869
  const depth = flagNum(flags, "depth");
1870
+ const dir = resolve(a1);
1559
1871
  console.log(JSON.stringify(await sendRequest({
1560
1872
  kind: "watch",
1561
1873
  project: file,
1562
- dir: a1,
1874
+ dir,
1563
1875
  ...depth !== void 0 ? { depth } : {}
1564
1876
  }, 8e3), null, 2));
1565
1877
  return 0;
@@ -1674,50 +1986,122 @@ async function main(argv) {
1674
1986
  if (cmd === "web") {
1675
1987
  const port = flagNum(flags, "port");
1676
1988
  const idle = flagNum(flags, "idle");
1989
+ 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);
1992
+ if (running !== null) {
1993
+ console.log(JSON.stringify({
1994
+ ok: true,
1995
+ daemon: true,
1996
+ reused: true,
1997
+ pid: running.pid,
1998
+ port: running.port,
1999
+ http: url(running.port)
2000
+ }, null, 2));
2001
+ return 0;
2002
+ }
1677
2003
  if (flags["daemon"] === true) {
1678
- const { spawn } = await import("node:child_process");
1679
- const args = [process.argv[1] ?? "", "web"];
1680
- if (port !== void 0) args.push("--port", String(port));
1681
- if (idle !== void 0) args.push("--idle", String(idle));
1682
- const child = spawn(process.execPath, args, {
1683
- detached: true,
1684
- stdio: "ignore"
2004
+ const res = await ensureDaemon({
2005
+ ...port !== void 0 ? { port } : {},
2006
+ ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
2007
+ noOpen
1685
2008
  });
1686
- child.unref();
1687
- const deadline = Date.now() + 5e3;
1688
- while (Date.now() < deadline) {
1689
- if (isDaemonRunning()) {
1690
- const res = await sendRequest({ kind: "ping" });
1691
- console.log(JSON.stringify({
1692
- daemon: true,
1693
- launcherPid: child.pid,
1694
- ...res
1695
- }, null, 2));
1696
- return 0;
1697
- }
1698
- await new Promise((r) => setTimeout(r, 100));
1699
- }
1700
- throw new Error("后台启动超时(5s 内未出现控制面 socket)");
2009
+ console.log(JSON.stringify({
2010
+ ok: true,
2011
+ daemon: true,
2012
+ reused: !res.started,
2013
+ pid: res.pid,
2014
+ port: res.port,
2015
+ http: url(res.port)
2016
+ }, null, 2));
2017
+ return 0;
1701
2018
  }
1702
- const handle = await startDaemon({
1703
- ...port !== void 0 ? { port } : {},
1704
- ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
1705
- updateCheck: true
1706
- });
2019
+ let handle;
2020
+ try {
2021
+ handle = await startDaemon({
2022
+ ...port !== void 0 ? { port } : {},
2023
+ ...idle !== void 0 ? { idleTimeoutMs: idle } : {},
2024
+ emptyGraceMs: 6e4,
2025
+ updateCheck: true
2026
+ });
2027
+ } catch (cause) {
2028
+ const again = await queryDaemon();
2029
+ if (again === null) throw cause;
2030
+ console.log(JSON.stringify({
2031
+ ok: true,
2032
+ daemon: true,
2033
+ reused: true,
2034
+ pid: again.pid,
2035
+ port: again.port,
2036
+ http: url(again.port)
2037
+ }, null, 2));
2038
+ return 0;
2039
+ }
2040
+ const http = url(handle.port);
1707
2041
  console.log(JSON.stringify({
1708
2042
  ok: true,
1709
2043
  pid: handle.pid,
1710
2044
  port: handle.port,
1711
- http: `http://localhost:${handle.port}`
2045
+ http
1712
2046
  }, null, 2));
2047
+ if (!noOpen) {
2048
+ const { openBrowser } = await import("./server-DZRNNWac.js").then((n) => n.i);
2049
+ openBrowser(`${http}/`);
2050
+ }
1713
2051
  await new Promise((resolve) => {
1714
2052
  process.once("SIGINT", () => void handle.close().then(() => resolve()));
1715
2053
  process.once("SIGTERM", () => void handle.close().then(() => resolve()));
1716
2054
  });
1717
2055
  return 0;
1718
2056
  }
1719
- if (!file) throw new Error("缺少 <file> 参数");
1720
2057
  let out;
2058
+ if (cmd === "preset") {
2059
+ const action = file ?? "list";
2060
+ if (action === "list") out = await call("paintress_preset", {
2061
+ action: "list",
2062
+ ...a1 !== void 0 ? { path: a1 } : {}
2063
+ });
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 名称]");
2066
+ out = await call("paintress_preset", {
2067
+ action: "set",
2068
+ path: a1,
2069
+ key: a2,
2070
+ type: positionals[3],
2071
+ value: positionals[4],
2072
+ ...flagStr(flags, "name") !== void 0 ? { name: flagStr(flags, "name") } : {}
2073
+ });
2074
+ } else if (action === "remove") {
2075
+ if (!a1 || !a2) throw new Error("用法:paintress preset remove <dir> <key>");
2076
+ out = await call("paintress_preset", {
2077
+ action: "remove",
2078
+ path: a1,
2079
+ key: a2
2080
+ });
2081
+ } else throw new Error(`未知 preset 动作:${action}(list / set / remove)`);
2082
+ console.log(JSON.stringify(out, null, 2));
2083
+ return 0;
2084
+ }
2085
+ if (cmd === "component") {
2086
+ const action = file ?? "list";
2087
+ if (action === "list") out = await call("paintress_component", {
2088
+ action: "list",
2089
+ ...a1 !== void 0 ? { path: a1 } : {}
2090
+ });
2091
+ else if (action === "insert") {
2092
+ if (!a1 || !a2) throw new Error("用法:paintress component insert <file> <id> [--parent p] [--index n]");
2093
+ out = await call("paintress_component", {
2094
+ action: "insert",
2095
+ path: a1,
2096
+ id: a2,
2097
+ ...flagStr(flags, "parent") !== void 0 ? { parent: flagStr(flags, "parent") } : {},
2098
+ ...flagNum(flags, "index") !== void 0 ? { index: flagNum(flags, "index") } : {}
2099
+ });
2100
+ } else throw new Error(`未知 component 动作:${action}(list / insert)`);
2101
+ console.log(JSON.stringify(out, null, 2));
2102
+ return 0;
2103
+ }
2104
+ if (!file) throw new Error("缺少 <file> 参数");
1721
2105
  switch (cmd) {
1722
2106
  case "new": {
1723
2107
  const name = flagStr(flags, "name");