mainflow 0.41.0 → 0.43.0-alpha.1
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 +755 -155
- package/dist/{dist-zSabglPq.js → dist-D5rZ0MQH.js} +71 -23
- package/dist/facts.d.ts +61 -12
- package/dist/facts.js +56 -13
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +5 -540
- package/dist/hooks.js +126 -92
- package/dist/{host-BAMZmRy8.js → host-BJvqZy-i.js} +1374 -489
- package/dist/host.d.ts +3 -2
- package/dist/host.js +1 -1
- package/dist/{index-Dk4fCI7q.d.ts → index-BqPQp6TF.d.ts} +2 -8
- package/dist/index-DRpamPge.d.ts +550 -0
- package/dist/launch.d.ts +6 -5
- package/dist/launch.js +323 -1
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/{operation-DlR1P_Pt.js → operation-BhBbGauo.js} +610 -343
- package/dist/{ui-9uuI4Jif.js → ui-xxzmhAqF.js} +107 -98
- package/dist/version-Dvv4jwF1.js +6 -0
- package/dist/web/assets/{index-DvYj_6IC.js → index-B4w2iRCQ.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +2 -2
- package/dist/launch-CMeKJsNd.js +0 -327
package/dist/web/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>mainflow</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-B4w2iRCQ.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-D3P-pLas.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mainflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0-alpha.1",
|
|
4
4
|
"description": "本機多 AI 協作的主脈絡任務管理:CLI 與介面",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@hono/node-server": "^2.1.1",
|
|
34
34
|
"hono": "^4.13.8",
|
|
35
|
-
"ignore": "^7.0.12",
|
|
36
35
|
"node-diff3": "3.2.1",
|
|
36
|
+
"semver": "^7.8.5",
|
|
37
37
|
"yaml": "^2.9.1",
|
|
38
38
|
"zod": "^4.6.5"
|
|
39
39
|
},
|
package/dist/launch-CMeKJsNd.js
DELETED
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
import { At as WriteError, i as hostName, kt as DATA_DIR, l as syncHost, n as DEFAULT_UI_PORT, s as removeHostLink } from "./host-BAMZmRy8.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-9uuI4Jif.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 };
|