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.
@@ -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
- export declare function readLastPort(root: string): number | undefined;
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 hub name a mainflow interface on this port reports, or null when nothing mainflow answers. */
18
- hubAt: (port: number) => Promise<string | null>;
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: boolean;
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 readLastPort, r as launchUi, t as PORT_SPAN } from "./launch-V6oqJDGG.js";
2
- export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readLastPort };
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 { B as linksInto, F as setFindingArchived, G as WriteError, K as DATA_DIR, L as setIssueArchived, T as transcriptPath, V as refIndex, W as ConflictError, b as setSessionArchived, d as setChecklistItem, f as setCriterion, h as updateTask, p as setMainlineStatus, u as setArchived, v as loadSessions, y as readSession, z as loadHub } from "./dist-DCafRe8c.js";
2
- import { DEFAULT_UI_PORT, syncHost } from "./host.js";
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, mkdirSync, openSync, readFileSync, readSync, statSync, watch, writeFileSync } from "node:fs";
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 { spawn, spawnSync } from "node:child_process";
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
- //#endregion
476
- //#region src/launch.ts
477
- /** How many ports above DEFAULT_UI_PORT are tried before giving up. */
478
- const PORT_SPAN = 100;
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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 { portCandidates as a, networkProbe as i, choosePort as n, readLastPort as o, launchUi as r, listProgress as s, PORT_SPAN as t };
484
+ export { serveUi };