mainflow 0.8.0 → 0.10.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 +6 -4
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +53 -10
- package/dist/{dist-DCafRe8c.js → host-BFGfpTDq.js} +194 -4
- package/dist/host.d.ts +9 -0
- package/dist/host.js +2 -89
- package/dist/launch-D5uHpqkh.js +184 -0
- package/dist/launch.d.ts +26 -4
- package/dist/launch.js +2 -2
- package/dist/progress-1BfPg2zN.js +10 -0
- package/dist/{launch-V6oqJDGG.js → ui-DbEmJiwO.js} +23 -123
- package/dist/web/assets/{index-oh5tFaO9.js → index-CZjPKVxY.js} +6 -6
- package/dist/web/assets/index-miwi9q2z.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-C3g64Xyp.css +0 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { n as DEFAULT_UI_PORT, nt as WriteError, rt as DATA_DIR, s as syncHost } from "./host-BFGfpTDq.js";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
//#region src/version.ts
|
|
7
|
+
/** This CLI's own version, from the package manifest one level above both src/ and dist/. */
|
|
8
|
+
const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/launch.ts
|
|
11
|
+
/** How many ports above DEFAULT_UI_PORT are tried before giving up. */
|
|
12
|
+
const PORT_SPAN = 100;
|
|
13
|
+
/** Where the last server started is remembered: machine-specific, so under the unversioned local/ folder. */
|
|
14
|
+
const uiRecordFile = (root) => join(root, DATA_DIR, "local", "ui.json");
|
|
15
|
+
function readUiRecord(root) {
|
|
16
|
+
try {
|
|
17
|
+
const record = JSON.parse(readFileSync(uiRecordFile(root), "utf8"));
|
|
18
|
+
if (!Number.isInteger(record.port)) return void 0;
|
|
19
|
+
return {
|
|
20
|
+
port: record.port,
|
|
21
|
+
...Number.isInteger(record.pid) && { pid: record.pid },
|
|
22
|
+
...typeof record.version === "string" && { version: record.version }
|
|
23
|
+
};
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function writeUiRecord(root, record) {
|
|
29
|
+
mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
|
|
30
|
+
writeFileSync(uiRecordFile(root), `${JSON.stringify(record)}\n`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
34
|
+
* the hub's configured port, then the last one used, then the default range.
|
|
35
|
+
*/
|
|
36
|
+
function portCandidates(options) {
|
|
37
|
+
if (options.explicit !== void 0) return [options.explicit];
|
|
38
|
+
const range = Array.from({ length: 100 }, (_, i) => DEFAULT_UI_PORT + i);
|
|
39
|
+
return [...new Set([
|
|
40
|
+
options.configured,
|
|
41
|
+
options.last,
|
|
42
|
+
...range
|
|
43
|
+
].filter((p) => p !== void 0))];
|
|
44
|
+
}
|
|
45
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
46
|
+
const networkProbe = {
|
|
47
|
+
async serverAt(port) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`http://localhost:${port}/api/hub`, { signal: AbortSignal.timeout(500) });
|
|
50
|
+
if (!res.ok) return null;
|
|
51
|
+
const body = await res.json();
|
|
52
|
+
if (typeof body.hub !== "string") return null;
|
|
53
|
+
return {
|
|
54
|
+
hub: body.hub,
|
|
55
|
+
...typeof body.version === "string" && { version: body.version }
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
isFree: (port) => new Promise((done) => {
|
|
62
|
+
const probe = createServer().once("error", () => done(false));
|
|
63
|
+
probe.listen(port, () => probe.close(() => done(true)));
|
|
64
|
+
}),
|
|
65
|
+
listenerOf(port) {
|
|
66
|
+
const out = spawnSync("lsof", [
|
|
67
|
+
"-nP",
|
|
68
|
+
`-iTCP:${port}`,
|
|
69
|
+
"-sTCP:LISTEN",
|
|
70
|
+
"-t"
|
|
71
|
+
], { encoding: "utf8" });
|
|
72
|
+
const pid = Number(out.stdout?.split("\n")[0]);
|
|
73
|
+
return out.status === 0 && Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
74
|
+
},
|
|
75
|
+
async stop(pid, port) {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, "SIGTERM");
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
for (let i = 0; i < 25; i++) {
|
|
82
|
+
await sleep(200);
|
|
83
|
+
if (await networkProbe.isFree(port)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
90
|
+
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
91
|
+
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
92
|
+
*/
|
|
93
|
+
async function choosePort(hub, candidates, probe = networkProbe) {
|
|
94
|
+
const servers = await Promise.all(candidates.map((port) => probe.serverAt(port)));
|
|
95
|
+
const at = candidates.findIndex((_, i) => servers[i]?.hub === hub);
|
|
96
|
+
if (at >= 0) return {
|
|
97
|
+
port: candidates[at],
|
|
98
|
+
running: servers[at].version ?? ""
|
|
99
|
+
};
|
|
100
|
+
for (const [i, port] of candidates.entries()) if (servers[i] === null && await probe.isFree(port)) return {
|
|
101
|
+
port,
|
|
102
|
+
running: void 0
|
|
103
|
+
};
|
|
104
|
+
const tried = candidates.length === 1 ? `port ${candidates[0]} is` : `ports ${candidates[0]} to ${candidates.at(-1)} are`;
|
|
105
|
+
throw new WriteError(`${tried} taken by other programs; free one, or pass --port <port>`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run `mainflow ui --no-open` detached, for a hook that must not wait: it reuses a server already serving this
|
|
109
|
+
* hub, replaces one of another version, or starts one. The CLI running now is the one started, so the hub's
|
|
110
|
+
* pinned version serves its interface.
|
|
111
|
+
*/
|
|
112
|
+
function startUiInBackground(root) {
|
|
113
|
+
const cli = process.argv[1];
|
|
114
|
+
if (!cli) return;
|
|
115
|
+
spawn(process.execPath, [
|
|
116
|
+
cli,
|
|
117
|
+
"ui",
|
|
118
|
+
"--no-open"
|
|
119
|
+
], {
|
|
120
|
+
cwd: root,
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: "ignore"
|
|
123
|
+
}).on("error", () => {}).unref();
|
|
124
|
+
}
|
|
125
|
+
/** Open a URL in the default browser without waiting for it. */
|
|
126
|
+
function openUrl(url) {
|
|
127
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
128
|
+
"cmd",
|
|
129
|
+
"/c",
|
|
130
|
+
"start",
|
|
131
|
+
"",
|
|
132
|
+
url
|
|
133
|
+
] : ["xdg-open", url];
|
|
134
|
+
spawn(command, args, {
|
|
135
|
+
detached: true,
|
|
136
|
+
stdio: "ignore"
|
|
137
|
+
}).on("error", () => console.log(`open ${url} in a browser`)).unref();
|
|
138
|
+
}
|
|
139
|
+
/** Start or reuse this hub's interface: the URL it is reached at, and the server when this call started one. */
|
|
140
|
+
async function launchUi(root, hub, configured, options) {
|
|
141
|
+
const log = options.log ?? console.log;
|
|
142
|
+
let explicit;
|
|
143
|
+
if (options.port !== void 0) {
|
|
144
|
+
explicit = Number(options.port);
|
|
145
|
+
if (!Number.isInteger(explicit) || explicit < 1024 || explicit > 65535) throw new WriteError("--port must be an integer from 1024 to 65535, e.g. --port 4320");
|
|
146
|
+
}
|
|
147
|
+
const probe = options.probe ?? networkProbe;
|
|
148
|
+
const record = readUiRecord(root);
|
|
149
|
+
const choice = await choosePort(hub, portCandidates({
|
|
150
|
+
explicit,
|
|
151
|
+
configured,
|
|
152
|
+
last: record?.port
|
|
153
|
+
}), probe);
|
|
154
|
+
let reuse = choice.running !== void 0;
|
|
155
|
+
if (reuse && choice.running !== CLI_VERSION) {
|
|
156
|
+
const from = choice.running || "unknown";
|
|
157
|
+
const pid = record?.port === choice.port && record.pid !== void 0 ? record.pid : probe.listenerOf(choice.port);
|
|
158
|
+
if (pid !== null && await probe.stop(pid, choice.port)) {
|
|
159
|
+
log(`! restarted: mainflow ${from} → ${CLI_VERSION}`);
|
|
160
|
+
reuse = false;
|
|
161
|
+
} else log(`! localhost:${choice.port} still runs mainflow ${from}, not ${CLI_VERSION}; stop it with \`kill $(lsof -ti tcp:${choice.port})\` and run mainflow ui again`);
|
|
162
|
+
}
|
|
163
|
+
let server;
|
|
164
|
+
if (!reuse) {
|
|
165
|
+
const { serveUi } = await import("./ui-DbEmJiwO.js");
|
|
166
|
+
server = await serveUi(root, choice.port);
|
|
167
|
+
writeUiRecord(root, {
|
|
168
|
+
port: choice.port,
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
version: CLI_VERSION
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const host = await syncHost(root, hub, choice.port, options.host);
|
|
174
|
+
if (host.url === null) log(`! host name not available: ${host.reason}`);
|
|
175
|
+
const url = host.url ?? `http://localhost:${choice.port}`;
|
|
176
|
+
log(`${reuse ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
|
|
177
|
+
if (options.open) openUrl(url);
|
|
178
|
+
return {
|
|
179
|
+
url,
|
|
180
|
+
server
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
export { portCandidates as a, CLI_VERSION as c, networkProbe as i, choosePort as n, readUiRecord as o, launchUi as r, startUiInBackground as s, PORT_SPAN as t };
|
package/dist/launch.d.ts
CHANGED
|
@@ -3,7 +3,13 @@ import { ServerType } from "@hono/node-server";
|
|
|
3
3
|
//#region src/launch.d.ts
|
|
4
4
|
/** How many ports above DEFAULT_UI_PORT are tried before giving up. */
|
|
5
5
|
export declare const PORT_SPAN = 100;
|
|
6
|
-
|
|
6
|
+
/** The server this hub last started: its port, and the process and version when it recorded them. */
|
|
7
|
+
export interface UiRecord {
|
|
8
|
+
port: number;
|
|
9
|
+
pid?: number;
|
|
10
|
+
version?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function readUiRecord(root: string): UiRecord | undefined;
|
|
7
13
|
/**
|
|
8
14
|
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
9
15
|
* the hub's configured port, then the last one used, then the default range.
|
|
@@ -13,15 +19,25 @@ export declare function portCandidates(options: {
|
|
|
13
19
|
configured?: number;
|
|
14
20
|
last?: number;
|
|
15
21
|
}): number[];
|
|
22
|
+
/** What a mainflow interface on a port reports; servers older than 0.8.1 report no version. */
|
|
23
|
+
export interface ServerInfo {
|
|
24
|
+
hub: string;
|
|
25
|
+
version?: string;
|
|
26
|
+
}
|
|
16
27
|
export interface PortProbe {
|
|
17
|
-
/** The
|
|
18
|
-
|
|
28
|
+
/** The mainflow interface on this port, or null when nothing mainflow answers. */
|
|
29
|
+
serverAt: (port: number) => Promise<ServerInfo | null>;
|
|
19
30
|
isFree: (port: number) => Promise<boolean>;
|
|
31
|
+
/** The process listening on this port, or null when it cannot be found. */
|
|
32
|
+
listenerOf: (port: number) => number | null;
|
|
33
|
+
/** Stop a process and wait until the port is free; false when it did not free up. */
|
|
34
|
+
stop: (pid: number, port: number) => Promise<boolean>;
|
|
20
35
|
}
|
|
21
36
|
export declare const networkProbe: PortProbe;
|
|
37
|
+
/** A port to serve on; `running` is the version already serving this hub there, '' when it reports none. */
|
|
22
38
|
export type PortChoice = {
|
|
23
39
|
port: number;
|
|
24
|
-
running
|
|
40
|
+
running?: string;
|
|
25
41
|
};
|
|
26
42
|
/**
|
|
27
43
|
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
@@ -29,6 +45,12 @@ export type PortChoice = {
|
|
|
29
45
|
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
30
46
|
*/
|
|
31
47
|
export declare function choosePort(hub: string, candidates: number[], probe?: PortProbe): Promise<PortChoice>;
|
|
48
|
+
/**
|
|
49
|
+
* Run `mainflow ui --no-open` detached, for a hook that must not wait: it reuses a server already serving this
|
|
50
|
+
* hub, replaces one of another version, or starts one. The CLI running now is the one started, so the hub's
|
|
51
|
+
* pinned version serves its interface.
|
|
52
|
+
*/
|
|
53
|
+
export declare function startUiInBackground(root: string): void;
|
|
32
54
|
export interface LaunchOptions {
|
|
33
55
|
port?: string;
|
|
34
56
|
open: boolean;
|
package/dist/launch.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as portCandidates, i as networkProbe, n as choosePort, o as
|
|
2
|
-
export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates,
|
|
1
|
+
import { a as portCandidates, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-D5uHpqkh.js";
|
|
2
|
+
export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord, startUiInBackground };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region ../core/dist/progress.js
|
|
2
|
+
function listProgress(items) {
|
|
3
|
+
const counted = items.filter((item) => !item.dropped);
|
|
4
|
+
return {
|
|
5
|
+
done: counted.filter((item) => item.done).length,
|
|
6
|
+
total: counted.length
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
export { listProgress as t };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { C as updateTask, D as readSession, E as loadSessions, J as setIssueArchived, K as setFindingArchived, N as transcriptPath, O as setSessionArchived, Q as refIndex, X as loadHub, Z as linksInto, _ as setArchived, a as openInApp, b as setMainlineStatus, nt as WriteError, rt as DATA_DIR, tt as ConflictError, v as setChecklistItem, x as setMainlineTitle, y as setCriterion } from "./host-BFGfpTDq.js";
|
|
2
|
+
import { t as listProgress } from "./progress-1BfPg2zN.js";
|
|
3
|
+
import { c as CLI_VERSION } from "./launch-D5uHpqkh.js";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
|
-
import { closeSync, existsSync,
|
|
5
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watch } from "node:fs";
|
|
5
6
|
import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
8
|
-
import { createServer } from "node:net";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { serve } from "@hono/node-server";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { Hono } from "hono";
|
|
@@ -205,15 +205,6 @@ function attributeTrace(trace, actions, now = Date.now()) {
|
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
207
|
//#endregion
|
|
208
|
-
//#region ../core/dist/progress.js
|
|
209
|
-
function listProgress(items) {
|
|
210
|
-
const counted = items.filter((item) => !item.dropped);
|
|
211
|
-
return {
|
|
212
|
-
done: counted.filter((item) => item.done).length,
|
|
213
|
-
total: counted.length
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
//#endregion
|
|
217
208
|
//#region src/server.ts
|
|
218
209
|
/** Every reference with a hub prefix in the documents of `hub`: links, origins and replaced items. */
|
|
219
210
|
function prefixedRefs(hub) {
|
|
@@ -323,6 +314,7 @@ function createApp(root) {
|
|
|
323
314
|
const hub = loadHub(root);
|
|
324
315
|
const body = {
|
|
325
316
|
hub: hub.config.hub,
|
|
317
|
+
version: CLI_VERSION,
|
|
326
318
|
repos: hub.config.repos,
|
|
327
319
|
mainlines: hub.mainlines.map(summarize),
|
|
328
320
|
openIssues: hub.issues.filter((i) => i.status !== "closed").length,
|
|
@@ -415,6 +407,10 @@ function createApp(root) {
|
|
|
415
407
|
const { archived, version } = await c.req.json();
|
|
416
408
|
return c.json(setArchived(root, c.req.param("m"), archived, version));
|
|
417
409
|
});
|
|
410
|
+
app.post("/api/mainlines/:m/title", async (c) => {
|
|
411
|
+
const { title, version } = await c.req.json();
|
|
412
|
+
return c.json(setMainlineTitle(root, c.req.param("m"), title, version));
|
|
413
|
+
});
|
|
418
414
|
app.post("/api/mainlines/:m/status", async (c) => {
|
|
419
415
|
const { status, version } = await c.req.json();
|
|
420
416
|
return c.json(setMainlineStatus(root, c.req.param("m"), status, version));
|
|
@@ -456,9 +452,16 @@ function webDir() {
|
|
|
456
452
|
* Resolves once the port is listening. A port taken between the free-port probe and here rejects with a
|
|
457
453
|
* WriteError instead of Node's unhandled 'error' event and stack trace.
|
|
458
454
|
*/
|
|
459
|
-
function serveUi(root, port) {
|
|
455
|
+
function serveUi(root, port, open = openInApp) {
|
|
460
456
|
const app = createApp(root);
|
|
461
457
|
const dist = webDir();
|
|
458
|
+
const hub = loadHub(root, { remotes: false }).config.hub;
|
|
459
|
+
app.get("/open/*", (c) => {
|
|
460
|
+
const url = new URL(c.req.url);
|
|
461
|
+
const target = `${url.pathname.slice(5) || "/"}${url.search}`;
|
|
462
|
+
const opened = open(hub, target);
|
|
463
|
+
return c.html(openPage(target, opened));
|
|
464
|
+
});
|
|
462
465
|
app.get("*", (c) => {
|
|
463
466
|
let file = join(dist, normalize(decodeURIComponent(c.req.path)));
|
|
464
467
|
if (!file.startsWith(dist + sep) || !existsSync(file) || !statSync(file).isFile()) file = join(dist, "index.html");
|
|
@@ -472,113 +475,10 @@ function serveUi(root, port) {
|
|
|
472
475
|
server.once("error", (error) => fail(error.code === "EADDRINUSE" ? new WriteError(`port ${port} is already in use; run mainflow ui again to pick another`) : error));
|
|
473
476
|
});
|
|
474
477
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
/** Where the last port used is remembered: machine-specific, so under the unversioned local/ folder. */
|
|
480
|
-
const lastPortFile = (root) => join(root, DATA_DIR, "local", "ui.json");
|
|
481
|
-
function readLastPort(root) {
|
|
482
|
-
try {
|
|
483
|
-
const port = JSON.parse(readFileSync(lastPortFile(root), "utf8")).port;
|
|
484
|
-
return Number.isInteger(port) ? port : void 0;
|
|
485
|
-
} catch {
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
function writeLastPort(root, port) {
|
|
490
|
-
mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
|
|
491
|
-
writeFileSync(lastPortFile(root), `${JSON.stringify({ port })}\n`);
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
495
|
-
* the hub's configured port, then the last one used, then the default range.
|
|
496
|
-
*/
|
|
497
|
-
function portCandidates(options) {
|
|
498
|
-
if (options.explicit !== void 0) return [options.explicit];
|
|
499
|
-
const range = Array.from({ length: 100 }, (_, i) => DEFAULT_UI_PORT + i);
|
|
500
|
-
return [...new Set([
|
|
501
|
-
options.configured,
|
|
502
|
-
options.last,
|
|
503
|
-
...range
|
|
504
|
-
].filter((p) => p !== void 0))];
|
|
505
|
-
}
|
|
506
|
-
const networkProbe = {
|
|
507
|
-
async hubAt(port) {
|
|
508
|
-
try {
|
|
509
|
-
const res = await fetch(`http://localhost:${port}/api/hub`, { signal: AbortSignal.timeout(500) });
|
|
510
|
-
if (!res.ok) return null;
|
|
511
|
-
const body = await res.json();
|
|
512
|
-
return typeof body.hub === "string" ? body.hub : null;
|
|
513
|
-
} catch {
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
},
|
|
517
|
-
isFree: (port) => new Promise((done) => {
|
|
518
|
-
const probe = createServer().once("error", () => done(false));
|
|
519
|
-
probe.listen(port, () => probe.close(() => done(true)));
|
|
520
|
-
})
|
|
521
|
-
};
|
|
522
|
-
/**
|
|
523
|
-
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
524
|
-
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
525
|
-
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
526
|
-
*/
|
|
527
|
-
async function choosePort(hub, candidates, probe = networkProbe) {
|
|
528
|
-
const owners = await Promise.all(candidates.map((port) => probe.hubAt(port)));
|
|
529
|
-
const running = candidates.find((_, i) => owners[i] === hub);
|
|
530
|
-
if (running !== void 0) return {
|
|
531
|
-
port: running,
|
|
532
|
-
running: true
|
|
533
|
-
};
|
|
534
|
-
for (const [i, port] of candidates.entries()) if (owners[i] === null && await probe.isFree(port)) return {
|
|
535
|
-
port,
|
|
536
|
-
running: false
|
|
537
|
-
};
|
|
538
|
-
const tried = candidates.length === 1 ? `port ${candidates[0]} is` : `ports ${candidates[0]} to ${candidates.at(-1)} are`;
|
|
539
|
-
throw new WriteError(`${tried} taken by other programs; free one, or pass --port <port>`);
|
|
540
|
-
}
|
|
541
|
-
/** Open a URL in the default browser without waiting for it. */
|
|
542
|
-
function openUrl(url) {
|
|
543
|
-
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
544
|
-
"cmd",
|
|
545
|
-
"/c",
|
|
546
|
-
"start",
|
|
547
|
-
"",
|
|
548
|
-
url
|
|
549
|
-
] : ["xdg-open", url];
|
|
550
|
-
spawn(command, args, {
|
|
551
|
-
detached: true,
|
|
552
|
-
stdio: "ignore"
|
|
553
|
-
}).on("error", () => console.log(`open ${url} in a browser`)).unref();
|
|
554
|
-
}
|
|
555
|
-
/** Start or reuse this hub's interface: the URL it is reached at, and the server when this call started one. */
|
|
556
|
-
async function launchUi(root, hub, configured, options) {
|
|
557
|
-
const log = options.log ?? console.log;
|
|
558
|
-
let explicit;
|
|
559
|
-
if (options.port !== void 0) {
|
|
560
|
-
explicit = Number(options.port);
|
|
561
|
-
if (!Number.isInteger(explicit) || explicit < 1024 || explicit > 65535) throw new WriteError("--port must be an integer from 1024 to 65535, e.g. --port 4320");
|
|
562
|
-
}
|
|
563
|
-
const choice = await choosePort(hub, portCandidates({
|
|
564
|
-
explicit,
|
|
565
|
-
configured,
|
|
566
|
-
last: readLastPort(root)
|
|
567
|
-
}), options.probe);
|
|
568
|
-
let server;
|
|
569
|
-
if (!choice.running) {
|
|
570
|
-
server = await serveUi(root, choice.port);
|
|
571
|
-
writeLastPort(root, choice.port);
|
|
572
|
-
}
|
|
573
|
-
const host = await syncHost(root, hub, choice.port, options.host);
|
|
574
|
-
if (host.url === null) log(`! host name not available: ${host.reason}`);
|
|
575
|
-
const url = host.url ?? `http://localhost:${choice.port}`;
|
|
576
|
-
log(`${choice.running ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
|
|
577
|
-
if (options.open) openUrl(url);
|
|
578
|
-
return {
|
|
579
|
-
url,
|
|
580
|
-
server
|
|
581
|
-
};
|
|
478
|
+
/** The page an /open link leaves in the browser: blank in the interface's colours, or a jump to the address. */
|
|
479
|
+
function openPage(target, opened) {
|
|
480
|
+
const escaped = target.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
481
|
+
return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark">${opened ? "" : `<meta http-equiv="refresh" content="0;url=${escaped}">`}<title>mainflow</title><style>html{background:#fff}@media (prefers-color-scheme: dark){html{background:#111113}}</style>`;
|
|
582
482
|
}
|
|
583
483
|
//#endregion
|
|
584
|
-
export {
|
|
484
|
+
export { serveUi };
|