@zkov/pi-md-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # pi-md-viewer
2
+
3
+ Local loopback Markdown viewer and pi agent tool. `mdview` открывает один или
4
+ несколько локальных Markdown-файлов в тёмном адаптивном browser UI. Повторный
5
+ вызов использует тот же `127.0.0.1` server и обновляет уже открытую страницу.
6
+
7
+ Runtime реализован на TypeScript/Node.js.
8
+
9
+ ## Требования
10
+
11
+ - Node.js 22 или новее;
12
+ - npm dependencies из `package.json`;
13
+ - Linux desktop system opener: `xdg-open`;
14
+ - macOS system opener: `open` — экспериментальная поддержка до проверки на macOS;
15
+ - для Playwright viewer: manual `npm install playwright` и установленный browser binary;
16
+ - для Termux system opener: `termux-open-url` из Termux:API package.
17
+
18
+ Server слушает только `127.0.0.1`. Viewer не создаёт runtime HTML, PID, lock,
19
+ socket, log, cache или bytecode files. Markdown raw HTML отключён.
20
+
21
+ ## Быстрый старт
22
+
23
+ Из корня checkout:
24
+
25
+ ```bash
26
+ npm install
27
+ npm run build
28
+ node bin/mdview.js README.md
29
+ ```
30
+
31
+ После npm/global install можно использовать console command:
32
+
33
+ ```bash
34
+ mdview README.md
35
+ ```
36
+
37
+ ## Использование
38
+
39
+ ```bash
40
+ mdview README.md
41
+ mdview README.md docs/design.md notes.markdown
42
+ mdview --no-open README.md
43
+ mdview --json README.md
44
+ mdview --status
45
+ mdview --close
46
+ ```
47
+
48
+ Поддерживаются `.md`, `.markdown`, `.mdown` и `.mkd`. Paths разрешаются
49
+ относительно текущего каталога, canonical paths дедуплицируются. Неверные paths
50
+ показываются отдельно; корректная часть одного вызова всё равно открывается.
51
+
52
+ Первый вызов запускает detached Node.js server на первом свободном порту из
53
+ `127.0.0.1:18765–18774`. Последующие вызовы находят его по fingerprint и
54
+ protocol version, а затем добавляют документы через локальный control API.
55
+ Локальные control requests не используют proxy environment. Для изолированных
56
+ тестов range можно переопределить переменной `PI_MD_VIEWER_PORT_RANGE`, например
57
+ `19000-19009`.
58
+
59
+ Browser UI позволяет переключать, обновлять и выгружать документы, копировать
60
+ code blocks и закрывать всю viewer session. Выгрузка последнего документа
61
+ завершает viewer и закрывает его вкладки. Markdown перечитывается и безопасно
62
+ рендерится в памяти при каждом запросе.
63
+
64
+ ## Viewer configuration
65
+
66
+ Standalone CLI priority:
67
+
68
+ ```text
69
+ CLI args > .md-viewer.json > user config > built-in default
70
+ ```
71
+
72
+ Pi-extension priority:
73
+
74
+ ```text
75
+ CLI args passed by extension > pi tool input > .pi/pi-md-viewer.json > .md-viewer.json > user config > built-in default
76
+ ```
77
+
78
+ Standalone CLI игнорирует `.pi/pi-md-viewer.json`. Pi extension читает
79
+ `.pi/pi-md-viewer.json` только в trusted project.
80
+
81
+ Built-in default:
82
+
83
+ ```json
84
+ {
85
+ "defaultViewer": "system",
86
+ "playwright": { "browser": "chromium" },
87
+ "viewers": {}
88
+ }
89
+ ```
90
+
91
+ User config lookup:
92
+
93
+ 1. `PI_MD_VIEWER_CONFIG`, если задан;
94
+ 2. Windows: `%APPDATA%\\pi-md-viewer\\config.json`;
95
+ 3. macOS: `~/Library/Application Support/pi-md-viewer/config.json`;
96
+ 4. POSIX: `$XDG_CONFIG_HOME/pi-md-viewer/config.json`;
97
+ 5. fallback для всех платформ: `~/.config/pi-md-viewer/config.json`.
98
+
99
+ Example `.md-viewer.json`:
100
+
101
+ ```json
102
+ {
103
+ "defaultViewer": "chrome",
104
+ "viewers": {
105
+ "chrome": {
106
+ "command": [
107
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
108
+ "--new-window",
109
+ "{url}"
110
+ ]
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ ## Viewer selection
117
+
118
+ ```bash
119
+ mdview --viewer system README.md
120
+ mdview --viewer playwright README.md
121
+ mdview --viewer chrome README.md
122
+ mdview --viewer-command "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" README.md
123
+ mdview --viewer-command '["firefox","--new-window","{url}"]' README.md
124
+ ```
125
+
126
+ `--viewer` accepts built-ins (`system`, `playwright`) or a named custom viewer
127
+ from config. `--viewer-command` is an ad-hoc command and has higher priority than
128
+ `--viewer`.
129
+
130
+ Custom commands are argv arrays, never shell command strings. `{url}` is replaced
131
+ inside argv elements. If no argv element contains the final URL, URL is appended
132
+ as the last argument.
133
+
134
+ ## Playwright viewer
135
+
136
+ Playwright opens the viewer in a headed browser when explicitly selected:
137
+
138
+ ```bash
139
+ npm install playwright
140
+ npx playwright install chromium
141
+ mdview --viewer playwright README.md
142
+ ```
143
+
144
+ Playwright is not installed automatically by `pi-md-viewer`. Install it manually
145
+ only on platforms where you explicitly want the headed Playwright viewer.
146
+
147
+ The Playwright viewer uses the real browser window viewport so the Markdown UI
148
+ resizes with the window instead of being locked to Playwright's default fixed
149
+ viewport. Chromium also starts maximized where the browser supports that flag.
150
+
151
+ Supported platforms: Windows, Linux desktop with `DISPLAY` or
152
+ `WAYLAND_DISPLAY`, and experimental macOS support. On Linux Wayland-only sessions, Chromium is launched with
153
+ Ozone/Wayland flags. Termux/Android is not a Playwright platform; `--viewer
154
+ playwright` reports that the viewer is not available on that platform and does
155
+ not attempt to import Playwright. Use the default `system` viewer on Termux,
156
+ which opens URLs through `termux-open-url`.
157
+
158
+ If Playwright is selected on a supported desktop platform but the package or
159
+ browser binary is missing, the viewer returns install commands and does not
160
+ silently fall back to `system`.
161
+
162
+ ## Lifecycle
163
+
164
+ Browser page отправляет heartbeat и держит SSE connection. После закрытия
165
+ последней страницы server даёт 5 секунд на refresh/reconnect, очищает in-memory
166
+ registry и завершается. Если browser ни разу не подключился, startup instance
167
+ завершается через 30 секунд. `mdview --close` завершает его явно.
168
+
169
+ ## Pi Package Catalog
170
+
171
+ Package подготовлен для публикации в Pi Package Catalog через npm:
172
+
173
+ ```bash
174
+ pi install npm:@zkov/pi-md-viewer
175
+ ```
176
+
177
+ Catalog metadata находится в `package.json`:
178
+
179
+ ```json
180
+ {
181
+ "keywords": ["pi-package"],
182
+ "pi": {
183
+ "extensions": ["./dist/extensions/show-markdown.js"]
184
+ }
185
+ }
186
+ ```
187
+
188
+ Package регистрирует tool:
189
+
190
+ ```text
191
+ show_markdown(paths: string[], open?: boolean, viewer?: string, viewerCommand?: string)
192
+ ```
193
+
194
+ Публикационный tarball собирается из TypeScript build output и runtime assets.
195
+ Playwright не устанавливается автоматически; пользователи desktop-платформ
196
+ устанавливают его вручную, только если нужен `--viewer playwright`.
197
+
198
+ ## Подключение к pi из checkout
199
+
200
+ Установите trusted local checkout как pi package:
201
+
202
+ ```bash
203
+ pi install /absolute/path/to/package
204
+ pi list
205
+ ```
206
+
207
+ Для приватного git repository:
208
+
209
+ ```bash
210
+ pi install git:https://github.com/zkov96/ai-md-shower
211
+ ```
212
+
213
+ Example pi tool input:
214
+
215
+ ```json
216
+ {
217
+ "paths": ["README.md"],
218
+ "viewer": "playwright"
219
+ }
220
+ ```
221
+
222
+ Relative paths разрешаются от текущего рабочего каталога pi. `open: false`
223
+ добавляет `--no-open`; иначе browser открывается только при отсутствии активной
224
+ viewer page. Tool передаёт каждый path отдельным argv element, не использует
225
+ shell и никогда не устанавливает dependencies автоматически.
226
+
227
+ Pi extensions выполняются с полными правами пользователя. Устанавливайте package
228
+ только из checkout/repository, исходникам которого доверяете. Viewer ограничивает
229
+ HTTP bind loopback-интерфейсом и не позволяет browser API регистрировать
230
+ произвольные filesystem paths.
231
+
232
+ Удаление package из user settings:
233
+
234
+ ```bash
235
+ pi remove /absolute/path/to/package
236
+ ```
237
+
238
+ ## Публикация package
239
+
240
+ Проверить состав tarball:
241
+
242
+ ```bash
243
+ npm run build
244
+ npm pack --dry-run
245
+ ```
246
+
247
+ ## Разработка
248
+
249
+ Архитектура и пошаговый implementation plan находятся в
250
+ `docs/superpowers/specs/` и `docs/superpowers/plans/`.
package/bin/mdview.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/src-ts/main.js";
@@ -0,0 +1,137 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Type } from "typebox";
5
+ const showMarkdownSchema = Type.Object({
6
+ paths: Type.Array(Type.String({ description: "Markdown path, absolute or relative to the current project" }), {
7
+ minItems: 1,
8
+ description: "One or more Markdown files to show in the local browser viewer",
9
+ }),
10
+ open: Type.Optional(Type.Boolean({
11
+ description: "Open the browser when no viewer page is connected (default: true)",
12
+ })),
13
+ viewer: Type.Optional(Type.String({
14
+ description: "Viewer strategy: system, playwright, or a named viewer from config",
15
+ })),
16
+ viewerCommand: Type.Optional(Type.String({
17
+ description: "Ad-hoc viewer command: plain executable or JSON argv array with optional {url}",
18
+ })),
19
+ });
20
+ export function buildInvocation(cwd, extensionFile, input, options = {}) {
21
+ if (input.paths.length === 0) {
22
+ throw new Error("show_markdown requires at least one path");
23
+ }
24
+ const platform = options.platform ?? process.platform;
25
+ const pathForCwd = platform === "win32" ? path.win32 : path.posix;
26
+ const extensionPath = fileURLToPath(extensionFile);
27
+ const launcher = path.resolve(path.dirname(extensionPath), "..", "bin", "mdview.js");
28
+ const resolvedPaths = input.paths.map((supplied) => {
29
+ const normalized = supplied.startsWith("@") ? supplied.slice(1) : supplied;
30
+ if (!normalized)
31
+ throw new Error("show_markdown path cannot be empty");
32
+ return pathForCwd.resolve(cwd, normalized);
33
+ });
34
+ const args = [launcher, "--json", "--launched-from-pi", "--cwd", cwd];
35
+ if (options.projectTrusted)
36
+ args.push("--project-trusted");
37
+ if (input.open === false)
38
+ args.push("--no-open");
39
+ if (input.viewer)
40
+ args.push("--viewer", input.viewer);
41
+ if (input.viewerCommand)
42
+ args.push("--viewer-command", input.viewerCommand);
43
+ args.push(...resolvedPaths);
44
+ return { command: process.execPath, args };
45
+ }
46
+ export function parseViewerResult(stdout) {
47
+ let value;
48
+ try {
49
+ value = JSON.parse(stdout);
50
+ }
51
+ catch (error) {
52
+ throw new Error(`Viewer returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
53
+ }
54
+ if (!isViewerResult(value)) {
55
+ throw new Error("Viewer returned an invalid result object");
56
+ }
57
+ return value;
58
+ }
59
+ function isViewerResult(value) {
60
+ if (typeof value !== "object" || value === null)
61
+ return false;
62
+ const candidate = value;
63
+ return typeof candidate.ok === "boolean"
64
+ && typeof candidate.action === "string"
65
+ && (typeof candidate.url === "string" || candidate.url === null)
66
+ && Array.isArray(candidate.added)
67
+ && candidate.added.every((item) => typeof item === "string")
68
+ && Array.isArray(candidate.rejected)
69
+ && candidate.rejected.every(isRejectedPath)
70
+ && typeof candidate.viewerClients === "number";
71
+ }
72
+ function isRejectedPath(value) {
73
+ if (typeof value !== "object" || value === null)
74
+ return false;
75
+ const candidate = value;
76
+ return typeof candidate.path === "string"
77
+ && typeof candidate.code === "string"
78
+ && typeof candidate.message === "string";
79
+ }
80
+ function safeError(stderr, stdout) {
81
+ const diagnostic = stderr.trim();
82
+ if (diagnostic)
83
+ return diagnostic.slice(0, 4096);
84
+ const output = stdout.trim();
85
+ if (!output)
86
+ return "mdview failed without diagnostic output";
87
+ try {
88
+ const parsed = JSON.parse(output);
89
+ const message = parsed.message ?? parsed.error?.message;
90
+ if (typeof message === "string" && message.trim())
91
+ return message.trim().slice(0, 4096);
92
+ }
93
+ catch {
94
+ // Fall back to the bounded raw output below.
95
+ }
96
+ return output.slice(0, 4096);
97
+ }
98
+ function formatResult(result) {
99
+ const pieces = [`${result.action}: ${result.added.length} Markdown file(s)`];
100
+ if (result.url)
101
+ pieces.push(result.url);
102
+ if (result.rejected.length > 0) {
103
+ pieces.push(`${result.rejected.length} rejected: ${result.rejected.map((item) => item.path).join(", ")}`);
104
+ }
105
+ return pieces.join("\n");
106
+ }
107
+ export default function registerExtension(pi) {
108
+ pi.registerTool({
109
+ name: "show_markdown",
110
+ label: "Show Markdown",
111
+ description: "Open one or more local Markdown files in the local browser viewer. Reuses the active viewer and returns a loopback URL. Does not install dependencies.",
112
+ promptSnippet: "Open local Markdown files in a responsive browser viewer",
113
+ promptGuidelines: [
114
+ "Use show_markdown when the user asks to show, demonstrate, preview, or conveniently open one or more Markdown documents.",
115
+ ],
116
+ parameters: showMarkdownSchema,
117
+ async execute(_toolCallId, input, signal, _onUpdate, ctx) {
118
+ const projectTrusted = typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false;
119
+ const invocation = buildInvocation(ctx.cwd, import.meta.url, input, { projectTrusted });
120
+ const child = await pi.exec(invocation.command, invocation.args, {
121
+ signal,
122
+ timeout: 15_000,
123
+ });
124
+ if (child.code !== 0) {
125
+ throw new Error(safeError(child.stderr, child.stdout));
126
+ }
127
+ const result = parseViewerResult(child.stdout);
128
+ if (!result.ok) {
129
+ throw new Error(formatResult(result));
130
+ }
131
+ return {
132
+ content: [{ type: "text", text: formatResult(result) }],
133
+ details: result,
134
+ };
135
+ },
136
+ });
137
+ }
@@ -0,0 +1,219 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import net from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { loadEffectiveConfig } from "./config.js";
8
+ import { discover, portRangeFromEnv, sendFiles, shutdownInstance } from "./discovery.js";
9
+ import { FileRegistry } from "./registry.js";
10
+ import { MarkdownRenderer } from "./renderer.js";
11
+ import { ViewerServer } from "./server.js";
12
+ import { launchViewer } from "./viewer-launcher.js";
13
+ const STARTUP_TIMEOUT_MS = 5000;
14
+ function operationResult(payload) {
15
+ return { url: null, added: [], rejected: [], viewerClients: 0, ...payload };
16
+ }
17
+ function parseArgs(argv, cwd) {
18
+ const parsed = {
19
+ paths: [],
20
+ json: false,
21
+ noOpen: false,
22
+ status: false,
23
+ close: false,
24
+ serve: false,
25
+ launchedFromPi: false,
26
+ projectTrusted: false,
27
+ cwd,
28
+ };
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const arg = argv[index];
31
+ if (arg === "--json")
32
+ parsed.json = true;
33
+ else if (arg === "--no-open")
34
+ parsed.noOpen = true;
35
+ else if (arg === "--status")
36
+ parsed.status = true;
37
+ else if (arg === "--close")
38
+ parsed.close = true;
39
+ else if (arg === "--serve")
40
+ parsed.serve = true;
41
+ else if (arg === "--launched-from-pi")
42
+ parsed.launchedFromPi = true;
43
+ else if (arg === "--project-trusted")
44
+ parsed.projectTrusted = true;
45
+ else if (arg === "--port")
46
+ parsed.port = Number(requireValue(argv, ++index, arg));
47
+ else if (arg === "--cwd")
48
+ parsed.cwd = requireValue(argv, ++index, arg);
49
+ else if (arg === "--viewer")
50
+ parsed.viewer = requireValue(argv, ++index, arg);
51
+ else if (arg === "--viewer-command")
52
+ parsed.viewerCommand = requireValue(argv, ++index, arg);
53
+ else
54
+ parsed.paths.push(arg);
55
+ }
56
+ return parsed;
57
+ }
58
+ function requireValue(argv, index, flag) {
59
+ const value = argv[index];
60
+ if (value === undefined)
61
+ throw new Error(`${flag} requires a value`);
62
+ return value;
63
+ }
64
+ function printResult(result, jsonMode, io) {
65
+ if (jsonMode) {
66
+ io.stdout(JSON.stringify(result));
67
+ return;
68
+ }
69
+ if (!result.ok) {
70
+ io.stderr(result.message ?? "Viewer operation failed");
71
+ return;
72
+ }
73
+ io.stdout(result.message ?? result.url ?? result.action);
74
+ }
75
+ export async function main(argv, io = {}) {
76
+ const env = io.env ?? process.env;
77
+ const output = { stdout: io.stdout ?? console.log, stderr: io.stderr ?? console.error };
78
+ let args;
79
+ try {
80
+ args = parseArgs(argv, process.cwd());
81
+ if (args.serve)
82
+ return await serve(args.port, args.paths);
83
+ const result = await operate(args, env);
84
+ printResult(result, args.json, output);
85
+ return result.ok ? 0 : 1;
86
+ }
87
+ catch (error) {
88
+ const json = argv.includes("--json");
89
+ const result = operationResult({ ok: false, action: "error", message: error instanceof Error ? error.message : String(error) });
90
+ printResult(result, json, output);
91
+ return 1;
92
+ }
93
+ }
94
+ async function operate(args, env) {
95
+ const modes = Number(args.status) + Number(args.close);
96
+ if (modes > 1)
97
+ return operationResult({ ok: false, action: "error", message: "Choose only one control mode" });
98
+ const ports = portRangeFromEnv(env);
99
+ let instance = await discover(ports, env);
100
+ if (args.status) {
101
+ if (!instance)
102
+ return operationResult({ ok: false, action: "error", message: "Viewer is not running" });
103
+ return operationResult({ ok: true, action: "status", url: instance.url, viewerClients: instance.viewerClients, message: `PID ${instance.pid}; ${instance.files} file(s)` });
104
+ }
105
+ if (args.close) {
106
+ if (!instance)
107
+ return operationResult({ ok: false, action: "error", message: "Viewer is not running" });
108
+ await shutdownInstance(instance);
109
+ await waitForShutdown(instance, env);
110
+ return operationResult({ ok: true, action: "closed", url: instance.url });
111
+ }
112
+ if (args.paths.length === 0)
113
+ return operationResult({ ok: false, action: "error", message: "At least one Markdown file is required" });
114
+ const registry = new FileRegistry();
115
+ const added = registry.addMany(args.paths.map((item) => path.resolve(args.cwd, item)));
116
+ const accepted = [...added.added, ...added.existing].map((record) => record.path);
117
+ if (accepted.length === 0) {
118
+ return operationResult({ ok: false, action: "error", rejected: added.rejected, message: "No valid Markdown files" });
119
+ }
120
+ let action = "updated";
121
+ if (!instance) {
122
+ instance = await startDetached(ports, accepted, env);
123
+ if (!instance)
124
+ return operationResult({ ok: false, action: "error", added: accepted, rejected: added.rejected, message: "No free port in the configured viewer port range" });
125
+ action = "started";
126
+ }
127
+ else {
128
+ const response = await sendFiles(instance, accepted);
129
+ const serverRejected = Array.isArray(response.rejected) ? response.rejected : [];
130
+ added.rejected.push(...serverRejected);
131
+ instance = await discover([instance.port], env) ?? instance;
132
+ }
133
+ const result = operationResult({ ok: true, action, url: instance.url, added: accepted, rejected: added.rejected, viewerClients: instance.viewerClients });
134
+ if (!args.noOpen && instance.viewerClients === 0) {
135
+ const cliOverrides = { viewer: args.viewer, viewerCommand: args.viewerCommand };
136
+ const selection = loadEffectiveConfig({
137
+ cwd: args.cwd,
138
+ env,
139
+ platform: process.platform,
140
+ homeDir: os.homedir(),
141
+ launchedFromPi: args.launchedFromPi,
142
+ projectTrusted: args.projectTrusted,
143
+ piOverrides: cliOverrides,
144
+ cliOverrides,
145
+ });
146
+ await launchViewer({ url: instance.url, selection, platform: process.platform, env });
147
+ }
148
+ return result;
149
+ }
150
+ async function serve(port, paths) {
151
+ if (!port)
152
+ return 2;
153
+ const registry = new FileRegistry();
154
+ const result = registry.addMany(paths);
155
+ if (result.added.length === 0 && result.existing.length === 0)
156
+ return 2;
157
+ const server = new ViewerServer({ host: "127.0.0.1", port, registry, renderer: new MarkdownRenderer(), monitorLifecycle: true });
158
+ await server.start();
159
+ await new Promise((resolve) => {
160
+ process.once("SIGINT", () => { void server.stop().then(resolve); });
161
+ });
162
+ return 0;
163
+ }
164
+ async function startDetached(ports, paths, env) {
165
+ for (const port of ports) {
166
+ if (!(await portIsFree(port))) {
167
+ const winner = await discover([port], env);
168
+ if (winner) {
169
+ await sendFiles(winner, paths);
170
+ return await discover([port], env) ?? winner;
171
+ }
172
+ continue;
173
+ }
174
+ const child = spawn(process.execPath, [entrypointPath(), "--serve", "--port", String(port), ...paths], {
175
+ detached: true,
176
+ stdio: "ignore",
177
+ windowsHide: true,
178
+ env: { ...env, NODE_NO_WARNINGS: "1" },
179
+ });
180
+ child.unref();
181
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
182
+ while (Date.now() < deadline) {
183
+ const instance = await discover([port], env);
184
+ if (instance)
185
+ return instance;
186
+ if (child.exitCode !== null)
187
+ break;
188
+ await delay(50);
189
+ }
190
+ }
191
+ return await discover(ports, env);
192
+ }
193
+ function entrypointPath() {
194
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
195
+ const dist = path.join(root, "dist", "src-ts", "main.js");
196
+ if (existsSync(dist))
197
+ return dist;
198
+ return fileURLToPath(import.meta.url).replace(/cli\.(ts|js)$/, "main.js");
199
+ }
200
+ async function portIsFree(port) {
201
+ return new Promise((resolve) => {
202
+ const server = net.createServer();
203
+ server.once("error", () => resolve(false));
204
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
205
+ });
206
+ }
207
+ async function waitForShutdown(instance, env) {
208
+ const deadline = Date.now() + 3000;
209
+ while (Date.now() < deadline) {
210
+ const current = await discover([instance.port], env);
211
+ if (!current || current.pid !== instance.pid)
212
+ return;
213
+ await delay(50);
214
+ }
215
+ throw new Error(`Viewer PID ${instance.pid} did not stop within 3 seconds`);
216
+ }
217
+ function delay(ms) {
218
+ return new Promise((resolve) => setTimeout(resolve, ms));
219
+ }