@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 +21 -0
- package/README.md +250 -0
- package/bin/mdview.js +2 -0
- package/dist/extensions/show-markdown.js +137 -0
- package/dist/src-ts/cli.js +219 -0
- package/dist/src-ts/config.js +114 -0
- package/dist/src-ts/discovery.js +95 -0
- package/dist/src-ts/lifecycle.js +54 -0
- package/dist/src-ts/main.js +5 -0
- package/dist/src-ts/platform.js +26 -0
- package/dist/src-ts/registry.js +81 -0
- package/dist/src-ts/renderer.js +67 -0
- package/dist/src-ts/server.js +326 -0
- package/dist/src-ts/types.js +5 -0
- package/dist/src-ts/viewer-command.js +31 -0
- package/dist/src-ts/viewer-launcher.js +107 -0
- package/extensions/show-markdown.ts +159 -0
- package/package.json +78 -0
- package/src/pi_md_viewer/static/viewer-actions.js +6 -0
- package/src/pi_md_viewer/static/viewer.css +252 -0
- package/src/pi_md_viewer/static/viewer.js +206 -0
- package/src/pi_md_viewer/templates/viewer.html +49 -0
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,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
|
+
}
|