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/README.md +32 -14
- package/README.zh-CN.md +29 -13
- package/dist/cli.js +467 -83
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-CzpO1cOe.js → render-BSf62388.js} +268 -96
- package/dist/server-DZRNNWac.js +1599 -0
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +45 -17
- package/ui/dist/assets/index-BFcvu7wr.css +1 -0
- package/ui/dist/assets/index-CEhK1JXE.js +8 -0
- package/ui/dist/assets/marked.esm-DZCVybOJ.js +72 -0
- package/ui/dist/index.html +2 -2
- package/dist/server-vPylVJZf.js +0 -854
- package/ui/dist/assets/index-Dyrrkh92.css +0 -1
- package/ui/dist/assets/index-kPX0SDa0.js +0 -8
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
3
|
-
import { T as loadDoc, qt as VERSION, t as renderDocument } from "./render-
|
|
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()))
|
|
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
|
|
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
|
|
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().
|
|
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 ??
|
|
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" &&
|
|
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
|
|
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
|
|
984
|
-
|
|
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 =
|
|
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/${
|
|
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] === "
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
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
|
|
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="
|
|
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 <name> <dir></code> 注册。</p>")));
|
|
1059
1327
|
return;
|
|
1060
1328
|
}
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
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 (
|
|
1336
|
+
if (hit.rest.length === 0) {
|
|
1069
1337
|
const files = registry.listDesigns(entry);
|
|
1070
|
-
const list = files.map((f) => `<li><a href="/render/${
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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 (
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
client
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
|
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
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
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
|
|
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");
|