mainflow 0.34.0 → 0.35.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 +81 -9
- package/dist/{dist-IdbnCDqM.js → dist-BdvWGTsb.js} +47 -4
- package/dist/facts.d.ts +67 -13
- package/dist/facts.js +63 -14
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +63 -9
- package/dist/hooks.js +3 -3
- package/dist/{host-79T-_NMu.js → host-bnHYIwuY.js} +476 -199
- package/dist/host.d.ts +9 -0
- package/dist/host.js +2 -2
- package/dist/{index-ihgXABQc.d.ts → index-DgYB5u1f.d.ts} +52 -1
- package/dist/launch-D80CYPcm.js +327 -0
- package/dist/launch.d.ts +50 -4
- package/dist/launch.js +2 -2
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/{operation--fJnUqvA.js → operation-DVcJgKM9.js} +2 -2
- package/dist/{ui-BcXPk-0P.js → ui-C4h4iLDu.js} +132 -12
- package/dist/web/assets/index-BDnlbKcr.js +82 -0
- package/dist/web/assets/index-Z6UfQ6pW.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/launch-DQVyIbpf.js +0 -205
- package/dist/web/assets/index-CC7l6bNN.css +0 -1
- package/dist/web/assets/index-CDNCPMjD.js +0 -82
package/dist/host.d.ts
CHANGED
|
@@ -4,9 +4,17 @@ export declare const hostName: (hub: string) => string;
|
|
|
4
4
|
export declare function siteBlock(hub: string, port: number): string;
|
|
5
5
|
/** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
|
|
6
6
|
export declare function caddySites(): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Remove the mainflow-* links in `sites` whose target is gone. One such link makes every Caddy reload fail,
|
|
9
|
+
* because the Caddyfile imports the whole directory; hubs deleted without being asked to stop (test hubs)
|
|
10
|
+
* leave them behind. Other names and links with a target are not touched. Returns the names removed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function removeDanglingLinks(sites: string): string[];
|
|
7
13
|
export interface HostOptions {
|
|
8
14
|
reload?: (sites: string) => string | null;
|
|
9
15
|
running?: () => Promise<boolean>;
|
|
16
|
+
/** Upstream addresses Caddy currently has for a host name; undefined when that cannot be read. */
|
|
17
|
+
upstreams?: (host: string) => Promise<string[] | undefined>;
|
|
10
18
|
}
|
|
11
19
|
/** The host name URL when Caddy serves it, or why it does not so the caller can fall back to localhost. */
|
|
12
20
|
export type HostResult = {
|
|
@@ -14,6 +22,7 @@ export type HostResult = {
|
|
|
14
22
|
} | {
|
|
15
23
|
url: null;
|
|
16
24
|
reason: string;
|
|
25
|
+
reloadFailed?: true;
|
|
17
26
|
};
|
|
18
27
|
/**
|
|
19
28
|
* Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
|
package/dist/host.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as openInApp, c as
|
|
2
|
-
export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeHostLink, siteBlock, syncHost };
|
|
1
|
+
import { a as openInApp, c as siteBlock, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, o as removeDanglingLinks, r as caddySites, s as removeHostLink, t as APP_BUNDLE_ID } from "./host-bnHYIwuY.js";
|
|
2
|
+
export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeDanglingLinks, removeHostLink, siteBlock, syncHost };
|
|
@@ -10,6 +10,8 @@ declare const ID_PATTERNS: {
|
|
|
10
10
|
readonly action: RegExp;
|
|
11
11
|
readonly issue: RegExp;
|
|
12
12
|
readonly finding: RegExp;
|
|
13
|
+
/** A hub-level group of mainlines and issues; groups/w{NNN}-{slug}.md */
|
|
14
|
+
readonly group: RegExp;
|
|
13
15
|
readonly grant: RegExp;
|
|
14
16
|
};
|
|
15
17
|
declare const CHECKLIST_REF: RegExp;
|
|
@@ -75,6 +77,11 @@ declare const mainlineSchema: z.ZodObject<{
|
|
|
75
77
|
P2: "P2";
|
|
76
78
|
P3: "P3";
|
|
77
79
|
}>>;
|
|
80
|
+
group: z.ZodOptional<z.ZodString>;
|
|
81
|
+
role: z.ZodOptional<z.ZodEnum<{
|
|
82
|
+
impl: "impl";
|
|
83
|
+
plan: "plan";
|
|
84
|
+
}>>;
|
|
78
85
|
due: z.ZodOptional<z.ZodString>;
|
|
79
86
|
archivedAt: z.ZodOptional<z.ZodString>;
|
|
80
87
|
criteria: z.ZodArray<z.ZodObject<{
|
|
@@ -370,6 +377,7 @@ declare const issueSchema: z.ZodObject<{
|
|
|
370
377
|
P2: "P2";
|
|
371
378
|
P3: "P3";
|
|
372
379
|
}>>;
|
|
380
|
+
group: z.ZodOptional<z.ZodString>;
|
|
373
381
|
order: z.ZodOptional<z.ZodNumber>;
|
|
374
382
|
due: z.ZodOptional<z.ZodString>;
|
|
375
383
|
plannedIn: z.ZodOptional<z.ZodString>;
|
|
@@ -410,6 +418,49 @@ declare const findingSchema: z.ZodObject<{
|
|
|
410
418
|
archivedAt: z.ZodOptional<z.ZodString>;
|
|
411
419
|
}, z.core.$strip>;
|
|
412
420
|
type Finding = z.infer<typeof findingSchema>;
|
|
421
|
+
/**
|
|
422
|
+
* Front matter of groups/w{NNN}-{slug}.md: mainlines and issues that belong together, such as a plan mainline with the
|
|
423
|
+
* impl mainlines it split off, or the issues to take up together when a measurement is fixed. Membership is written
|
|
424
|
+
* on each member (`group`), so a group file never lists its members and the two cannot disagree. A group is apart
|
|
425
|
+
* from dependencies: depends-on between groups orders them, and priority and trigger say how urgent and when.
|
|
426
|
+
*/
|
|
427
|
+
declare const groupSchema: z.ZodObject<{
|
|
428
|
+
category: z.ZodOptional<z.ZodString>;
|
|
429
|
+
labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
430
|
+
created: z.ZodString;
|
|
431
|
+
closedAt: z.ZodOptional<z.ZodString>;
|
|
432
|
+
resolution: z.ZodOptional<z.ZodString>;
|
|
433
|
+
origin: z.ZodOptional<z.ZodString>;
|
|
434
|
+
refs: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
435
|
+
title: z.ZodString;
|
|
436
|
+
url: z.ZodOptional<z.ZodString>;
|
|
437
|
+
path: z.ZodOptional<z.ZodString>;
|
|
438
|
+
date: z.ZodOptional<z.ZodString>;
|
|
439
|
+
}, z.core.$strip>>>;
|
|
440
|
+
links: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
441
|
+
rel: z.ZodEnum<{
|
|
442
|
+
"depends-on": "depends-on";
|
|
443
|
+
relates: "relates";
|
|
444
|
+
"split-from": "split-from";
|
|
445
|
+
}>;
|
|
446
|
+
to: z.ZodString;
|
|
447
|
+
note: z.ZodOptional<z.ZodString>;
|
|
448
|
+
}, z.core.$strip>>>;
|
|
449
|
+
id: z.ZodString;
|
|
450
|
+
title: z.ZodString;
|
|
451
|
+
status: z.ZodEnum<{
|
|
452
|
+
done: "done";
|
|
453
|
+
open: "open";
|
|
454
|
+
}>;
|
|
455
|
+
priority: z.ZodOptional<z.ZodEnum<{
|
|
456
|
+
P0: "P0";
|
|
457
|
+
P1: "P1";
|
|
458
|
+
P2: "P2";
|
|
459
|
+
P3: "P3";
|
|
460
|
+
}>>;
|
|
461
|
+
trigger: z.ZodOptional<z.ZodString>;
|
|
462
|
+
}, z.core.$strip>;
|
|
463
|
+
type Group = z.infer<typeof groupSchema>;
|
|
413
464
|
/** One line of ops/{YYYY-MM}.log: an external operation, append-only. */
|
|
414
465
|
declare const opsEntrySchema: z.ZodObject<{
|
|
415
466
|
at: z.ZodString;
|
|
@@ -420,4 +471,4 @@ declare const opsEntrySchema: z.ZodObject<{
|
|
|
420
471
|
}, z.core.$strip>;
|
|
421
472
|
type OpsEntry = z.infer<typeof opsEntrySchema>;
|
|
422
473
|
//#endregion
|
|
423
|
-
export {
|
|
474
|
+
export { Task as _, Decision as a, Group as c, Mainline as d, OpsEntry as f, SurveyLine as g, Session as h, ChecklistItem as i, ID_PATTERNS as l, SLUG as m, ActionBlock as n, FORMAT_VERSION as o, SERVES_REF as p, CHECKLIST_REF as r, Finding as s, Action as t, Issue as u, Until as v };
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { _t as DATA_DIR, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, s as removeHostLink, vt as WriteError } from "./host-bnHYIwuY.js";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } 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
|
+
...typeof record.hostError === "string" && { hostError: record.hostError }
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function writeUiRecord(root, record) {
|
|
30
|
+
mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
|
|
31
|
+
writeFileSync(uiRecordFile(root), `${JSON.stringify(record)}\n`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
35
|
+
* the hub's configured port, then the last one used, then the default range.
|
|
36
|
+
*/
|
|
37
|
+
function portCandidates(options) {
|
|
38
|
+
if (options.explicit !== void 0) return [options.explicit];
|
|
39
|
+
const range = Array.from({ length: 100 }, (_, i) => DEFAULT_UI_PORT + i);
|
|
40
|
+
return [...new Set([
|
|
41
|
+
options.configured,
|
|
42
|
+
options.last,
|
|
43
|
+
...range
|
|
44
|
+
].filter((p) => p !== void 0))];
|
|
45
|
+
}
|
|
46
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
47
|
+
/** How long a server gets to answer /api/ping; the endpoint loads nothing, so a server that is slower is not a live one. */
|
|
48
|
+
const PROBE_MS = 1e3;
|
|
49
|
+
/**
|
|
50
|
+
* Ask `path` of the server on `port`: its JSON body (also from a 503 of kind hub-config), 404 for a server without
|
|
51
|
+
* that endpoint, null for anything else.
|
|
52
|
+
* A server that predates the endpoint answers with its page (200 text/html) rather than 404, so a 200 that is not a
|
|
53
|
+
* JSON object naming a hub counts as no endpoint too.
|
|
54
|
+
*/
|
|
55
|
+
async function getJson(port, path) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`http://localhost:${port}${path}`, { signal: AbortSignal.timeout(PROBE_MS) });
|
|
58
|
+
if (res.status === 404) return 404;
|
|
59
|
+
if (!res.ok && res.status !== 503) return null;
|
|
60
|
+
const body = await res.json().catch(() => null);
|
|
61
|
+
if (res.status === 503 && body?.kind !== "hub-config") return null;
|
|
62
|
+
return body !== null && typeof body === "object" && typeof body.hub === "string" ? body : 404;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const networkProbe = {
|
|
68
|
+
async serverAt(port) {
|
|
69
|
+
let body = await getJson(port, "/api/ping");
|
|
70
|
+
if (body === 404) body = await getJson(port, "/api/hub");
|
|
71
|
+
if (body === null || body === 404 || typeof body.hub !== "string") return null;
|
|
72
|
+
return {
|
|
73
|
+
hub: body.hub,
|
|
74
|
+
...typeof body.version === "string" && { version: body.version },
|
|
75
|
+
...typeof body.root === "string" && { root: body.root },
|
|
76
|
+
...Number.isInteger(body.pid) && { pid: body.pid },
|
|
77
|
+
...body.status === "config-error" && { status: "config-error" }
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
isFree: (port) => new Promise((done) => {
|
|
81
|
+
const probe = createServer().once("error", () => done(false));
|
|
82
|
+
probe.listen(port, () => probe.close(() => done(true)));
|
|
83
|
+
}),
|
|
84
|
+
listenerOf(port) {
|
|
85
|
+
const out = spawnSync("lsof", [
|
|
86
|
+
"-nP",
|
|
87
|
+
`-iTCP:${port}`,
|
|
88
|
+
"-sTCP:LISTEN",
|
|
89
|
+
"-t"
|
|
90
|
+
], { encoding: "utf8" });
|
|
91
|
+
const pid = Number(out.stdout?.split("\n")[0]);
|
|
92
|
+
return out.status === 0 && Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
93
|
+
},
|
|
94
|
+
async stop(pid, port) {
|
|
95
|
+
try {
|
|
96
|
+
process.kill(pid, "SIGTERM");
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < 25; i++) {
|
|
101
|
+
await sleep(200);
|
|
102
|
+
if (await networkProbe.isFree(port)) return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/** The absolute path of `root` with symbolic links followed, so two paths to one directory compare equal. */
|
|
108
|
+
function realRoot(root) {
|
|
109
|
+
try {
|
|
110
|
+
return realpathSync(root);
|
|
111
|
+
} catch {
|
|
112
|
+
return resolve(root);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Whether `info` is a server of the hub `hub` at `root`. A server that reports no root cannot be told apart by it, so the name decides. */
|
|
116
|
+
const serves = (info, hub, root) => info !== null && info.hub === hub && (info.root === void 0 || root === void 0 || info.root === root);
|
|
117
|
+
/**
|
|
118
|
+
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
119
|
+
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
120
|
+
* probe timeout. Among several servers of the hub, the one of `version` is kept, else the first in the order, and
|
|
121
|
+
* the rest are returned as `extras`. A hub of the same name at another root, such as a worktree's, is not this
|
|
122
|
+
* hub's server. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
123
|
+
*/
|
|
124
|
+
async function choosePort(hub, candidates, probe = networkProbe, options = {}) {
|
|
125
|
+
const servers = await Promise.all(candidates.map((port) => probe.serverAt(port)));
|
|
126
|
+
const ours = candidates.flatMap((port, i) => serves(servers[i], hub, options.root) ? [{
|
|
127
|
+
port,
|
|
128
|
+
...servers[i]
|
|
129
|
+
}] : []);
|
|
130
|
+
const kept = ours.find((s) => s.version === options.version) ?? ours[0];
|
|
131
|
+
if (kept) return {
|
|
132
|
+
port: kept.port,
|
|
133
|
+
running: kept.version ?? "",
|
|
134
|
+
...kept.pid !== void 0 && { pid: kept.pid },
|
|
135
|
+
extras: ours.filter((s) => s !== kept)
|
|
136
|
+
};
|
|
137
|
+
for (const [i, port] of candidates.entries()) if (servers[i] === null && await probe.isFree(port)) return {
|
|
138
|
+
port,
|
|
139
|
+
running: void 0,
|
|
140
|
+
extras: []
|
|
141
|
+
};
|
|
142
|
+
const tried = candidates.length === 1 ? `port ${candidates[0]} is` : `ports ${candidates[0]} to ${candidates.at(-1)} are`;
|
|
143
|
+
throw new WriteError(`${tried} taken by other programs; free one, or pass --port <port>`);
|
|
144
|
+
}
|
|
145
|
+
/** The lock that makes starting an interface one hook at a time: machine-specific, so under the unversioned local/ folder. */
|
|
146
|
+
const uiLockFile = (root) => join(root, DATA_DIR, "local", "ui.lock");
|
|
147
|
+
const processAlive = (pid) => {
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return error.code === "EPERM";
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Run `action` while holding local/ui.lock, so several hooks starting at the same moment, such as the sessions of a
|
|
157
|
+
* workspace opened together, take turns: the later ones then find the first one's server and reuse it. A lock whose
|
|
158
|
+
* process is gone, or that is older than `staleMs`, is taken over; after `waitMs` the action runs without the lock
|
|
159
|
+
* rather than leave a session without its interface.
|
|
160
|
+
*/
|
|
161
|
+
async function withUiLock(root, action, options = {}) {
|
|
162
|
+
const { waitMs = 2e4, staleMs = 6e4 } = options;
|
|
163
|
+
const file = uiLockFile(root);
|
|
164
|
+
mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
|
|
165
|
+
const deadline = Date.now() + waitMs;
|
|
166
|
+
let held = false;
|
|
167
|
+
for (;;) {
|
|
168
|
+
try {
|
|
169
|
+
writeFileSync(file, `${process.pid}\n`, { flag: "wx" });
|
|
170
|
+
held = true;
|
|
171
|
+
break;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code !== "EEXIST") break;
|
|
174
|
+
}
|
|
175
|
+
const holder = Number.parseInt(readLock(file), 10);
|
|
176
|
+
const age = Date.now() - (statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
|
|
177
|
+
if (Number.isInteger(holder) && holder > 0 && !processAlive(holder) || age > staleMs) {
|
|
178
|
+
rmSync(file, { force: true });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (Date.now() >= deadline) break;
|
|
182
|
+
await sleep(50);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
return await action();
|
|
186
|
+
} finally {
|
|
187
|
+
if (held && Number.parseInt(readLock(file), 10) === process.pid) rmSync(file, { force: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function readLock(file) {
|
|
191
|
+
try {
|
|
192
|
+
return readFileSync(file, "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Run `mainflow ui --no-open` detached, for a hook that must not wait: it reuses a server already serving this
|
|
199
|
+
* hub, replaces one of another version, or starts one. The CLI running now is the one started, so the hub's
|
|
200
|
+
* pinned version serves its interface.
|
|
201
|
+
*/
|
|
202
|
+
function startUiInBackground(root) {
|
|
203
|
+
const cli = process.argv[1];
|
|
204
|
+
if (!cli || process.env.MAINFLOW_NO_BACKGROUND_UI === "1") return;
|
|
205
|
+
spawn(process.execPath, [
|
|
206
|
+
cli,
|
|
207
|
+
"ui",
|
|
208
|
+
"--no-open"
|
|
209
|
+
], {
|
|
210
|
+
cwd: root,
|
|
211
|
+
detached: true,
|
|
212
|
+
stdio: "ignore"
|
|
213
|
+
}).on("error", () => {}).unref();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Call `onGone` once the hub's config.yaml disappears, checking every `everyMs`. A server started for a hub in a
|
|
217
|
+
* directory that is later deleted, such as a test's, then stops instead of holding its port forever. The timer
|
|
218
|
+
* does not keep the process alive by itself.
|
|
219
|
+
*/
|
|
220
|
+
function watchHubGone(root, onGone, everyMs = 3e4) {
|
|
221
|
+
const timer = setInterval(() => {
|
|
222
|
+
if (existsSync(join(root, ".mainflow", "config.yaml"))) return;
|
|
223
|
+
clearInterval(timer);
|
|
224
|
+
onGone();
|
|
225
|
+
}, everyMs);
|
|
226
|
+
timer.unref();
|
|
227
|
+
return timer;
|
|
228
|
+
}
|
|
229
|
+
/** Open a URL in the default browser without waiting for it. */
|
|
230
|
+
function openUrl(url) {
|
|
231
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
232
|
+
"cmd",
|
|
233
|
+
"/c",
|
|
234
|
+
"start",
|
|
235
|
+
"",
|
|
236
|
+
url
|
|
237
|
+
] : ["xdg-open", url];
|
|
238
|
+
spawn(command, args, {
|
|
239
|
+
detached: true,
|
|
240
|
+
stdio: "ignore"
|
|
241
|
+
}).on("error", () => console.log(`open ${url} in a browser`)).unref();
|
|
242
|
+
}
|
|
243
|
+
/** Start or reuse this hub's interface: the URL it is reached at, and the server when this call started one. */
|
|
244
|
+
async function launchUi(root, hub, configured, options) {
|
|
245
|
+
const log = options.log ?? console.log;
|
|
246
|
+
let explicit;
|
|
247
|
+
if (options.port !== void 0) {
|
|
248
|
+
explicit = Number(options.port);
|
|
249
|
+
if (!Number.isInteger(explicit) || explicit < 1024 || explicit > 65535) throw new WriteError("--port must be an integer from 1024 to 65535, e.g. --port 4320");
|
|
250
|
+
}
|
|
251
|
+
const probe = options.probe ?? networkProbe;
|
|
252
|
+
const hubRoot = realRoot(root);
|
|
253
|
+
const { choice, reuse, server } = await withUiLock(root, async () => {
|
|
254
|
+
const record = readUiRecord(root);
|
|
255
|
+
const choice = await choosePort(hub, portCandidates({
|
|
256
|
+
explicit,
|
|
257
|
+
configured,
|
|
258
|
+
last: record?.port
|
|
259
|
+
}), probe, {
|
|
260
|
+
root: hubRoot,
|
|
261
|
+
version: CLI_VERSION
|
|
262
|
+
});
|
|
263
|
+
for (const extra of choice.extras) {
|
|
264
|
+
if (extra.root === void 0) continue;
|
|
265
|
+
const pid = extra.pid ?? probe.listenerOf(extra.port);
|
|
266
|
+
if (pid !== null && pid !== process.pid && await probe.stop(pid, extra.port)) log(`! stopped a second interface of ${hub} on localhost:${extra.port}`);
|
|
267
|
+
}
|
|
268
|
+
let reuse = choice.running !== void 0;
|
|
269
|
+
if (reuse && choice.running !== CLI_VERSION) {
|
|
270
|
+
const from = choice.running || "unknown";
|
|
271
|
+
const pid = choice.pid ?? (record?.port === choice.port && record.pid !== void 0 ? record.pid : probe.listenerOf(choice.port));
|
|
272
|
+
if (pid !== null && await probe.stop(pid, choice.port)) {
|
|
273
|
+
log(`! restarted: mainflow ${from} → ${CLI_VERSION}`);
|
|
274
|
+
reuse = false;
|
|
275
|
+
} 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`);
|
|
276
|
+
}
|
|
277
|
+
let server;
|
|
278
|
+
if (!reuse) {
|
|
279
|
+
const { serveUi } = await import("./ui-C4h4iLDu.js");
|
|
280
|
+
server = await serveUi(root, choice.port);
|
|
281
|
+
writeUiRecord(root, {
|
|
282
|
+
port: choice.port,
|
|
283
|
+
pid: process.pid,
|
|
284
|
+
version: CLI_VERSION
|
|
285
|
+
});
|
|
286
|
+
const started = server;
|
|
287
|
+
watchHubGone(root, () => {
|
|
288
|
+
const failure = removeHostLink(root, hub);
|
|
289
|
+
if (failure) log(`! caddy reload failed after removing ${hostName(hub)}: ${failure}`);
|
|
290
|
+
started.close();
|
|
291
|
+
process.exit(0);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
choice,
|
|
296
|
+
reuse,
|
|
297
|
+
server
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
const host = await syncHost(root, hub, choice.port, options.host);
|
|
301
|
+
if (host.url === null) log(`! host name not available: ${host.reason}`);
|
|
302
|
+
const hostError = host.url === null && host.reloadFailed ? host.reason : void 0;
|
|
303
|
+
const recorded = readUiRecord(root);
|
|
304
|
+
if (recorded && recorded.hostError !== hostError) {
|
|
305
|
+
const { hostError: _previous, ...rest } = recorded;
|
|
306
|
+
writeUiRecord(root, hostError ? {
|
|
307
|
+
...rest,
|
|
308
|
+
hostError
|
|
309
|
+
} : rest);
|
|
310
|
+
}
|
|
311
|
+
const url = host.url ?? `http://localhost:${choice.port}`;
|
|
312
|
+
log(`${reuse ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
|
|
313
|
+
if (options.open) openUrl(url);
|
|
314
|
+
return {
|
|
315
|
+
url,
|
|
316
|
+
server,
|
|
317
|
+
...hostError && { hostError }
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The exit code `mainflow ui` sets once launching is done. A stale host name makes it 1 only when this process
|
|
322
|
+
* has no server of its own and so ends here; a process that serves keeps 0, or a normal stop much later would
|
|
323
|
+
* report 1.
|
|
324
|
+
*/
|
|
325
|
+
const exitCodeFor = (result) => result.hostError && !result.server ? 1 : 0;
|
|
326
|
+
//#endregion
|
|
327
|
+
export { networkProbe as a, realRoot as c, withUiLock as d, CLI_VERSION as f, launchUi as i, startUiInBackground as l, choosePort as n, portCandidates as o, exitCodeFor as r, readUiRecord as s, PORT_SPAN as t, watchHubGone as u };
|
package/dist/launch.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface UiRecord {
|
|
|
8
8
|
port: number;
|
|
9
9
|
pid?: number;
|
|
10
10
|
version?: string;
|
|
11
|
+
/** Why the last run could not point the host name at this server: the Caddy reload failure, absent when it worked. */
|
|
12
|
+
hostError?: string;
|
|
11
13
|
}
|
|
12
14
|
export declare function readUiRecord(root: string): UiRecord | undefined;
|
|
13
15
|
/**
|
|
@@ -19,10 +21,18 @@ export declare function portCandidates(options: {
|
|
|
19
21
|
configured?: number;
|
|
20
22
|
last?: number;
|
|
21
23
|
}): number[];
|
|
22
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* What a mainflow interface on a port reports. Servers older than 0.8.1 report no version, and servers that answer
|
|
26
|
+
* only /api/hub report neither their root nor their process.
|
|
27
|
+
*/
|
|
23
28
|
export interface ServerInfo {
|
|
24
29
|
hub: string;
|
|
25
30
|
version?: string;
|
|
31
|
+
/** The hub's root with symbolic links resolved */
|
|
32
|
+
root?: string;
|
|
33
|
+
pid?: number;
|
|
34
|
+
/** 'config-error' when the server is this hub's but its config.yaml cannot be used right now */
|
|
35
|
+
status?: 'config-error';
|
|
26
36
|
}
|
|
27
37
|
export interface PortProbe {
|
|
28
38
|
/** The mainflow interface on this port, or null when nothing mainflow answers. */
|
|
@@ -34,17 +44,43 @@ export interface PortProbe {
|
|
|
34
44
|
stop: (pid: number, port: number) => Promise<boolean>;
|
|
35
45
|
}
|
|
36
46
|
export declare const networkProbe: PortProbe;
|
|
37
|
-
/**
|
|
47
|
+
/** The absolute path of `root` with symbolic links followed, so two paths to one directory compare equal. */
|
|
48
|
+
export declare function realRoot(root: string): string;
|
|
49
|
+
/** A server of this hub on `port`, with what it reported. */
|
|
50
|
+
export interface HubServer extends ServerInfo {
|
|
51
|
+
port: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A port to serve on. `running` is the version already serving this hub there, '' when it reports none. `extras` are
|
|
55
|
+
* the other servers of this hub found on the candidates, to be stopped so one remains.
|
|
56
|
+
*/
|
|
38
57
|
export type PortChoice = {
|
|
39
58
|
port: number;
|
|
40
59
|
running?: string;
|
|
60
|
+
pid?: number;
|
|
61
|
+
extras: HubServer[];
|
|
41
62
|
};
|
|
42
63
|
/**
|
|
43
64
|
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
44
65
|
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
45
|
-
* probe timeout.
|
|
66
|
+
* probe timeout. Among several servers of the hub, the one of `version` is kept, else the first in the order, and
|
|
67
|
+
* the rest are returned as `extras`. A hub of the same name at another root, such as a worktree's, is not this
|
|
68
|
+
* hub's server. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
69
|
+
*/
|
|
70
|
+
export declare function choosePort(hub: string, candidates: number[], probe?: PortProbe, options?: {
|
|
71
|
+
root?: string;
|
|
72
|
+
version?: string;
|
|
73
|
+
}): Promise<PortChoice>;
|
|
74
|
+
/**
|
|
75
|
+
* Run `action` while holding local/ui.lock, so several hooks starting at the same moment, such as the sessions of a
|
|
76
|
+
* workspace opened together, take turns: the later ones then find the first one's server and reuse it. A lock whose
|
|
77
|
+
* process is gone, or that is older than `staleMs`, is taken over; after `waitMs` the action runs without the lock
|
|
78
|
+
* rather than leave a session without its interface.
|
|
46
79
|
*/
|
|
47
|
-
export declare function
|
|
80
|
+
export declare function withUiLock<T>(root: string, action: () => Promise<T>, options?: {
|
|
81
|
+
waitMs?: number;
|
|
82
|
+
staleMs?: number;
|
|
83
|
+
}): Promise<T>;
|
|
48
84
|
/**
|
|
49
85
|
* Run `mainflow ui --no-open` detached, for a hook that must not wait: it reuses a server already serving this
|
|
50
86
|
* hub, replaces one of another version, or starts one. The CLI running now is the one started, so the hub's
|
|
@@ -68,5 +104,15 @@ export interface LaunchOptions {
|
|
|
68
104
|
export declare function launchUi(root: string, hub: string, configured: number | undefined, options: LaunchOptions): Promise<{
|
|
69
105
|
url: string;
|
|
70
106
|
server?: ServerType;
|
|
107
|
+
hostError?: string;
|
|
71
108
|
}>;
|
|
109
|
+
/**
|
|
110
|
+
* The exit code `mainflow ui` sets once launching is done. A stale host name makes it 1 only when this process
|
|
111
|
+
* has no server of its own and so ends here; a process that serves keeps 0, or a normal stop much later would
|
|
112
|
+
* report 1.
|
|
113
|
+
*/
|
|
114
|
+
export declare const exitCodeFor: (result: {
|
|
115
|
+
hostError?: string;
|
|
116
|
+
server?: unknown;
|
|
117
|
+
}) => number;
|
|
72
118
|
//#endregion
|
package/dist/launch.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord, startUiInBackground, watchHubGone };
|
|
1
|
+
import { a as networkProbe, c as realRoot, d as withUiLock, i as launchUi, l as startUiInBackground, n as choosePort, o as portCandidates, r as exitCodeFor, s as readUiRecord, t as PORT_SPAN, u as watchHubGone } from "./launch-D80CYPcm.js";
|
|
2
|
+
export { PORT_SPAN, choosePort, exitCodeFor, launchUi, networkProbe, portCandidates, readUiRecord, realRoot, startUiInBackground, watchHubGone, withUiLock };
|
|
Binary file
|
|
Binary file
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as ACCESS_HEADERS, B as applyOperation, F as renumberRequest, I as renumberFiles, L as renumberPath, P as applyOffline, Q as nodeStorage, R as renumberText, X as nowMinute, _t as DATA_DIR, at as comparePaths, bt as splitFrontMatter, ct as requestTime, et as BLOB_HASH, gt as ConflictError, it as SOCKET_MAX_AGE_CLOSE, lt as textBytes, nt as MAX_FILE_BYTES, ot as exceedsFileLimit, rt as ROUTES, st as readsAsText, tt as MAX_BLOB_BYTES, vt as WriteError, yt as contentHash, z as scopeOfPath } from "./host-bnHYIwuY.js";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -5902,4 +5902,4 @@ function hubWriter(root, env = process.env, options = {}) {
|
|
|
5902
5902
|
};
|
|
5903
5903
|
}
|
|
5904
5904
|
//#endregion
|
|
5905
|
-
export {
|
|
5905
|
+
export { isSynced as A, ServerError as C, syncSettings as D, hubName as E, SETTINGS_FILE as O, ServerApi as S, earlierToken as T, readDaemon as _, remoteMode as a, readRemote as b, planAnnounce as c, toolCallOf as d, startDaemon as f, probeDaemon as g, callDaemon as h, queuedCount as i, syncPaths as j, SYNC_DIR as k, planFetch as l, DaemonUnavailable as m, hubWriter as n, fetchDenial as o, DaemonRunning as p, writtenLines as r, hookAnswer as s, CONFIRM_MS as t, planFlush as u, silentHint as v, ENV as w, oversizedFiles as x, watchTree as y };
|