paintress 0.7.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 +8 -4
- package/README.zh-CN.md +7 -3
- package/dist/cli.js +468 -31
- package/dist/mcp/server.js +1 -1
- package/dist/{server-DBRmwE_v.js → server-Buuoq3kq.js} +150 -32
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +4 -2
package/README.md
CHANGED
|
@@ -81,7 +81,8 @@ paintress component list|insert|add ... project component libra
|
|
|
81
81
|
paintress render <file> <out.html> write the canvas as HTML
|
|
82
82
|
paintress shot <file> <out.png> [--scale 0.2..2] [--board id|name] render to PNG (default 0.8)
|
|
83
83
|
|
|
84
|
-
paintress web [--port n] [--idle ms] [--foreground]
|
|
84
|
+
paintress web [--port n] [--bind auto|loopback|all] [--idle ms] [--foreground]
|
|
85
|
+
ensure the viewer is running (idempotent, returns at once)
|
|
85
86
|
paintress ping | status | stop viewer control
|
|
86
87
|
paintress watch <name> <dir> [--depth n] | unwatch <name> register a directory to browse
|
|
87
88
|
paintress attach <name> keep the viewer alive while an agent works
|
|
@@ -96,12 +97,14 @@ paintress mcp-config <client> print the config snippet
|
|
|
96
97
|
```bash
|
|
97
98
|
paintress web
|
|
98
99
|
paintress watch demo ./demo
|
|
99
|
-
# open http://localhost:
|
|
100
|
+
# open http://localhost:6033/demo
|
|
100
101
|
```
|
|
101
102
|
|
|
102
103
|
`paintress web` is idempotent and returns immediately: if a viewer is already running it prints that process's `pid` / `port` / `http`, otherwise it starts one and opens the viewer window on it. Either way the command is safe to run before every drawing session — there is never a second instance, and the window is opened by the program, in the desktop browser (`--foreground` keeps the process attached to your shell when you want its log; `paintress stop` is how you replace a running viewer, e.g. on another port). Under WSL the window lands in the Windows browser, never in a browser inside the distro.
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
The port is not a constant you should memorize: it is **chosen at startup and reported back**. The default is `6033`; if that port is unusable the viewer walks `6033 → 6133 → 6233` on `127.0.0.1`, and if none of them can be reached from the host it re-binds the same three ports on `0.0.0.0` (six candidates, first usable one wins, otherwise it fails with the full attempt table). Under WSL this matters: Windows can reserve a port for Hyper-V, in which case `localhost` forwarding is never created and the human's browser sees "connection refused" while everything inside the distro works — the fallback then hands you `http://<wsl-ip>:6033/` instead. The `http` / `access.url` field is authoritative: print it, do not rebuild it. `--port` pins one port and never jumps (a busy port is an error), `--bind loopback|all` pins the interface.
|
|
106
|
+
|
|
107
|
+
Agents get the same guarantee from the MCP side: `paintress_viewer` ensures the viewer and registers the current project, and the MCP server re-checks it before every write, so the viewer is up before the first edit instead of after someone asks. Its result carries the same `url` / `access` block, so an agent that started the viewer on a fallback port tells the human the address that actually works.
|
|
105
108
|
|
|
106
109
|
A design file that has a sibling Markdown file of the same name (`home.ptr` → `home.md`) gets a notes button in the top-left corner of the canvas. Click it and the canvas narrows to make room for the note: what the design is for, what is still open, which requirement it answers. The note is a plain Markdown file next to the design — write it yourself or let the agent write it; the panel is read-only.
|
|
107
110
|
|
|
@@ -109,7 +112,7 @@ The rail in the top-right corner holds the project's design system. **Presets**
|
|
|
109
112
|
|
|
110
113
|
Canvas navigation: wheel zooms at the pointer, space / middle button / empty area drags to pan, `Ctrl/Cmd+0` returns to 100%, `Shift+1` fits the window, and `Ctrl/Cmd+K` quickly switches design files. Selecting a node opens a read-only inspector. Files with more than one board get a board strip; the active board can be exported as PNG at 0.5x / 1x / 2x (1x means its design pixels; the file is `<design>-<board>@<scale>x.png` and is scaled back when either edge exceeds 8192px). Edits made through the CLI or MCP are highlighted in the viewer, and clicks assign short ids that the agent can read back with `paintress_marks`; each mark carries the file and node id, so a shorthand resolves to one element in one file.
|
|
111
114
|
|
|
112
|
-
Projects are named after their directory path (`c-thun/raybend`), so the URL reads like the path: `http://localhost:
|
|
115
|
+
Projects are named after their directory path (`c-thun/raybend`), so the URL reads like the path: `http://localhost:6033/c-thun/raybend?file=design/editor.pen`. Project rows have an **×** on hover that unbinds the project (design files are untouched; an agent re-registers on its next start), and **Stop server** in the sidebar footer shuts the viewer process down — both ask for confirmation first.
|
|
113
116
|
|
|
114
117
|
The viewer follows the agent instead of asking you to manage it. When an agent starts the viewer it opens your browser on that agent's own project — nothing for the agent to run twice, nothing for you to open by hand; projects appear and leave with a short animation as agents come and go. When the last project is gone **or the server is stopped** (the sidebar's Stop server, an idle exit), the page counts down ten seconds and closes its own window — you never have to close the tab by hand. A browser that nobody is looking at no longer keeps the process alive.
|
|
115
118
|
|
|
@@ -137,6 +140,7 @@ One source of truth per project, so a change lands everywhere instead of on one
|
|
|
137
140
|
| `PAINTRESS_NO_OPEN` | `1` never opens a browser window |
|
|
138
141
|
| `PAINTRESS_NO_AUTO_WEB` | `1` stops the MCP server from ensuring the viewer before writes |
|
|
139
142
|
| `PAINTRESS_NO_WEB_FONTS` | `1` skips the built-in web fonts (offline / CI) |
|
|
143
|
+
| `PAINTRESS_PORT` | Port the `ui/` vite dev server proxies to (default `6033`); set it to the viewer's actual port when the port ladder jumped |
|
|
140
144
|
| `CI` | `true` behaves like `PAINTRESS_NO_OPEN` |
|
|
141
145
|
|
|
142
146
|
## MCP clients
|
package/README.zh-CN.md
CHANGED
|
@@ -81,7 +81,8 @@ paintress component list|insert|add ... 项目组件库
|
|
|
81
81
|
paintress render <file> <out.html> 渲染为 HTML
|
|
82
82
|
paintress shot <file> <out.png> [--scale 0.2..2] [--board id|name] 渲染为 PNG(默认 0.8)
|
|
83
83
|
|
|
84
|
-
paintress web [--port n] [--idle ms] [--foreground]
|
|
84
|
+
paintress web [--port n] [--bind auto|loopback|all] [--idle ms] [--foreground]
|
|
85
|
+
确保查看器在跑(幂等,立刻返回)
|
|
85
86
|
paintress ping | status | stop 查看器控制
|
|
86
87
|
paintress watch <name> <dir> [--depth n] | unwatch <name> 注册/移除要浏览的目录
|
|
87
88
|
paintress attach <name> agent 工作期间保持查看器存活
|
|
@@ -96,12 +97,14 @@ paintress mcp-config <client> 打印单个客户端的
|
|
|
96
97
|
```bash
|
|
97
98
|
paintress web
|
|
98
99
|
paintress watch demo ./demo
|
|
99
|
-
# 打开 http://localhost:
|
|
100
|
+
# 打开 http://localhost:6033/demo
|
|
100
101
|
```
|
|
101
102
|
|
|
102
103
|
`paintress web` 是幂等的、且立刻返回:已经在跑就打印那个进程的 `pid` / `port` / `http`,没跑就拉起一个并把查看器窗口开在它上面。所以每次开始作画前无条件跑一遍都没问题——永远不会出现第二个实例;窗口由程序自己开在桌面浏览器里(`--foreground` 让进程占住终端、日志直接可见;要换端口/重启,用 `paintress stop`)。在 WSL 里窗口落在 Windows 的浏览器,不会是发行版里的那个浏览器。
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
端口不是一个要背下来的常量:它在启动时**选好并报回来**。默认 `6033`;用不了就沿 `6033 → 6133 → 6233` 在 `127.0.0.1` 上跳级,三个都到不了宿主就改绑 `0.0.0.0` 再走一遍同样三个端口(共六个候选,第一个可用的胜出,全灭就带着完整尝试表报错)。WSL 里这条很重要:Windows 可能把端口划给 Hyper-V,此时 `localhost` 转发根本不会建立——发行版里一切正常,人在宿主浏览器里只看到「连接失败」;兜底会把 `http://<wsl-ip>:6033/` 交给你。`http` / `access.url` 字段是权威值:直接用它,不要自己拼。`--port` 钉死一个端口、绝不跳级(被占就是报错),`--bind loopback|all` 钉死绑定面。
|
|
106
|
+
|
|
107
|
+
agent 从 MCP 侧拿到同样的保证:`paintress_viewer` 确保查看器在跑并把当前项目注册进去,MCP server 还会在每次操作前再确认一遍——于是查看器是在第一次编辑之前就起来的,而不是等人开口。它的返回值里带着同一份 `url` / `access`,所以就算查看器落在了兜底端口上,agent 也会把人引到真正能打开的地址。
|
|
105
108
|
|
|
106
109
|
设计稿旁边放一份同名 Markdown(`home.ptr` → `home.md`),画布左上角就会出现「笔记」按钮。点开后画布向右收窄、左侧切出笔记:这份设计是干什么的、还有哪些待定、对应哪条需求。笔记就是设计稿旁边的一份普通 Markdown——您自己写,或者让 agent 写;面板本身只读。
|
|
107
110
|
|
|
@@ -133,6 +136,7 @@ agent 从 MCP 侧拿到同样的保证:`paintress_viewer` 确保查看器在
|
|
|
133
136
|
| `PAINTRESS_NO_OPEN` | `1` 时不打开浏览器窗口 |
|
|
134
137
|
| `PAINTRESS_NO_AUTO_WEB` | `1` 时 MCP 写操作前不再确保查看器在跑 |
|
|
135
138
|
| `PAINTRESS_NO_WEB_FONTS` | `1` 时跳过内置网络字体(离线 / CI) |
|
|
139
|
+
| `PAINTRESS_PORT` | `ui/` 开发服务器的代理目标端口(默认 `6033`);端口阶梯跳级时设成查看器实际端口 |
|
|
136
140
|
| `CI` | `true` 等价于 `PAINTRESS_NO_OPEN` |
|
|
137
141
|
|
|
138
142
|
## MCP 客户端
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
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
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";
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { createServer } from "node:net";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
11
12
|
import { createServer as createServer$1 } from "node:http";
|
|
12
13
|
import { readFile } from "node:fs/promises";
|
|
13
14
|
import chokidar from "chokidar";
|
|
@@ -415,8 +416,368 @@ var VersionChecker = class {
|
|
|
415
416
|
};
|
|
416
417
|
}
|
|
417
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
|
+
}
|
|
418
780
|
// src/web/daemon.ts
|
|
419
|
-
const DEFAULT_PORT = 3316;
|
|
420
781
|
const MIME = {
|
|
421
782
|
".html": "text/html; charset=utf-8",
|
|
422
783
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -595,6 +956,12 @@ async function startDaemon(opts = {}) {
|
|
|
595
956
|
const watchers = new WatchManager();
|
|
596
957
|
const versions = new VersionChecker();
|
|
597
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;
|
|
598
965
|
const idleTimeoutMs = opts.idleTimeoutMs ?? 6e4;
|
|
599
966
|
const emptyGraceMs = opts.emptyGraceMs ?? Math.min(idleTimeoutMs, 3e3);
|
|
600
967
|
const emptyBrowserMs = opts.emptyBrowserMs ?? 3e4;
|
|
@@ -731,7 +1098,8 @@ async function startDaemon(opts = {}) {
|
|
|
731
1098
|
pid: process.pid,
|
|
732
1099
|
port,
|
|
733
1100
|
uptimeMs: Date.now() - startedAt,
|
|
734
|
-
projects: registry.all().length
|
|
1101
|
+
projects: registry.all().length,
|
|
1102
|
+
...accessInfo !== null ? { access: accessInfo } : {}
|
|
735
1103
|
});
|
|
736
1104
|
break;
|
|
737
1105
|
case "status":
|
|
@@ -865,7 +1233,7 @@ async function startDaemon(opts = {}) {
|
|
|
865
1233
|
};
|
|
866
1234
|
const sockServer = createServer(handleClient);
|
|
867
1235
|
const uiDist = findUiDist();
|
|
868
|
-
const
|
|
1236
|
+
const handleHttp = async (req, res) => {
|
|
869
1237
|
lastHttpAt = Date.now();
|
|
870
1238
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
871
1239
|
const segments = url.pathname.split("/").filter((s) => s !== "");
|
|
@@ -891,6 +1259,17 @@ async function startDaemon(opts = {}) {
|
|
|
891
1259
|
};
|
|
892
1260
|
try {
|
|
893
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
|
+
}
|
|
894
1273
|
if (segments[1] === "projects" && segments.length === 2) {
|
|
895
1274
|
sendJson(200, {
|
|
896
1275
|
ok: true,
|
|
@@ -1608,7 +1987,8 @@ async function startDaemon(opts = {}) {
|
|
|
1608
1987
|
const info = registry.info(p);
|
|
1609
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>`;
|
|
1610
1989
|
}).join("");
|
|
1611
|
-
|
|
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>")));
|
|
1612
1992
|
return;
|
|
1613
1993
|
}
|
|
1614
1994
|
const hit = resolveProjectPrefix(registry, segments);
|
|
@@ -1628,27 +2008,40 @@ async function startDaemon(opts = {}) {
|
|
|
1628
2008
|
} catch (cause) {
|
|
1629
2009
|
send(500, layout("错误", `<h1>500</h1><pre>${esc(cause instanceof Error ? cause.message : String(cause))}</pre>`));
|
|
1630
2010
|
}
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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`) }
|
|
1640
2020
|
});
|
|
1641
|
-
})
|
|
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;
|
|
1642
2029
|
const sock = socketPath();
|
|
1643
2030
|
try {
|
|
1644
2031
|
if (existsSync(sock)) unlinkSync(sock);
|
|
1645
2032
|
} catch {}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
2033
|
+
try {
|
|
2034
|
+
await new Promise((resolve, reject) => {
|
|
2035
|
+
sockServer.once("error", reject);
|
|
2036
|
+
sockServer.listen(sock, () => {
|
|
2037
|
+
resolve();
|
|
2038
|
+
});
|
|
1650
2039
|
});
|
|
1651
|
-
})
|
|
2040
|
+
} catch (cause) {
|
|
2041
|
+
httpServer.close();
|
|
2042
|
+
releaseLock();
|
|
2043
|
+
throw cause;
|
|
2044
|
+
}
|
|
1652
2045
|
/**
|
|
1653
2046
|
* 生命周期策略:控制权在 agent 侧,人不需要管进程在不在。
|
|
1654
2047
|
*
|
|
@@ -1724,7 +2117,16 @@ async function startDaemon(opts = {}) {
|
|
|
1724
2117
|
port,
|
|
1725
2118
|
pid: process.pid,
|
|
1726
2119
|
close: () => shutdown("close"),
|
|
1727
|
-
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
|
+
}
|
|
1728
2130
|
};
|
|
1729
2131
|
}
|
|
1730
2132
|
// src/integrations/clients.ts
|
|
@@ -2026,6 +2428,7 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
|
|
|
2026
2428
|
"index",
|
|
2027
2429
|
"scale",
|
|
2028
2430
|
"port",
|
|
2431
|
+
"bind",
|
|
2029
2432
|
"idle",
|
|
2030
2433
|
"foreground",
|
|
2031
2434
|
"daemon",
|
|
@@ -2099,6 +2502,29 @@ function currentLaunchCommand() {
|
|
|
2099
2502
|
realpath: (p) => realpathSync(p)
|
|
2100
2503
|
});
|
|
2101
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
|
+
}
|
|
2102
2528
|
function flagStr(f, key) {
|
|
2103
2529
|
const v = f[key];
|
|
2104
2530
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
@@ -2282,7 +2708,13 @@ async function main(argv) {
|
|
|
2282
2708
|
dryRun
|
|
2283
2709
|
});
|
|
2284
2710
|
}
|
|
2285
|
-
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
|
+
} : {
|
|
2286
2718
|
ok: true,
|
|
2287
2719
|
launch,
|
|
2288
2720
|
results
|
|
@@ -2312,9 +2744,9 @@ async function main(argv) {
|
|
|
2312
2744
|
if (cmd === "web") {
|
|
2313
2745
|
const port = flagNum(flags, "port");
|
|
2314
2746
|
const idle = flagNum(flags, "idle");
|
|
2747
|
+
const bind = parseBind(flagStr(flags, "bind"));
|
|
2315
2748
|
const noOpen = flags[NO_OPEN_FLAG] === true || process.env["PAINTRESS_NO_OPEN"] === "1";
|
|
2316
|
-
const
|
|
2317
|
-
const running = await reuseDaemon(port);
|
|
2749
|
+
const running = await reuseDaemon(port, bind);
|
|
2318
2750
|
if (running !== null) {
|
|
2319
2751
|
console.log(JSON.stringify({
|
|
2320
2752
|
ok: true,
|
|
@@ -2322,7 +2754,8 @@ async function main(argv) {
|
|
|
2322
2754
|
reused: true,
|
|
2323
2755
|
pid: running.pid,
|
|
2324
2756
|
port: running.port,
|
|
2325
|
-
http:
|
|
2757
|
+
http: httpOf(running.access),
|
|
2758
|
+
access: running.access,
|
|
2326
2759
|
designSystem: cwdDesignSystem()
|
|
2327
2760
|
}, null, 2));
|
|
2328
2761
|
return 0;
|
|
@@ -2330,6 +2763,7 @@ async function main(argv) {
|
|
|
2330
2763
|
if (flags["foreground"] !== true) {
|
|
2331
2764
|
const res = await ensureDaemon({
|
|
2332
2765
|
...port !== void 0 ? { port } : {},
|
|
2766
|
+
...bind !== void 0 ? { bind } : {},
|
|
2333
2767
|
...idle !== void 0 ? { idleTimeoutMs: idle } : {},
|
|
2334
2768
|
noOpen
|
|
2335
2769
|
});
|
|
@@ -2339,7 +2773,8 @@ async function main(argv) {
|
|
|
2339
2773
|
reused: !res.started,
|
|
2340
2774
|
pid: res.pid,
|
|
2341
2775
|
port: res.port,
|
|
2342
|
-
http:
|
|
2776
|
+
http: httpOf(res.access),
|
|
2777
|
+
access: res.access,
|
|
2343
2778
|
designSystem: cwdDesignSystem()
|
|
2344
2779
|
}, null, 2));
|
|
2345
2780
|
return 0;
|
|
@@ -2348,6 +2783,7 @@ async function main(argv) {
|
|
|
2348
2783
|
try {
|
|
2349
2784
|
handle = await startDaemon({
|
|
2350
2785
|
...port !== void 0 ? { port } : {},
|
|
2786
|
+
...bind !== void 0 ? { bind } : {},
|
|
2351
2787
|
...idle !== void 0 ? { idleTimeoutMs: idle } : {},
|
|
2352
2788
|
emptyGraceMs: 6e4,
|
|
2353
2789
|
updateCheck: true
|
|
@@ -2361,20 +2797,21 @@ async function main(argv) {
|
|
|
2361
2797
|
reused: true,
|
|
2362
2798
|
pid: again.pid,
|
|
2363
2799
|
port: again.port,
|
|
2364
|
-
http:
|
|
2800
|
+
http: httpOf(again.access),
|
|
2801
|
+
access: again.access
|
|
2365
2802
|
}, null, 2));
|
|
2366
2803
|
return 0;
|
|
2367
2804
|
}
|
|
2368
|
-
const http = url(handle.port);
|
|
2369
2805
|
console.log(JSON.stringify({
|
|
2370
2806
|
ok: true,
|
|
2371
2807
|
pid: handle.pid,
|
|
2372
2808
|
port: handle.port,
|
|
2373
|
-
http
|
|
2809
|
+
http: httpOf(handle.access),
|
|
2810
|
+
access: handle.access
|
|
2374
2811
|
}, null, 2));
|
|
2375
2812
|
if (!noOpen) {
|
|
2376
|
-
const { openBrowser } = await import("./server-
|
|
2377
|
-
openBrowser(
|
|
2813
|
+
const { openBrowser } = await import("./server-Buuoq3kq.js").then((n) => n.i);
|
|
2814
|
+
openBrowser(handle.access.url);
|
|
2378
2815
|
}
|
|
2379
2816
|
await new Promise((resolve) => {
|
|
2380
2817
|
process.once("SIGINT", () => void handle.close().then(() => resolve()));
|
package/dist/mcp/server.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as createMcpServer, n as runStdioServer } from "../server-
|
|
1
|
+
export { t as createMcpServer, n as runStdioServer } from "../server-Buuoq3kq.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { A as genId, B as presetGroupsOf, C as update, E as saveDoc, F as splitFontStack, H as DocumentSchema, I as textFontGaps, L as PROP_TOKEN_KIND, M as isFontSourceKind, N as isGenericFamily, O as validateDoc, P as planFonts, R as isNumericPresetValue, S as remove, T as loadDoc, U as PEN_DOC_VERSION, b as move, g as batch, j as fontRegistryOf, k as collectIds, m as expandRefs, rn as VERSION, t as renderDocument, v as get, w as FileChangedError, x as query, y as insert, z as isPresetType } from "./render-DWs1iQ0v.js";
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
@@ -1961,6 +1961,18 @@ function safeJoin(projectRootReal, requestPath) {
|
|
|
1961
1961
|
function isDaemonRunning() {
|
|
1962
1962
|
return existsSync(socketPath());
|
|
1963
1963
|
}
|
|
1964
|
+
/** 旧版 daemon(未上报 access)的兼容值:当作本机 loopback 可用 */
|
|
1965
|
+
function legacyAccess(port) {
|
|
1966
|
+
return {
|
|
1967
|
+
url: `http://localhost:${port}/`,
|
|
1968
|
+
host: "localhost",
|
|
1969
|
+
bind: "loopback",
|
|
1970
|
+
windows: "skipped",
|
|
1971
|
+
jumped: false,
|
|
1972
|
+
requestedPort: port,
|
|
1973
|
+
attempts: []
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1964
1976
|
/**
|
|
1965
1977
|
* 查询运行中的 daemon:未运行、socket 残留、daemon 无响应都返回 null。
|
|
1966
1978
|
* 调用方拿到 null 就可以安全地走「启动」分支 —— 幂等的判据只有这一处。
|
|
@@ -1972,7 +1984,8 @@ async function queryDaemon(timeoutMs = 2e3) {
|
|
|
1972
1984
|
if (res.ok && "pong" in res) return {
|
|
1973
1985
|
pid: res.pid,
|
|
1974
1986
|
port: res.port,
|
|
1975
|
-
uptimeMs: res.uptimeMs
|
|
1987
|
+
uptimeMs: res.uptimeMs,
|
|
1988
|
+
access: res.access ?? legacyAccess(res.port)
|
|
1976
1989
|
};
|
|
1977
1990
|
} catch {}
|
|
1978
1991
|
return null;
|
|
@@ -2090,16 +2103,17 @@ function daemonPid() {
|
|
|
2090
2103
|
}
|
|
2091
2104
|
}
|
|
2092
2105
|
// src/web/spawn.ts
|
|
2093
|
-
const START_TIMEOUT_MS =
|
|
2106
|
+
const START_TIMEOUT_MS = 15e3;
|
|
2094
2107
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2095
2108
|
/**
|
|
2096
|
-
* 复用已在运行的 daemon
|
|
2097
|
-
*
|
|
2109
|
+
* 复用已在运行的 daemon;显式指定的端口/绑定与运行中的不一致时抛错
|
|
2110
|
+
* (幂等不等于「悄悄忽略你的参数」:要换端口或绑定得先 `paintress stop`)。
|
|
2098
2111
|
*/
|
|
2099
|
-
async function reuseDaemon(port) {
|
|
2112
|
+
async function reuseDaemon(port, bind) {
|
|
2100
2113
|
const info = await queryDaemon();
|
|
2101
2114
|
if (info === null) return null;
|
|
2102
2115
|
if (port !== void 0 && port !== info.port) throw new Error(`paintress web 已在运行(pid ${info.pid},端口 ${info.port}),与请求的端口 ${port} 不同;如需换端口请先 paintress stop`);
|
|
2116
|
+
if (bind !== void 0 && bind !== "auto" && bind !== info.access.bind) throw new Error(`paintress web 已在运行(pid ${info.pid},绑定 ${info.access.bind}),与请求的绑定 ${bind} 不同;如需换绑定请先 paintress stop`);
|
|
2103
2117
|
return info;
|
|
2104
2118
|
}
|
|
2105
2119
|
/** 启动超时的诊断信息:区分「进程没起来」与「进程在但控制面不通」 */
|
|
@@ -2112,11 +2126,12 @@ function timeoutHint() {
|
|
|
2112
2126
|
return ";可能是锁或端口残留,先 paintress ping 看状态,或前台跑 paintress web 看报错";
|
|
2113
2127
|
}
|
|
2114
2128
|
async function ensureDaemon(opts = {}) {
|
|
2115
|
-
const running = await reuseDaemon(opts.port);
|
|
2129
|
+
const running = await reuseDaemon(opts.port, opts.bind);
|
|
2116
2130
|
if (running !== null) return {
|
|
2117
2131
|
started: false,
|
|
2118
2132
|
pid: running.pid,
|
|
2119
|
-
port: running.port
|
|
2133
|
+
port: running.port,
|
|
2134
|
+
access: running.access
|
|
2120
2135
|
};
|
|
2121
2136
|
const entry = process.argv[1];
|
|
2122
2137
|
if (!entry) throw new Error("无法确定 CLI 入口路径,不能后台启动 daemon");
|
|
@@ -2126,14 +2141,30 @@ async function ensureDaemon(opts = {}) {
|
|
|
2126
2141
|
"--foreground"
|
|
2127
2142
|
];
|
|
2128
2143
|
if (opts.port !== void 0) args.push("--port", String(opts.port));
|
|
2144
|
+
if (opts.bind !== void 0) args.push("--bind", opts.bind);
|
|
2129
2145
|
if (opts.idleTimeoutMs !== void 0) args.push("--idle", String(opts.idleTimeoutMs));
|
|
2130
2146
|
if (opts.noOpen === true) args.push("--no-open");
|
|
2131
2147
|
const child = spawn(process.execPath, args, {
|
|
2132
2148
|
detached: true,
|
|
2133
|
-
stdio:
|
|
2149
|
+
stdio: [
|
|
2150
|
+
"ignore",
|
|
2151
|
+
"ignore",
|
|
2152
|
+
"pipe"
|
|
2153
|
+
]
|
|
2134
2154
|
});
|
|
2135
2155
|
child.unref();
|
|
2156
|
+
let startupStderr = "";
|
|
2157
|
+
let ready = false;
|
|
2158
|
+
child.stderr?.on("data", (chunk) => {
|
|
2159
|
+
if (ready) return;
|
|
2160
|
+
startupStderr = `${startupStderr}${chunk.toString("utf8")}`.slice(-32768);
|
|
2161
|
+
});
|
|
2162
|
+
child.stderr?.unref?.();
|
|
2136
2163
|
let exitCode = null;
|
|
2164
|
+
child.once("error", (cause) => {
|
|
2165
|
+
startupStderr = `${startupStderr}\n${cause.message}`;
|
|
2166
|
+
exitCode = -1;
|
|
2167
|
+
});
|
|
2137
2168
|
child.once("exit", (code) => {
|
|
2138
2169
|
exitCode = code;
|
|
2139
2170
|
});
|
|
@@ -2142,25 +2173,30 @@ async function ensureDaemon(opts = {}) {
|
|
|
2142
2173
|
const info = await queryDaemon(500);
|
|
2143
2174
|
if (info !== null) {
|
|
2144
2175
|
if (opts.port !== void 0 && info.port !== opts.port) throw new Error(`daemon 起在端口 ${info.port} 而不是请求的 ${opts.port};先 paintress stop 再重试`);
|
|
2176
|
+
if (opts.bind !== void 0 && opts.bind !== "auto" && info.access.bind !== opts.bind) throw new Error(`daemon 起在绑定 ${info.access.bind} 而不是请求的 ${opts.bind};先 paintress stop 再重试`);
|
|
2177
|
+
ready = true;
|
|
2145
2178
|
return {
|
|
2146
|
-
started:
|
|
2179
|
+
started: info.pid === child.pid,
|
|
2147
2180
|
pid: info.pid,
|
|
2148
|
-
port: info.port
|
|
2181
|
+
port: info.port,
|
|
2182
|
+
access: info.access
|
|
2149
2183
|
};
|
|
2150
2184
|
}
|
|
2151
|
-
if (exitCode !== null)
|
|
2185
|
+
if (exitCode !== null) {
|
|
2186
|
+
const detail = startupStderr.trim();
|
|
2187
|
+
if (/已在运行|单实例锁/.test(detail)) {
|
|
2188
|
+
await sleep(100);
|
|
2189
|
+
continue;
|
|
2190
|
+
}
|
|
2191
|
+
const suffix = detail === "" ? timeoutHint() : `:\n${detail}`;
|
|
2192
|
+
throw new Error(`后台 daemon 启动失败(退出码 ${exitCode})${suffix}`);
|
|
2193
|
+
}
|
|
2152
2194
|
await sleep(100);
|
|
2153
2195
|
}
|
|
2154
|
-
|
|
2155
|
-
}
|
|
2156
|
-
// src/web/open-browser.ts
|
|
2157
|
-
var open_browser_exports = /* @__PURE__ */ __exportAll({ openBrowser: () => openBrowser });
|
|
2158
|
-
/** 按 PATH 找可执行文件(不 shell out,避免和 shell 引号规则纠缠) */
|
|
2159
|
-
function onPath(cmd) {
|
|
2160
|
-
if (cmd.includes("/")) return existsSync(cmd);
|
|
2161
|
-
for (const dir of (process.env["PATH"] ?? "").split(":")) if (dir !== "" && existsSync(`${dir}/${cmd}`)) return true;
|
|
2162
|
-
return false;
|
|
2196
|
+
const detail = startupStderr.trim();
|
|
2197
|
+
throw new Error(`后台启动 daemon 超时(${START_TIMEOUT_MS}ms)${detail === "" ? timeoutHint() : `:\n${detail}`}`);
|
|
2163
2198
|
}
|
|
2199
|
+
// src/shared/wsl.ts
|
|
2164
2200
|
function readOsrelease() {
|
|
2165
2201
|
try {
|
|
2166
2202
|
return readFileSync("/proc/sys/kernel/osrelease", "utf8");
|
|
@@ -2179,6 +2215,69 @@ function isWsl(ctx = {}) {
|
|
|
2179
2215
|
const rel = ctx.osrelease !== void 0 ? ctx.osrelease : readOsrelease();
|
|
2180
2216
|
return rel !== null && /microsoft/i.test(rel);
|
|
2181
2217
|
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Windows 侧系统目录(`/mnt/c/Windows/System32`)。找不到返回 null:
|
|
2220
|
+
* 这是「能不能调 Windows 程序」的前提,null 时所有宿主探测都跳过。
|
|
2221
|
+
*/
|
|
2222
|
+
function windowsSystem32() {
|
|
2223
|
+
const drive = process.env["SystemDrive"]?.replace(/\0/g, "").toLowerCase();
|
|
2224
|
+
const candidates = [];
|
|
2225
|
+
if (drive !== void 0 && /^[a-z]:$/.test(drive)) candidates.push(`/mnt/${drive[0]}/Windows/System32`);
|
|
2226
|
+
candidates.push("/mnt/c/Windows/System32");
|
|
2227
|
+
for (const letter of "cdefg") candidates.push(`/mnt/${letter}/Windows/System32`);
|
|
2228
|
+
for (const c of candidates) try {
|
|
2229
|
+
if (existsSync(c)) return c;
|
|
2230
|
+
} catch {}
|
|
2231
|
+
return null;
|
|
2232
|
+
}
|
|
2233
|
+
/** binfmt 注册项在不在:在 = interop 可执行(比试运行一条命令便宜得多) */
|
|
2234
|
+
function interopEnabled() {
|
|
2235
|
+
return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || existsSync("/proc/sys/fs/binfmt_misc/WSLInterop-late");
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* 默认路由所在网卡的 IPv4(WSL 里就是 eth0)。
|
|
2239
|
+
* 绑 0.0.0.0 时,Windows 要访问的就是这个地址 —— 不能随便取一张网卡,
|
|
2240
|
+
* 否则会拿到 docker0 之类的内网地址,给出去的 URL 直接不可用。
|
|
2241
|
+
*/
|
|
2242
|
+
function defaultRouteIpv4() {
|
|
2243
|
+
const iface = defaultRouteInterface();
|
|
2244
|
+
const ifaces = networkInterfaces();
|
|
2245
|
+
const pick = (name) => {
|
|
2246
|
+
if (name === null) return null;
|
|
2247
|
+
for (const addr of ifaces[name] ?? []) if (addr.family === "IPv4" && !addr.internal) return addr.address;
|
|
2248
|
+
return null;
|
|
2249
|
+
};
|
|
2250
|
+
const fromRoute = pick(iface);
|
|
2251
|
+
if (fromRoute !== null) return fromRoute;
|
|
2252
|
+
for (const name of Object.keys(ifaces)) {
|
|
2253
|
+
const hit = pick(name);
|
|
2254
|
+
if (hit !== null) return hit;
|
|
2255
|
+
}
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
/** /proc/net/route:Destination 为 0 且带 RTF_GATEWAY 的那一行就是默认路由 */
|
|
2259
|
+
function defaultRouteInterface() {
|
|
2260
|
+
try {
|
|
2261
|
+
const lines = readFileSync("/proc/net/route", "utf8").split("\n").slice(1);
|
|
2262
|
+
for (const line of lines) {
|
|
2263
|
+
const cols = line.trim().split(/\s+/);
|
|
2264
|
+
if (cols.length < 4) continue;
|
|
2265
|
+
const [, dest, , flags] = cols;
|
|
2266
|
+
if (dest !== "00000000") continue;
|
|
2267
|
+
if ((Number.parseInt(flags ?? "", 16) & 2) === 0) continue;
|
|
2268
|
+
return cols[0] ?? null;
|
|
2269
|
+
}
|
|
2270
|
+
} catch {}
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
// src/web/open-browser.ts
|
|
2274
|
+
var open_browser_exports = /* @__PURE__ */ __exportAll({ openBrowser: () => openBrowser });
|
|
2275
|
+
/** 按 PATH 找可执行文件(不 shell out,避免和 shell 引号规则纠缠) */
|
|
2276
|
+
function onPath(cmd) {
|
|
2277
|
+
if (cmd.includes("/")) return existsSync(cmd);
|
|
2278
|
+
for (const dir of (process.env["PATH"] ?? "").split(":")) if (dir !== "" && existsSync(`${dir}/${cmd}`)) return true;
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2182
2281
|
/** Windows 侧打开 URL 的候选命令,按可靠度排序 */
|
|
2183
2282
|
function windowsLaunchers(url) {
|
|
2184
2283
|
const ps = `Start-Process '${url.replace(/'/g, "''")}'`;
|
|
@@ -2257,14 +2356,19 @@ function openBrowser(url, ctx = {}) {
|
|
|
2257
2356
|
return false;
|
|
2258
2357
|
}
|
|
2259
2358
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2359
|
+
const originOf = (o) => typeof o === "number" ? {
|
|
2360
|
+
port: o,
|
|
2361
|
+
host: "localhost"
|
|
2362
|
+
} : o;
|
|
2363
|
+
function baseUrl(o) {
|
|
2364
|
+
const { port, host } = originOf(o);
|
|
2365
|
+
return `http://${host}:${port}/`;
|
|
2263
2366
|
}
|
|
2264
2367
|
/** 项目深链:项目 key 本身就带 `/`(`c-thun/raybend`),URL 里不需要转义 */
|
|
2265
|
-
function projectUrl(
|
|
2266
|
-
|
|
2267
|
-
|
|
2368
|
+
function projectUrl(o, project, file) {
|
|
2369
|
+
const { port, host } = originOf(o);
|
|
2370
|
+
if (!project) return baseUrl(o);
|
|
2371
|
+
return `http://${host}:${port}/${project.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${file ? `?file=${encodeURIComponent(file)}` : ""}`;
|
|
2268
2372
|
}
|
|
2269
2373
|
// src/web/autoregister.ts
|
|
2270
2374
|
/**
|
|
@@ -2307,11 +2411,15 @@ async function ensureViewer(opts = {}) {
|
|
|
2307
2411
|
root: dir,
|
|
2308
2412
|
pid: running.pid,
|
|
2309
2413
|
port: running.port,
|
|
2310
|
-
url: projectUrl(
|
|
2414
|
+
url: projectUrl({
|
|
2415
|
+
port: running.port,
|
|
2416
|
+
host: running.access.host
|
|
2417
|
+
}, project),
|
|
2418
|
+
access: running.access,
|
|
2311
2419
|
startedDaemon: false
|
|
2312
2420
|
};
|
|
2313
2421
|
}
|
|
2314
|
-
const { started, pid, port } = await ensureDaemon({ noOpen: true });
|
|
2422
|
+
const { started, pid, port, access } = await ensureDaemon({ noOpen: true });
|
|
2315
2423
|
const watched = await sendRequest({
|
|
2316
2424
|
kind: "watch",
|
|
2317
2425
|
project,
|
|
@@ -2324,13 +2432,18 @@ async function ensureViewer(opts = {}) {
|
|
|
2324
2432
|
handles.set(project, await attachPersistent(project));
|
|
2325
2433
|
rootsSynced = false;
|
|
2326
2434
|
await syncProjectRoots();
|
|
2327
|
-
|
|
2435
|
+
const url = projectUrl({
|
|
2436
|
+
port,
|
|
2437
|
+
host: access.host
|
|
2438
|
+
}, project);
|
|
2439
|
+
if (started) openBrowser(url);
|
|
2328
2440
|
return {
|
|
2329
2441
|
project,
|
|
2330
2442
|
root: dir,
|
|
2331
2443
|
pid,
|
|
2332
2444
|
port,
|
|
2333
|
-
url
|
|
2445
|
+
url,
|
|
2446
|
+
access,
|
|
2334
2447
|
startedDaemon: started
|
|
2335
2448
|
};
|
|
2336
2449
|
}
|
|
@@ -2637,6 +2750,9 @@ const TOOLS = {
|
|
|
2637
2750
|
...depth !== void 0 ? { depth } : {}
|
|
2638
2751
|
});
|
|
2639
2752
|
const designSystem = projectFilesInfo(info.root, { ensure: true });
|
|
2753
|
+
let note;
|
|
2754
|
+
if (info.access.bind === "all") note = `宿主 localhost 转发不可用,查看器已绑 0.0.0.0;给人打开的地址用返回的 url(${info.url}),不要用 localhost。`;
|
|
2755
|
+
else if (info.access.jumped) note = `默认端口 ${info.access.requestedPort} 不可用,查看器起在 ${info.port};给人打开的地址是 ${info.url}。`;
|
|
2640
2756
|
return {
|
|
2641
2757
|
ok: true,
|
|
2642
2758
|
project: info.project,
|
|
@@ -2644,6 +2760,8 @@ const TOOLS = {
|
|
|
2644
2760
|
url: info.url,
|
|
2645
2761
|
pid: info.pid,
|
|
2646
2762
|
port: info.port,
|
|
2763
|
+
access: info.access,
|
|
2764
|
+
...note !== void 0 ? { note } : {},
|
|
2647
2765
|
started: info.startedDaemon,
|
|
2648
2766
|
designSystem
|
|
2649
2767
|
};
|
|
@@ -3459,4 +3577,4 @@ async function runStdioServer() {
|
|
|
3459
3577
|
installParentWatchdog();
|
|
3460
3578
|
registerSelf();
|
|
3461
3579
|
}
|
|
3462
|
-
export {
|
|
3580
|
+
export { listComponents as A, findBrowser as C, ensureProjectScaffold as D, componentsPathFor as E, projectFilesInfo as F, projectFontRegistry as I, readTextIfExists as L, listPresets as M, notePathFor as N, findNodeIn as O, presetPathFor as P, setPreset as R, encodeLine as S, applyProjectPresets as T, resolveProjectDir as _, defaultRouteIpv4 as a, socketPath as b, windowsSystem32 as c, attachPersistent as d, isDaemonRunning as f, pidPath as g, lockPath as h, open_browser_exports as i, listPresetGroups as j, findProjectRoot as k, ensureDaemon as l, sendRequest as m, runStdioServer as n, interopEnabled as o, queryDaemon as p, TOOLS as r, isWsl as s, createMcpServer as t, reuseDaemon as u, safeJoin as v, screenshotPen as w, LineDecoder as x, sanitizeProjectName as y, setProjectRoots as z };
|
package/package.json
CHANGED
|
@@ -105,8 +105,10 @@ paintress web
|
|
|
105
105
|
paintress watch <name> <dir> # register a directory that lives outside the project
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
- project list: `http://localhost:
|
|
109
|
-
- one document: `http://localhost:
|
|
108
|
+
- project list: `http://localhost:6033/`
|
|
109
|
+
- one document: `http://localhost:6033/<name>?file=<relative/path>`
|
|
110
|
+
|
|
111
|
+
**Never hardcode the port.** It is picked at startup and reported back in the `http` field (`paintress web`) and in `url` / `access.url` (`paintress_viewer`): print that field, do not rebuild the URL. The default is `6033`, but if the host cannot reach it the viewer falls back to `6133` / `6233`, and finally to `0.0.0.0` with the same three ports — in that last case the working address is `http://<wsl-ip>:6033/` and `localhost` will **not** work from the user's browser, so say so instead of handing them a dead link. `--port` pins one port and errors when busy; `--bind loopback|all` pins the interface.
|
|
110
112
|
|
|
111
113
|
## Bind the project yourself — do not wait to be asked
|
|
112
114
|
|