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