mainflow 0.9.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 +5 -3
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +47 -7
- package/dist/{dist-_ArTiNbL.js → host-BFGfpTDq.js} +185 -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 +6 -0
- package/dist/launch.js +2 -2
- package/dist/progress-1BfPg2zN.js +10 -0
- package/dist/{launch-D2WJRvPD.js → ui-DbEmJiwO.js} +18 -174
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
3
|
-
import {
|
|
2
|
+
import { $ as MAINLINE_FILE, A as setSessionStatus, B as appendOps, C as updateTask, D as readSession, F as sessionBriefing, G as reopenIssue, H as createFinding, J as setIssueArchived, K as setFindingArchived, M as startSession, O as setSessionArchived, P as updateAction, S as supersedeDecision, U as createIssue, V as closeIssue, W as planIssue, X as loadHub, Y as HubError, _ as setArchived, at as findHubRoot, b as setMainlineStatus, c as migrateHub, d as addRepo, et as TASK_FILE, f as createDecision, g as initHub, h as dropChecklistItem, l as acceptDecision, m as createTask, nt as WriteError, p as createMainline, q as setFindingStatus, u as addHub, v as setChecklistItem, w as addAction, y as setCriterion } from "./host-BFGfpTDq.js";
|
|
3
|
+
import { t as listProgress } from "./progress-1BfPg2zN.js";
|
|
4
4
|
import { COMMAND_SPECS, OPTIONS } from "./facts.js";
|
|
5
5
|
import { runHook } from "./hooks.js";
|
|
6
|
+
import { c as CLI_VERSION, r as launchUi, s as startUiInBackground } from "./launch-D5uHpqkh.js";
|
|
6
7
|
import { readFileSync, realpathSync } from "node:fs";
|
|
7
8
|
import { join, relative } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -89,7 +90,8 @@ const RUNS = {
|
|
|
89
90
|
const { stdout, error } = runHook(args[0], args[1], {
|
|
90
91
|
payload,
|
|
91
92
|
envFile: process.env.CLAUDE_ENV_FILE,
|
|
92
|
-
cli: CLI
|
|
93
|
+
cli: CLI,
|
|
94
|
+
startUi: startUiInBackground
|
|
93
95
|
});
|
|
94
96
|
if (error) console.error(error);
|
|
95
97
|
if (stdout) console.log(stdout);
|
package/dist/hooks.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface ClaudeCodePayload {
|
|
|
11
11
|
effort?: {
|
|
12
12
|
level?: string;
|
|
13
13
|
};
|
|
14
|
+
/** Stop: true when the turn is already continuing because a Stop hook asked it to */
|
|
15
|
+
stop_hook_active?: boolean;
|
|
16
|
+
/** MessageDisplay: the lines of an assistant message about to be shown */
|
|
17
|
+
delta?: string;
|
|
14
18
|
/** PostToolUse: the tool that ran and its input */
|
|
15
19
|
tool_name?: string;
|
|
16
20
|
tool_input?: {
|
|
@@ -26,9 +30,13 @@ export interface HookContext {
|
|
|
26
30
|
envFile?: string;
|
|
27
31
|
/** How the executor should invoke the CLI inside the hub, used in the texts it reads */
|
|
28
32
|
cli: string;
|
|
33
|
+
/** Start the hub's interface without waiting; given by the CLI entry point, left out by callers that must not spawn */
|
|
34
|
+
startUi?: (root: string) => void;
|
|
29
35
|
}
|
|
30
36
|
export declare const claudeCodeSessionId: (raw: string) => string;
|
|
31
37
|
type Handler = (root: string, ctx: HookContext) => string | null;
|
|
38
|
+
/** How a hub's interface is linked from a reply: through its /open route, which shows the address in the app. */
|
|
39
|
+
export declare const openLink: (hub: string) => (path: string) => string;
|
|
32
40
|
export declare const HOOK_PLATFORMS: Record<string, Record<string, Handler>>;
|
|
33
41
|
/**
|
|
34
42
|
* Run a hook handler. A hook must never break the host session, so outside a hub it stays silent and any
|
package/dist/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as setSessionStatus, D as readSession, F as sessionBriefing, I as turnReminder, L as decorateReply, R as endsWithMarker, T as ensureSession, X as loadHub, Y as HubError, at as findHubRoot, i as hostName, it as currentBranch, j as settleSession, k as setSessionInfo, ot as worktreesOf, rt as DATA_DIR, z as mayDecorate } from "./host-BFGfpTDq.js";
|
|
2
2
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
//#region src/hooks.ts
|
|
@@ -23,8 +23,9 @@ const brief = (text) => {
|
|
|
23
23
|
const shortId = (raw) => raw.replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8);
|
|
24
24
|
const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
|
|
25
25
|
/**
|
|
26
|
-
* One handler per Claude Code event; each returns what to print
|
|
27
|
-
*
|
|
26
|
+
* One handler per Claude Code event; each returns what to print, or null. SessionStart and UserPromptSubmit take
|
|
27
|
+
* plain text for the model; PostToolUse, Stop and MessageDisplay take the hook JSON, since their plain stdout
|
|
28
|
+
* never reaches the model or the screen.
|
|
28
29
|
*/
|
|
29
30
|
/**
|
|
30
31
|
* Record the main session: create it when it is missing, since a turn that tracked nothing left no record, and
|
|
@@ -51,12 +52,50 @@ const settleOnEnd = (root, { payload }) => {
|
|
|
51
52
|
if (payload.session_id) settleSession(root, claudeCodeSessionId(payload.session_id));
|
|
52
53
|
return null;
|
|
53
54
|
};
|
|
55
|
+
/** How a hub's interface is linked from a reply: through its /open route, which shows the address in the app. */
|
|
56
|
+
const openLink = (hub) => (path) => `https://${hostName(hub)}/open${path}`;
|
|
57
|
+
/**
|
|
58
|
+
* MessageDisplay: link the references in the lines about to be shown and expand a trailing end marker into the
|
|
59
|
+
* current action line. Display only, so the model keeps reading what it wrote. Text with nothing to change
|
|
60
|
+
* returns before the hub is loaded, because Claude Code holds every batch until the hook answers.
|
|
61
|
+
*/
|
|
62
|
+
const displayReply = (root, { payload }) => {
|
|
63
|
+
const text = payload.delta;
|
|
64
|
+
if (!text || !mayDecorate(text)) return null;
|
|
65
|
+
const hub = loadHub(root, { remotes: false });
|
|
66
|
+
const session = payload.session_id ? readSession(root, claudeCodeSessionId(payload.session_id)) : null;
|
|
67
|
+
const shown = decorateReply(text, {
|
|
68
|
+
hub,
|
|
69
|
+
session,
|
|
70
|
+
link: openLink(hub.config.hub)
|
|
71
|
+
});
|
|
72
|
+
return shown === text ? null : JSON.stringify({ hookSpecificOutput: {
|
|
73
|
+
hookEventName: "MessageDisplay",
|
|
74
|
+
displayContent: shown
|
|
75
|
+
} });
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Stop: settle the session, then ask for the end marker when a session with an action in progress ended its
|
|
79
|
+
* turn without one. Only the model knows which message closes a turn, so the marker is its to write; this makes
|
|
80
|
+
* a forgotten one cost a single extra message. stop_hook_active stops it from asking twice in a row.
|
|
81
|
+
*/
|
|
82
|
+
const settleAndCheckMarker = (root, context) => {
|
|
83
|
+
settleOnEnd(root, context);
|
|
84
|
+
const { payload } = context;
|
|
85
|
+
if (!payload.session_id || payload.stop_hook_active || endsWithMarker(payload.last_assistant_message ?? "")) return null;
|
|
86
|
+
if (!readSession(root, claudeCodeSessionId(payload.session_id))?.actions.some((a) => a.status === "doing")) return null;
|
|
87
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
88
|
+
hookEventName: "Stop",
|
|
89
|
+
additionalContext: `[mainflow] 這一輪最後一則回覆沒有以單獨一行的 ▸ 結尾。請只回一則內容為 ▸ 的訊息,不加其他文字。`
|
|
90
|
+
} });
|
|
91
|
+
};
|
|
54
92
|
const CLAUDE_CODE_EVENTS = {
|
|
55
|
-
"session-start": (root, { payload, envFile, cli }) => {
|
|
93
|
+
"session-start": (root, { payload, envFile, cli, startUi }) => {
|
|
56
94
|
if (!payload.session_id) return null;
|
|
57
95
|
const id = claudeCodeSessionId(payload.session_id);
|
|
58
96
|
const session = recordSession(root, payload, id, true);
|
|
59
97
|
if (envFile) appendFileSync(envFile, `export MAINFLOW_SESSION=${id}\n`);
|
|
98
|
+
startUi?.(root);
|
|
60
99
|
return sessionBriefing(root, session, {
|
|
61
100
|
cli,
|
|
62
101
|
envNote: envFile ? "已設定環境變數 MAINFLOW_SESSION,指令不必再帶 --session" : `執行指令時帶 --session ${id}`
|
|
@@ -66,7 +105,7 @@ const CLAUDE_CODE_EVENTS = {
|
|
|
66
105
|
if (!payload.session_id) return null;
|
|
67
106
|
return turnReminder(root, recordSession(root, payload, claudeCodeSessionId(payload.session_id), false), { cli });
|
|
68
107
|
},
|
|
69
|
-
stop:
|
|
108
|
+
stop: settleAndCheckMarker,
|
|
70
109
|
"session-end": settleOnEnd,
|
|
71
110
|
"subagent-start": (root, { payload }) => {
|
|
72
111
|
if (!payload.agent_id) return null;
|
|
@@ -93,7 +132,8 @@ const CLAUDE_CODE_EVENTS = {
|
|
|
93
132
|
setSessionStatus(root, id, "ended");
|
|
94
133
|
return null;
|
|
95
134
|
},
|
|
96
|
-
"post-tool-use": checkAfterWrite
|
|
135
|
+
"post-tool-use": checkAfterWrite,
|
|
136
|
+
"message-display": displayReply
|
|
97
137
|
};
|
|
98
138
|
/** How many problems outside the written file the reminder lists before it only gives the count. */
|
|
99
139
|
const OTHER_PROBLEMS_SHOWN = 5;
|
|
@@ -184,4 +224,4 @@ function runHook(platform, event, ctx) {
|
|
|
184
224
|
}
|
|
185
225
|
}
|
|
186
226
|
//#endregion
|
|
187
|
-
export { HOOK_PLATFORMS, claudeCodeSessionId, runHook, subagentTranscript };
|
|
227
|
+
export { HOOK_PLATFORMS, claudeCodeSessionId, openLink, runHook, subagentTranscript };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { A as mainlineSchema, C as TASK_SECTIONS, D as findingSchema, E as decisionSchema, F as refMainline, I as sessionSchema, L as taskSchema, M as parseOpsLine, N as parseRef, O as formatOpsEntry, P as refExample, S as TASK_REQUIRED_SECTIONS, T as configSchema, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, j as opsEntrySchema, k as issueSchema, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, v as REF_EXAMPLES, w as WORK_STATUS, x as SLUG, y as RESERVED_HUB_NAMES } from "./dist-D2NK-QQP.js";
|
|
2
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { createHash, randomBytes } from "node:crypto";
|
|
6
6
|
import { parse, stringify } from "yaml";
|
|
7
7
|
//#region ../core/dist/paths.js
|
|
@@ -909,6 +909,68 @@ function appendOps(root, input) {
|
|
|
909
909
|
return entry;
|
|
910
910
|
});
|
|
911
911
|
}
|
|
912
|
+
/** Where the hub's interface shows a reference; null for kinds it has no page or panel for. */
|
|
913
|
+
function pathOf(ref, target) {
|
|
914
|
+
const [m, child] = ref.split("/");
|
|
915
|
+
switch (target.kind) {
|
|
916
|
+
case "mainline": return `/mainlines/${m}`;
|
|
917
|
+
case "task": return `/mainlines/${m}/tasks/${child}`;
|
|
918
|
+
case "decision": return `/mainlines/${m}/decisions/${child}`;
|
|
919
|
+
case "issue": return `/issues?peek=${ref}`;
|
|
920
|
+
case "finding": return `/findings?peek=${ref}`;
|
|
921
|
+
default: return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const sessionPath = (id) => `/sessions/${id}`;
|
|
925
|
+
/** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
|
|
926
|
+
const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
|
|
927
|
+
/** A file under a mainline directory, or a reference to a mainline, task, decision, issue or finding. */
|
|
928
|
+
const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[if]\d{3})(?![\w\]])/g;
|
|
929
|
+
/** Links for the references and mainline files the text mentions; references the hub does not have stay as text. */
|
|
930
|
+
function linkMentions(text, context) {
|
|
931
|
+
const index = refIndex(context.hub);
|
|
932
|
+
return text.split(PROTECTED).map((part, i) => i % 2 === 1 ? part : part.replace(MENTION, (all, m, file, ref) => {
|
|
933
|
+
if (m && file) return index.has(m) ? `[${m}/${file}](${context.link(`/mainlines/${m}/files/${file}`)})` : all;
|
|
934
|
+
const target = ref ? index.get(ref) : void 0;
|
|
935
|
+
const path = ref && target ? pathOf(ref, target) : null;
|
|
936
|
+
return path ? `[${ref}](${context.link(path)})` : all;
|
|
937
|
+
})).join("");
|
|
938
|
+
}
|
|
939
|
+
/** `▸ m001 title › t001 title › a001 do`, each id linked; null when the session has no action in progress. */
|
|
940
|
+
function currentActionLine(context) {
|
|
941
|
+
const action = context.session?.actions.find((a) => a.status === "doing");
|
|
942
|
+
if (!context.session || !action) return null;
|
|
943
|
+
const index = refIndex(context.hub);
|
|
944
|
+
const parts = [];
|
|
945
|
+
const served = action.serves.map((s) => parseRef(s)).find((r) => r !== null);
|
|
946
|
+
if (served && "mainline" in served) {
|
|
947
|
+
const mainline = index.get(served.mainline);
|
|
948
|
+
parts.push(`[${served.mainline}](${context.link(`/mainlines/${served.mainline}`)}) ${mainline?.title ?? ""}`.trimEnd());
|
|
949
|
+
if ("task" in served) {
|
|
950
|
+
const ref = `${served.mainline}/${served.task}`;
|
|
951
|
+
parts.push(`[${served.task}](${context.link(`/mainlines/${served.mainline}/tasks/${served.task}`)}) ${index.get(ref)?.title ?? ""}`.trimEnd());
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
parts.push(`[${action.id}](${context.link(sessionPath(context.session.id))}) ${action.do}`);
|
|
955
|
+
return `▸ ${parts.join(" › ")}`;
|
|
956
|
+
}
|
|
957
|
+
/** Whether a message ends with the marker line, as the turn's last message should. */
|
|
958
|
+
const endsWithMarker = (text) => text.trimEnd().split("\n").at(-1)?.trim() === "▸";
|
|
959
|
+
/**
|
|
960
|
+
* The text to display for a piece of a reply: mentions linked, and a trailing marker line replaced with the
|
|
961
|
+
* current action line (or removed when there is none). Returns the input unchanged when there is nothing to do.
|
|
962
|
+
*/
|
|
963
|
+
function decorateReply(text, context) {
|
|
964
|
+
let shown = linkMentions(text, context);
|
|
965
|
+
if (endsWithMarker(shown)) {
|
|
966
|
+
const body = shown.trimEnd().slice(0, -1).trimEnd();
|
|
967
|
+
const line = currentActionLine(context);
|
|
968
|
+
shown = line ? `${body}\n\n${line}\n` : `${body}\n`;
|
|
969
|
+
}
|
|
970
|
+
return shown;
|
|
971
|
+
}
|
|
972
|
+
/** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
|
|
973
|
+
const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
|
|
912
974
|
//#endregion
|
|
913
975
|
//#region ../core/dist/briefing.js
|
|
914
976
|
function describe(root, ref) {
|
|
@@ -977,7 +1039,11 @@ function turnReminder(root, session, options) {
|
|
|
977
1039
|
if (session.actions.length === 0) return null;
|
|
978
1040
|
const doing = current(session);
|
|
979
1041
|
const open = session.actions.filter((a) => a.status === "todo" || a.status === "doing");
|
|
980
|
-
return [
|
|
1042
|
+
return [
|
|
1043
|
+
doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length === 0 ? `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。` : `[mainflow] ${session.id} 目前沒有進行中的行動。`,
|
|
1044
|
+
...doing ? [`- 這一輪最後一則回覆的最後一行只寫 ▸,回覆範例等其他內容都放在它前面。畫面上它會換成目前行動與連結。`] : [],
|
|
1045
|
+
...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
|
|
1046
|
+
].join("\n");
|
|
981
1047
|
}
|
|
982
1048
|
//#endregion
|
|
983
1049
|
//#region ../core/dist/sessions.js
|
|
@@ -1653,4 +1719,119 @@ function migrateHub(root, options = {}) {
|
|
|
1653
1719
|
});
|
|
1654
1720
|
}
|
|
1655
1721
|
//#endregion
|
|
1656
|
-
|
|
1722
|
+
//#region src/host.ts
|
|
1723
|
+
const DEFAULT_UI_PORT = 4320;
|
|
1724
|
+
const hostName = (hub) => `${hub}.mainflow.localhost`;
|
|
1725
|
+
function siteBlock(hub, port) {
|
|
1726
|
+
return [
|
|
1727
|
+
`# mainflow hub ${hub}: written by \`mainflow ui\` to match the port it serves on; do not edit.`,
|
|
1728
|
+
`${hostName(hub)} {`,
|
|
1729
|
+
" tls internal",
|
|
1730
|
+
`\treverse_proxy localhost:${port}`,
|
|
1731
|
+
"}",
|
|
1732
|
+
""
|
|
1733
|
+
].join("\n");
|
|
1734
|
+
}
|
|
1735
|
+
/** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
|
|
1736
|
+
function caddySites() {
|
|
1737
|
+
if (process.env.MAINFLOW_CADDY_SITES) return process.env.MAINFLOW_CADDY_SITES;
|
|
1738
|
+
const prefix = spawnSync("brew", ["--prefix"], { encoding: "utf8" });
|
|
1739
|
+
if (prefix.error || prefix.status !== 0) return null;
|
|
1740
|
+
return join(prefix.stdout.trim(), "etc/caddy/sites");
|
|
1741
|
+
}
|
|
1742
|
+
/** Reload Caddy from the Caddyfile two levels above the sites directory; the error text on failure. */
|
|
1743
|
+
function reloadCaddy(sites) {
|
|
1744
|
+
const caddyfile = join(sites, "..", "..", "Caddyfile");
|
|
1745
|
+
const reload = spawnSync("caddy", [
|
|
1746
|
+
"reload",
|
|
1747
|
+
"--config",
|
|
1748
|
+
caddyfile
|
|
1749
|
+
], { encoding: "utf8" });
|
|
1750
|
+
if (reload.error) return reload.error.message;
|
|
1751
|
+
return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
|
|
1755
|
+
* Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
|
|
1756
|
+
* refused connection or a timeout means Caddy is down (observed 2026-09-25: checking for 200 reported a
|
|
1757
|
+
* running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
|
|
1758
|
+
*/
|
|
1759
|
+
async function caddyRunning() {
|
|
1760
|
+
const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
|
|
1761
|
+
try {
|
|
1762
|
+
await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
|
|
1763
|
+
return true;
|
|
1764
|
+
} catch {
|
|
1765
|
+
return false;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
|
|
1769
|
+
/**
|
|
1770
|
+
* Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
|
|
1771
|
+
* port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
|
|
1772
|
+
* different hub of the same name stops here: overwriting it would silently take that hub's host name.
|
|
1773
|
+
*/
|
|
1774
|
+
async function syncHost(root, hub, port, options = {}) {
|
|
1775
|
+
const sites = caddySites();
|
|
1776
|
+
if (!sites || !existsSync(sites)) return {
|
|
1777
|
+
url: null,
|
|
1778
|
+
reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
|
|
1779
|
+
};
|
|
1780
|
+
const dir = join(root, DATA_DIR, "local", "caddy");
|
|
1781
|
+
const file = join(dir, `${hub}.caddy`);
|
|
1782
|
+
const link = join(sites, `mainflow-${hub}.caddy`);
|
|
1783
|
+
const linked = lstatSync(link, { throwIfNoEntry: false });
|
|
1784
|
+
if (linked) {
|
|
1785
|
+
const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
|
|
1786
|
+
if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
|
|
1787
|
+
}
|
|
1788
|
+
const block = siteBlock(hub, port);
|
|
1789
|
+
if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
|
|
1790
|
+
mkdirSync(dir, { recursive: true });
|
|
1791
|
+
writeFileSync(file, block);
|
|
1792
|
+
if (linked) rmSync(link);
|
|
1793
|
+
symlinkSync(file, link);
|
|
1794
|
+
const failure = (options.reload ?? reloadCaddy)(sites);
|
|
1795
|
+
if (failure) return {
|
|
1796
|
+
url: null,
|
|
1797
|
+
reason: `caddy reload failed: ${failure}`
|
|
1798
|
+
};
|
|
1799
|
+
} else if (!await (options.running ?? caddyRunning)()) return {
|
|
1800
|
+
url: null,
|
|
1801
|
+
reason: "Caddy is not running; start it with `brew services start caddy`"
|
|
1802
|
+
};
|
|
1803
|
+
return { url: `https://${hostName(hub)}` };
|
|
1804
|
+
}
|
|
1805
|
+
/** The macOS app's bundle identifier, from apps/macos/scripts/package.sh. */
|
|
1806
|
+
const APP_BUNDLE_ID = "space.carlos.mainflow";
|
|
1807
|
+
/** Close the tabs Safari and Chrome opened on this hub's /open route; each browser only when it is running. */
|
|
1808
|
+
const closeOpenTabs = (hub) => {
|
|
1809
|
+
const marker = `${hostName(hub)}/open/`;
|
|
1810
|
+
return ["Safari", "Google Chrome"].map((app) => `if application "${app}" is running then
|
|
1811
|
+
tell application "${app}"
|
|
1812
|
+
repeat with w in (every window)
|
|
1813
|
+
close (every tab of w whose URL contains "${marker}")
|
|
1814
|
+
end repeat
|
|
1815
|
+
end tell
|
|
1816
|
+
end if`).join("\n");
|
|
1817
|
+
};
|
|
1818
|
+
/**
|
|
1819
|
+
* Show an address of the hub's interface in the macOS app through its mainflow:// scheme (docs/app.md "連結").
|
|
1820
|
+
* A link in a reply reaches here through the default browser, so once the app has the address the browser tab
|
|
1821
|
+
* it left is closed and the app brought back to the front. Returns false when no app handles the scheme; the
|
|
1822
|
+
* caller then leaves the page to the browser. Does nothing and returns false outside macOS.
|
|
1823
|
+
*/
|
|
1824
|
+
function openInApp(hub, target, closeAfterMs = 150) {
|
|
1825
|
+
if (process.platform !== "darwin") return false;
|
|
1826
|
+
if (spawnSync("open", [`mainflow://${hub}${target}`]).status !== 0) return false;
|
|
1827
|
+
setTimeout(() => {
|
|
1828
|
+
spawnSync("osascript", ["-e", closeOpenTabs(hub)]);
|
|
1829
|
+
spawn("open", ["-b", APP_BUNDLE_ID], {
|
|
1830
|
+
detached: true,
|
|
1831
|
+
stdio: "ignore"
|
|
1832
|
+
}).on("error", () => {}).unref();
|
|
1833
|
+
}, closeAfterMs);
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
//#endregion
|
|
1837
|
+
export { MAINLINE_FILE as $, setSessionStatus as A, appendOps as B, updateTask as C, readSession as D, loadSessions as E, sessionBriefing as F, reopenIssue as G, createFinding as H, turnReminder as I, setIssueArchived as J, setFindingArchived as K, decorateReply as L, startSession as M, transcriptPath as N, setSessionArchived as O, updateAction as P, refIndex as Q, endsWithMarker as R, supersedeDecision as S, ensureSession as T, createIssue as U, closeIssue as V, planIssue as W, loadHub as X, HubError as Y, linksInto as Z, setArchived as _, openInApp as a, findHubRoot as at, setMainlineStatus as b, migrateHub as c, addRepo as d, TASK_FILE as et, createDecision as f, initHub as g, dropChecklistItem as h, hostName as i, currentBranch as it, settleSession as j, setSessionInfo as k, acceptDecision as l, createTask as m, DEFAULT_UI_PORT as n, WriteError as nt, siteBlock as o, worktreesOf as ot, createMainline as p, setFindingStatus as q, caddySites as r, DATA_DIR as rt, syncHost as s, APP_BUNDLE_ID as t, ConflictError as tt, addHub as u, setChecklistItem as v, addAction as w, setMainlineTitle as x, setCriterion as y, mayDecorate as z };
|
package/dist/host.d.ts
CHANGED
|
@@ -21,4 +21,13 @@ export type HostResult = {
|
|
|
21
21
|
* different hub of the same name stops here: overwriting it would silently take that hub's host name.
|
|
22
22
|
*/
|
|
23
23
|
export declare function syncHost(root: string, hub: string, port: number, options?: HostOptions): Promise<HostResult>;
|
|
24
|
+
/** The macOS app's bundle identifier, from apps/macos/scripts/package.sh. */
|
|
25
|
+
export declare const APP_BUNDLE_ID = "space.carlos.mainflow";
|
|
26
|
+
/**
|
|
27
|
+
* Show an address of the hub's interface in the macOS app through its mainflow:// scheme (docs/app.md "連結").
|
|
28
|
+
* A link in a reply reaches here through the default browser, so once the app has the address the browser tab
|
|
29
|
+
* it left is closed and the app brought back to the front. Returns false when no app handles the scheme; the
|
|
30
|
+
* caller then leaves the page to the browser. Does nothing and returns false outside macOS.
|
|
31
|
+
*/
|
|
32
|
+
export declare function openInApp(hub: string, target: string, closeAfterMs?: number): boolean;
|
|
24
33
|
//#endregion
|
package/dist/host.js
CHANGED
|
@@ -1,89 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
//#region src/host.ts
|
|
6
|
-
const DEFAULT_UI_PORT = 4320;
|
|
7
|
-
const hostName = (hub) => `${hub}.mainflow.localhost`;
|
|
8
|
-
function siteBlock(hub, port) {
|
|
9
|
-
return [
|
|
10
|
-
`# mainflow hub ${hub}: written by \`mainflow ui\` to match the port it serves on; do not edit.`,
|
|
11
|
-
`${hostName(hub)} {`,
|
|
12
|
-
" tls internal",
|
|
13
|
-
`\treverse_proxy localhost:${port}`,
|
|
14
|
-
"}",
|
|
15
|
-
""
|
|
16
|
-
].join("\n");
|
|
17
|
-
}
|
|
18
|
-
/** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
|
|
19
|
-
function caddySites() {
|
|
20
|
-
if (process.env.MAINFLOW_CADDY_SITES) return process.env.MAINFLOW_CADDY_SITES;
|
|
21
|
-
const prefix = spawnSync("brew", ["--prefix"], { encoding: "utf8" });
|
|
22
|
-
if (prefix.error || prefix.status !== 0) return null;
|
|
23
|
-
return join(prefix.stdout.trim(), "etc/caddy/sites");
|
|
24
|
-
}
|
|
25
|
-
/** Reload Caddy from the Caddyfile two levels above the sites directory; the error text on failure. */
|
|
26
|
-
function reloadCaddy(sites) {
|
|
27
|
-
const caddyfile = join(sites, "..", "..", "Caddyfile");
|
|
28
|
-
const reload = spawnSync("caddy", [
|
|
29
|
-
"reload",
|
|
30
|
-
"--config",
|
|
31
|
-
caddyfile
|
|
32
|
-
], { encoding: "utf8" });
|
|
33
|
-
if (reload.error) return reload.error.message;
|
|
34
|
-
return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
|
|
38
|
-
* Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
|
|
39
|
-
* refused connection or a timeout means Caddy is down (observed 2026-09-25: checking for 200 reported a
|
|
40
|
-
* running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
|
|
41
|
-
*/
|
|
42
|
-
async function caddyRunning() {
|
|
43
|
-
const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
|
|
44
|
-
try {
|
|
45
|
-
await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
|
|
46
|
-
return true;
|
|
47
|
-
} catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
|
|
52
|
-
/**
|
|
53
|
-
* Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
|
|
54
|
-
* port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
|
|
55
|
-
* different hub of the same name stops here: overwriting it would silently take that hub's host name.
|
|
56
|
-
*/
|
|
57
|
-
async function syncHost(root, hub, port, options = {}) {
|
|
58
|
-
const sites = caddySites();
|
|
59
|
-
if (!sites || !existsSync(sites)) return {
|
|
60
|
-
url: null,
|
|
61
|
-
reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
|
|
62
|
-
};
|
|
63
|
-
const dir = join(root, DATA_DIR, "local", "caddy");
|
|
64
|
-
const file = join(dir, `${hub}.caddy`);
|
|
65
|
-
const link = join(sites, `mainflow-${hub}.caddy`);
|
|
66
|
-
const linked = lstatSync(link, { throwIfNoEntry: false });
|
|
67
|
-
if (linked) {
|
|
68
|
-
const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
|
|
69
|
-
if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
|
|
70
|
-
}
|
|
71
|
-
const block = siteBlock(hub, port);
|
|
72
|
-
if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
|
|
73
|
-
mkdirSync(dir, { recursive: true });
|
|
74
|
-
writeFileSync(file, block);
|
|
75
|
-
if (linked) rmSync(link);
|
|
76
|
-
symlinkSync(file, link);
|
|
77
|
-
const failure = (options.reload ?? reloadCaddy)(sites);
|
|
78
|
-
if (failure) return {
|
|
79
|
-
url: null,
|
|
80
|
-
reason: `caddy reload failed: ${failure}`
|
|
81
|
-
};
|
|
82
|
-
} else if (!await (options.running ?? caddyRunning)()) return {
|
|
83
|
-
url: null,
|
|
84
|
-
reason: "Caddy is not running; start it with `brew services start caddy`"
|
|
85
|
-
};
|
|
86
|
-
return { url: `https://${hostName(hub)}` };
|
|
87
|
-
}
|
|
88
|
-
//#endregion
|
|
89
|
-
export { DEFAULT_UI_PORT, caddySites, hostName, siteBlock, syncHost };
|
|
1
|
+
import { a as openInApp, i as hostName, n as DEFAULT_UI_PORT, o as siteBlock, r as caddySites, s as syncHost, t as APP_BUNDLE_ID } from "./host-BFGfpTDq.js";
|
|
2
|
+
export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, siteBlock, syncHost };
|
|
@@ -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
|
@@ -45,6 +45,12 @@ export type PortChoice = {
|
|
|
45
45
|
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
46
46
|
*/
|
|
47
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;
|
|
48
54
|
export interface LaunchOptions {
|
|
49
55
|
port?: string;
|
|
50
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 readUiRecord, r as launchUi, t as PORT_SPAN } from "./launch-
|
|
2
|
-
export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord };
|
|
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,19 +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
|
-
//#region src/version.ts
|
|
218
|
-
/** This CLI's own version, from the package manifest one level above both src/ and dist/. */
|
|
219
|
-
const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
220
|
-
//#endregion
|
|
221
208
|
//#region src/server.ts
|
|
222
209
|
/** Every reference with a hub prefix in the documents of `hub`: links, origins and replaced items. */
|
|
223
210
|
function prefixedRefs(hub) {
|
|
@@ -465,9 +452,16 @@ function webDir() {
|
|
|
465
452
|
* Resolves once the port is listening. A port taken between the free-port probe and here rejects with a
|
|
466
453
|
* WriteError instead of Node's unhandled 'error' event and stack trace.
|
|
467
454
|
*/
|
|
468
|
-
function serveUi(root, port) {
|
|
455
|
+
function serveUi(root, port, open = openInApp) {
|
|
469
456
|
const app = createApp(root);
|
|
470
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
|
+
});
|
|
471
465
|
app.get("*", (c) => {
|
|
472
466
|
let file = join(dist, normalize(decodeURIComponent(c.req.path)));
|
|
473
467
|
if (!file.startsWith(dist + sep) || !existsSync(file) || !statSync(file).isFile()) file = join(dist, "index.html");
|
|
@@ -481,160 +475,10 @@ function serveUi(root, port) {
|
|
|
481
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));
|
|
482
476
|
});
|
|
483
477
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
/** Where the last server started is remembered: machine-specific, so under the unversioned local/ folder. */
|
|
489
|
-
const uiRecordFile = (root) => join(root, DATA_DIR, "local", "ui.json");
|
|
490
|
-
function readUiRecord(root) {
|
|
491
|
-
try {
|
|
492
|
-
const record = JSON.parse(readFileSync(uiRecordFile(root), "utf8"));
|
|
493
|
-
if (!Number.isInteger(record.port)) return void 0;
|
|
494
|
-
return {
|
|
495
|
-
port: record.port,
|
|
496
|
-
...Number.isInteger(record.pid) && { pid: record.pid },
|
|
497
|
-
...typeof record.version === "string" && { version: record.version }
|
|
498
|
-
};
|
|
499
|
-
} catch {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
function writeUiRecord(root, record) {
|
|
504
|
-
mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
|
|
505
|
-
writeFileSync(uiRecordFile(root), `${JSON.stringify(record)}\n`);
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
509
|
-
* the hub's configured port, then the last one used, then the default range.
|
|
510
|
-
*/
|
|
511
|
-
function portCandidates(options) {
|
|
512
|
-
if (options.explicit !== void 0) return [options.explicit];
|
|
513
|
-
const range = Array.from({ length: 100 }, (_, i) => DEFAULT_UI_PORT + i);
|
|
514
|
-
return [...new Set([
|
|
515
|
-
options.configured,
|
|
516
|
-
options.last,
|
|
517
|
-
...range
|
|
518
|
-
].filter((p) => p !== void 0))];
|
|
519
|
-
}
|
|
520
|
-
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
521
|
-
const networkProbe = {
|
|
522
|
-
async serverAt(port) {
|
|
523
|
-
try {
|
|
524
|
-
const res = await fetch(`http://localhost:${port}/api/hub`, { signal: AbortSignal.timeout(500) });
|
|
525
|
-
if (!res.ok) return null;
|
|
526
|
-
const body = await res.json();
|
|
527
|
-
if (typeof body.hub !== "string") return null;
|
|
528
|
-
return {
|
|
529
|
-
hub: body.hub,
|
|
530
|
-
...typeof body.version === "string" && { version: body.version }
|
|
531
|
-
};
|
|
532
|
-
} catch {
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
},
|
|
536
|
-
isFree: (port) => new Promise((done) => {
|
|
537
|
-
const probe = createServer().once("error", () => done(false));
|
|
538
|
-
probe.listen(port, () => probe.close(() => done(true)));
|
|
539
|
-
}),
|
|
540
|
-
listenerOf(port) {
|
|
541
|
-
const out = spawnSync("lsof", [
|
|
542
|
-
"-nP",
|
|
543
|
-
`-iTCP:${port}`,
|
|
544
|
-
"-sTCP:LISTEN",
|
|
545
|
-
"-t"
|
|
546
|
-
], { encoding: "utf8" });
|
|
547
|
-
const pid = Number(out.stdout?.split("\n")[0]);
|
|
548
|
-
return out.status === 0 && Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
549
|
-
},
|
|
550
|
-
async stop(pid, port) {
|
|
551
|
-
try {
|
|
552
|
-
process.kill(pid, "SIGTERM");
|
|
553
|
-
} catch {
|
|
554
|
-
return false;
|
|
555
|
-
}
|
|
556
|
-
for (let i = 0; i < 25; i++) {
|
|
557
|
-
await sleep(200);
|
|
558
|
-
if (await networkProbe.isFree(port)) return true;
|
|
559
|
-
}
|
|
560
|
-
return false;
|
|
561
|
-
}
|
|
562
|
-
};
|
|
563
|
-
/**
|
|
564
|
-
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
565
|
-
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
566
|
-
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
567
|
-
*/
|
|
568
|
-
async function choosePort(hub, candidates, probe = networkProbe) {
|
|
569
|
-
const servers = await Promise.all(candidates.map((port) => probe.serverAt(port)));
|
|
570
|
-
const at = candidates.findIndex((_, i) => servers[i]?.hub === hub);
|
|
571
|
-
if (at >= 0) return {
|
|
572
|
-
port: candidates[at],
|
|
573
|
-
running: servers[at].version ?? ""
|
|
574
|
-
};
|
|
575
|
-
for (const [i, port] of candidates.entries()) if (servers[i] === null && await probe.isFree(port)) return {
|
|
576
|
-
port,
|
|
577
|
-
running: void 0
|
|
578
|
-
};
|
|
579
|
-
const tried = candidates.length === 1 ? `port ${candidates[0]} is` : `ports ${candidates[0]} to ${candidates.at(-1)} are`;
|
|
580
|
-
throw new WriteError(`${tried} taken by other programs; free one, or pass --port <port>`);
|
|
581
|
-
}
|
|
582
|
-
/** Open a URL in the default browser without waiting for it. */
|
|
583
|
-
function openUrl(url) {
|
|
584
|
-
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
585
|
-
"cmd",
|
|
586
|
-
"/c",
|
|
587
|
-
"start",
|
|
588
|
-
"",
|
|
589
|
-
url
|
|
590
|
-
] : ["xdg-open", url];
|
|
591
|
-
spawn(command, args, {
|
|
592
|
-
detached: true,
|
|
593
|
-
stdio: "ignore"
|
|
594
|
-
}).on("error", () => console.log(`open ${url} in a browser`)).unref();
|
|
595
|
-
}
|
|
596
|
-
/** Start or reuse this hub's interface: the URL it is reached at, and the server when this call started one. */
|
|
597
|
-
async function launchUi(root, hub, configured, options) {
|
|
598
|
-
const log = options.log ?? console.log;
|
|
599
|
-
let explicit;
|
|
600
|
-
if (options.port !== void 0) {
|
|
601
|
-
explicit = Number(options.port);
|
|
602
|
-
if (!Number.isInteger(explicit) || explicit < 1024 || explicit > 65535) throw new WriteError("--port must be an integer from 1024 to 65535, e.g. --port 4320");
|
|
603
|
-
}
|
|
604
|
-
const probe = options.probe ?? networkProbe;
|
|
605
|
-
const record = readUiRecord(root);
|
|
606
|
-
const choice = await choosePort(hub, portCandidates({
|
|
607
|
-
explicit,
|
|
608
|
-
configured,
|
|
609
|
-
last: record?.port
|
|
610
|
-
}), probe);
|
|
611
|
-
let reuse = choice.running !== void 0;
|
|
612
|
-
if (reuse && choice.running !== CLI_VERSION) {
|
|
613
|
-
const from = choice.running || "unknown";
|
|
614
|
-
const pid = record?.port === choice.port && record.pid !== void 0 ? record.pid : probe.listenerOf(choice.port);
|
|
615
|
-
if (pid !== null && await probe.stop(pid, choice.port)) {
|
|
616
|
-
log(`! restarted: mainflow ${from} → ${CLI_VERSION}`);
|
|
617
|
-
reuse = false;
|
|
618
|
-
} 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`);
|
|
619
|
-
}
|
|
620
|
-
let server;
|
|
621
|
-
if (!reuse) {
|
|
622
|
-
server = await serveUi(root, choice.port);
|
|
623
|
-
writeUiRecord(root, {
|
|
624
|
-
port: choice.port,
|
|
625
|
-
pid: process.pid,
|
|
626
|
-
version: CLI_VERSION
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
const host = await syncHost(root, hub, choice.port, options.host);
|
|
630
|
-
if (host.url === null) log(`! host name not available: ${host.reason}`);
|
|
631
|
-
const url = host.url ?? `http://localhost:${choice.port}`;
|
|
632
|
-
log(`${reuse ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
|
|
633
|
-
if (options.open) openUrl(url);
|
|
634
|
-
return {
|
|
635
|
-
url,
|
|
636
|
-
server
|
|
637
|
-
};
|
|
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>`;
|
|
638
482
|
}
|
|
639
483
|
//#endregion
|
|
640
|
-
export {
|
|
484
|
+
export { serveUi };
|