@scoutmesh/viewer 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { once } from "node:events";
5
+ import {
6
+ closeSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+ import { setTimeout as delay } from "node:timers/promises";
17
+
18
+ import { openExternalBrowser } from "./external-browser.js";
19
+ import { createViewerServer } from "./server.js";
20
+ import { writeStateFile } from "./state-file.js";
21
+ import { ViewStore } from "./store.js";
22
+ import { launchPackage, managedCommand } from "./update-manager.js";
23
+ import { viewerVersion } from "./version.js";
24
+
25
+ const stateFile = path.resolve(
26
+ process.env.VIEWER_STATE_FILE ??
27
+ path.join(
28
+ process.env.SCOUTMESH_VIEWER_HOME ??
29
+ path.join(homedir(), ".scoutmesh", "workspaces"),
30
+ "state.json"
31
+ )
32
+ );
33
+ const runtimeFile = `${stateFile}.runtime.json`;
34
+ const lockFile = `${stateFile}.lock`;
35
+ const logFile = `${stateFile}.log`;
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+ function runtime() {
44
+ if (!existsSync(runtimeFile)) {
45
+ return undefined;
46
+ }
47
+ const value = JSON.parse(readFileSync(runtimeFile, "utf-8"));
48
+ if (
49
+ !value ||
50
+ typeof value !== "object" ||
51
+ !("pid" in value) ||
52
+ !("token" in value) ||
53
+ !("url" in value) ||
54
+ !Number.isSafeInteger(value.pid) ||
55
+ typeof value.pid !== "number" ||
56
+ value.pid < 1 ||
57
+ typeof value.token !== "string" ||
58
+ !/^[a-f0-9]{64}$/u.test(value.token) ||
59
+ typeof value.url !== "string" ||
60
+ !/^http:\/\/127\.0\.0\.1:\d+$/u.test(value.url)
61
+ ) {
62
+ throw new Error(
63
+ `Invalid runtime file: ${runtimeFile}. Stop the viewer before repairing it.`
64
+ );
65
+ }
66
+ return { pid: value.pid, token: value.token, url: value.url };
67
+ }
68
+
69
+ async function call(
70
+ info ,
71
+ route ,
72
+ method = "GET",
73
+ body
74
+ ) {
75
+ return await fetch(`${info.url}${route}`, {
76
+ ...(body === undefined ? {} : { body }),
77
+ headers: {
78
+ Authorization: `Bearer ${info.token}`,
79
+ "Content-Type": "application/json",
80
+ },
81
+ method,
82
+ redirect: "error",
83
+ signal: AbortSignal.timeout(3000),
84
+ });
85
+ }
86
+
87
+ async function running() {
88
+ const info = runtime();
89
+ if (!info) {
90
+ return undefined;
91
+ }
92
+ try {
93
+ const response = await call(info, "/api/health");
94
+ const health = await response.json();
95
+ if (
96
+ response.ok &&
97
+ health &&
98
+ typeof health === "object" &&
99
+ "service" in health &&
100
+ "pid" in health &&
101
+ health.service === "scoutmesh-viewer" &&
102
+ health.pid === info.pid
103
+ ) {
104
+ return {
105
+ ...info,
106
+ version:
107
+ "version" in health && typeof health.version === "string"
108
+ ? health.version
109
+ : "unknown",
110
+ };
111
+ }
112
+ } catch {
113
+ /* Check process ownership before treating an unreachable server as stopped. */
114
+ }
115
+ if (alive(info.pid)) {
116
+ throw new Error(
117
+ `Viewer process ${info.pid} exists, but its authenticated endpoint at ${info.url} could not be reached or verified. Localhost access may require agent permission. Retry this CLI with approved local access; do not start a second server or read credential files manually.`
118
+ );
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ function alive(pid ) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ } catch (error) {
128
+ return !(
129
+ error instanceof Error &&
130
+ "code" in error &&
131
+ error.code === "ESRCH"
132
+ );
133
+ }
134
+ }
135
+
136
+ function acquireLock() {
137
+ mkdirSync(path.dirname(stateFile), { mode: 0o700, recursive: true });
138
+ const claim = () => {
139
+ const descriptor = openSync(lockFile, "wx", 0o600);
140
+ try {
141
+ writeFileSync(descriptor, String(process.pid));
142
+ } finally {
143
+ closeSync(descriptor);
144
+ }
145
+ };
146
+ try {
147
+ claim();
148
+ return;
149
+ } catch (error) {
150
+ if (
151
+ !(error instanceof Error) ||
152
+ !("code" in error) ||
153
+ error.code !== "EEXIST"
154
+ ) {
155
+ throw error;
156
+ }
157
+ }
158
+ // Serialize stale-lock recovery so simultaneous starts cannot remove a new owner.
159
+ const recoveryFile = `${lockFile}.recovery`;
160
+ const recovery = openSync(recoveryFile, "wx", 0o600);
161
+ try {
162
+ if (existsSync(lockFile)) {
163
+ const owner = Number(readFileSync(lockFile, "utf-8"));
164
+ if (!Number.isSafeInteger(owner) || owner < 1 || alive(owner)) {
165
+ throw new Error(
166
+ `Another viewer owns ${lockFile}. Use status or stop; do not start a second writer.`
167
+ );
168
+ }
169
+ rmSync(lockFile);
170
+ }
171
+ claim();
172
+ } finally {
173
+ closeSync(recovery);
174
+ rmSync(recoveryFile, { force: true });
175
+ }
176
+ }
177
+
178
+ function shutdown() {
179
+ process.exit(0);
180
+ }
181
+
182
+ async function serve() {
183
+ const port = Number(process.env.VIEWER_PORT ?? 4317);
184
+ if (!Number.isInteger(port) || (port !== 0 && port < 1024) || port > 65_535) {
185
+ throw new Error(
186
+ "VIEWER_PORT must be 0 (automatic) or an integer between 1024 and 65535."
187
+ );
188
+ }
189
+ acquireLock();
190
+ const token = randomBytes(32).toString("hex");
191
+ const clean = () => {
192
+ // Only this lock owner removes its runtime metadata.
193
+ if (
194
+ existsSync(lockFile) &&
195
+ readFileSync(lockFile, "utf-8") === String(process.pid)
196
+ ) {
197
+ rmSync(runtimeFile, { force: true });
198
+ rmSync(lockFile, { force: true });
199
+ }
200
+ };
201
+ process.once("exit", clean);
202
+ process.once("SIGTERM", shutdown);
203
+ process.once("SIGINT", shutdown);
204
+ const store = new ViewStore(stateFile);
205
+ const server = createViewerServer(openExternalBrowser, store, {
206
+ shutdown,
207
+ token,
208
+ });
209
+ try {
210
+ server.listen(port, "127.0.0.1");
211
+ await once(server, "listening");
212
+ const address = server.address();
213
+ if (!address || typeof address === "string") {
214
+ throw new Error("Viewer did not bind a local TCP port.");
215
+ }
216
+ writeStateFile(runtimeFile, {
217
+ pid: process.pid,
218
+ token,
219
+ url: `http://127.0.0.1:${address.port}`,
220
+ });
221
+ } catch (error) {
222
+ if (server.listening) {
223
+ server.closeAllConnections();
224
+ server.close();
225
+ }
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ function printStatus(info , reused = false) {
231
+ console.info(
232
+ JSON.stringify(
233
+ info
234
+ ? {
235
+ pid: info.pid,
236
+ reused,
237
+ stateFile,
238
+ status: "running",
239
+ url: info.url,
240
+ version: info.version,
241
+ }
242
+ : { stateFile, status: "stopped", version: viewerVersion }
243
+ )
244
+ );
245
+ }
246
+
247
+ async function start() {
248
+ const existing = await running();
249
+ if (existing) {
250
+ if (existing.version !== viewerVersion) {
251
+ throw new Error(
252
+ `Viewer ${existing.version} is already running; this CLI is ${viewerVersion}. Use the managed launcher to upgrade, or stop the previous viewer before starting this version.`
253
+ );
254
+ }
255
+ printStatus(existing, true);
256
+ return;
257
+ }
258
+ mkdirSync(path.dirname(stateFile), { mode: 0o700, recursive: true });
259
+ const log = openSync(logFile, "a", 0o600);
260
+ const child = spawn(process.execPath, [import.meta.filename, "serve"], {
261
+ detached: true,
262
+ env: { ...process.env, VIEWER_STATE_FILE: stateFile },
263
+ stdio: ["ignore", log, log],
264
+ windowsHide: true,
265
+ });
266
+ closeSync(log);
267
+ let spawnError ;
268
+ child.once("error", (error) => {
269
+ spawnError = error;
270
+ });
271
+ child.unref();
272
+ const info = await waitForStart(() => spawnError);
273
+ printStatus(info, info.pid !== child.pid);
274
+ }
275
+
276
+ async function waitForStart(
277
+ spawnError ,
278
+ remaining = 60
279
+ ) {
280
+ const error = spawnError();
281
+ if (error) {
282
+ throw error;
283
+ }
284
+ const info = await running();
285
+ if (info) {
286
+ return info;
287
+ }
288
+ if (remaining === 0) {
289
+ throw new Error(
290
+ `Viewer did not start. Check ${logFile}. Port ${process.env.VIEWER_PORT ?? 4317} may be in use; set VIEWER_PORT to a free port. Saved state was not cleared.`
291
+ );
292
+ }
293
+ await delay(100);
294
+ return await waitForStart(spawnError, remaining - 1);
295
+ }
296
+
297
+ async function waitForStop(remaining = 50) {
298
+ if (!existsSync(lockFile)) {
299
+ return;
300
+ }
301
+ if (remaining === 0) {
302
+ throw new Error("Viewer is still stopping. Check status.");
303
+ }
304
+ await delay(100);
305
+ await waitForStop(remaining - 1);
306
+ }
307
+
308
+ async function stop(info ) {
309
+ if (info) {
310
+ const response = await call(info, "/api/shutdown", "POST");
311
+ if (!response.ok) {
312
+ throw new Error(`Stop failed: HTTP ${response.status}`);
313
+ }
314
+ await waitForStop();
315
+ }
316
+ printStatus();
317
+ }
318
+
319
+ async function sendRequest(
320
+ info ,
321
+ method ,
322
+ route ,
323
+ bodyFile
324
+ ) {
325
+ if (
326
+ !method ||
327
+ !["GET", "POST", "PATCH", "PUT", "DELETE"].includes(method) ||
328
+ !route ||
329
+ !/^\/api\/views(?:\/[\w-]+(?:\/state)?)?$/u.test(route)
330
+ ) {
331
+ throw new Error(
332
+ "Use request GET|POST|PATCH|PUT|DELETE /api/views[/id[/state]] [body.json]."
333
+ );
334
+ }
335
+ const body = bodyFile
336
+ ? readFileSync(path.resolve(bodyFile), "utf-8")
337
+ : undefined;
338
+ if (body !== undefined) {
339
+ JSON.parse(body);
340
+ }
341
+ const response = await call(info, route, method, body);
342
+ console.info(await response.text());
343
+ if (!response.ok) {
344
+ process.exitCode = 1;
345
+ }
346
+ }
347
+
348
+ async function main() {
349
+ const [command = "help", method, route, bodyFile] = process.argv.slice(2);
350
+ if (command === "managed") {
351
+ if (!method) {
352
+ throw new Error("Missing managed installation path.");
353
+ }
354
+ await managedCommand(path.resolve(method), process.argv.slice(4));
355
+ return;
356
+ }
357
+ if (command === "--version") {
358
+ console.info(viewerVersion);
359
+ return;
360
+ }
361
+ if (command === "serve") {
362
+ await serve();
363
+ return;
364
+ }
365
+ if (command === "launch") {
366
+ if (process.env.VIEWER_STATE_FILE) {
367
+ throw new Error(
368
+ "launch uses SCOUTMESH_VIEWER_HOME; unset VIEWER_STATE_FILE to avoid switching workspaces."
369
+ );
370
+ }
371
+ console.info(
372
+ JSON.stringify(
373
+ await launchPackage(
374
+ path.resolve(import.meta.dirname, ".."),
375
+ path.dirname(stateFile)
376
+ )
377
+ )
378
+ );
379
+ return;
380
+ }
381
+ if (command === "start") {
382
+ await start();
383
+ return;
384
+ }
385
+ if (command === "help" || command === "--help") {
386
+ console.info(
387
+ "scoutmesh-viewer launch | start | status | stop | token | skill | request METHOD /api/path [body.json]\nRequires Node.js 24+. Settings: SCOUTMESH_VIEWER_HOME, VIEWER_STATE_FILE, VIEWER_PORT.\nlaunch installs/upgrades a durable npm workspace with backup/rollback; start only reuses the same version. token is a local credential: never share it in chat or URLs."
388
+ );
389
+ return;
390
+ }
391
+ if (command === "skill") {
392
+ console.info(
393
+ readFileSync(
394
+ new URL("../skills/local-candidate-viewer/SKILL.md", import.meta.url),
395
+ "utf-8"
396
+ )
397
+ );
398
+ return;
399
+ }
400
+ const info = await running();
401
+ if (command === "status") {
402
+ printStatus(info);
403
+ return;
404
+ }
405
+ if (command === "stop") {
406
+ await stop(info);
407
+ return;
408
+ }
409
+ if (!info) {
410
+ throw new Error("Viewer is not running. Run scoutmesh-viewer start first.");
411
+ }
412
+ if (command === "token") {
413
+ console.info(info.token);
414
+ return;
415
+ }
416
+ if (command === "request") {
417
+ await sendRequest(info, method, route, bodyFile);
418
+ return;
419
+ }
420
+ throw new Error(`Unknown command: ${command}. Run scoutmesh-viewer --help.`);
421
+ }
422
+
423
+ try {
424
+ await main();
425
+ } catch (error) {
426
+ console.error(
427
+ error instanceof Error ? error.message : "Viewer command failed."
428
+ );
429
+ process.exitCode = 1;
430
+ }
@@ -0,0 +1,62 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { InputError } from "./input-error.js";
5
+
6
+ const execute = promisify(execFile);
7
+
8
+ export function externalUrl(value ) {
9
+ if (typeof value !== "string" || value.length > 8192) {
10
+ throw new InputError("Provide an HTTP or HTTPS link.");
11
+ }
12
+ let url ;
13
+ try {
14
+ url = new URL(value);
15
+ } catch {
16
+ throw new InputError("Provide a valid HTTP or HTTPS link.");
17
+ }
18
+ if (
19
+ !["https:", "http:"].includes(url.protocol) ||
20
+ url.username ||
21
+ url.password
22
+ ) {
23
+ throw new InputError(
24
+ "Only HTTP and HTTPS links without credentials can be opened."
25
+ );
26
+ }
27
+ return url.href;
28
+ }
29
+
30
+ export async function openExternalBrowser(url ) {
31
+ const destination = externalUrl(url);
32
+ if (!["darwin", "win32"].includes(process.platform)) {
33
+ throw new InputError(
34
+ "External browser opening requires macOS or Windows.",
35
+ 501
36
+ );
37
+ }
38
+ try {
39
+ // Pass the URL as one argument. Never interpret candidate data as shell code.
40
+ if (process.platform === "darwin") {
41
+ await execute("/usr/bin/open", ["--", destination], { timeout: 10_000 });
42
+ } else {
43
+ const command = Buffer.from(
44
+ "$ErrorActionPreference = 'Stop'; Start-Process -FilePath $env:SCOUTMESH_EXTERNAL_URL",
45
+ "utf16le"
46
+ ).toString("base64");
47
+ await execute(
48
+ "powershell.exe",
49
+ ["-NoProfile", "-NonInteractive", "-EncodedCommand", command],
50
+ {
51
+ env: { ...process.env, SCOUTMESH_EXTERNAL_URL: destination },
52
+ timeout: 10_000,
53
+ }
54
+ );
55
+ }
56
+ } catch {
57
+ throw new InputError(
58
+ "Could not open your default browser. Try the link again.",
59
+ 502
60
+ );
61
+ }
62
+ }
@@ -0,0 +1,8 @@
1
+ export class InputError extends Error {
2
+ name = "InputError";
3
+ status ;
4
+ constructor(message , status = 400) {
5
+ super(message);
6
+ this.status = status;
7
+ }
8
+ }
@@ -0,0 +1,39 @@
1
+ // Stable entry point: read the active package on every invocation.
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const root = import.meta.dirname;
7
+ const config = JSON.parse(
8
+ readFileSync(path.join(root, "launcher.json"), "utf-8").replace(
9
+ /^\uFEFF/u,
10
+ ""
11
+ )
12
+ );
13
+ if (
14
+ !config ||
15
+ typeof config !== "object" ||
16
+ !("cli" in config) ||
17
+ typeof config.cli !== "string" ||
18
+ !("node" in config) ||
19
+ typeof config.node !== "string"
20
+ ) {
21
+ throw new Error(
22
+ "Invalid launcher configuration. Rerun the official bootstrap."
23
+ );
24
+ }
25
+ const child = spawn(
26
+ config.node,
27
+ [config.cli, "managed", root, ...process.argv.slice(2)],
28
+ {
29
+ stdio: "inherit",
30
+ windowsHide: true,
31
+ }
32
+ );
33
+ child.once("error", (error) => {
34
+ console.error(error.message);
35
+ process.exitCode = 1;
36
+ });
37
+ child.once("exit", (code) => {
38
+ process.exitCode = code ?? 1;
39
+ });