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