napbox 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export declare function sweepCachedVersions(): Promise<void>;
@@ -0,0 +1,17 @@
1
+ // 插件把每个版本的包装进缓存目录(scripts/server-launcher.mjs)。一周没启动过的版本删掉,别的版本要用时重新安装。
2
+ import { readdir, rm, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { cacheDirectory, packageVersion } from "./locations.js";
5
+ const KEEP_MS = 7 * 24 * 60 * 60 * 1000;
6
+ export async function sweepCachedVersions() {
7
+ const versions = path.join(cacheDirectory(), "versions");
8
+ const names = await readdir(versions).catch(() => []);
9
+ for (const name of names) {
10
+ if (name === packageVersion)
11
+ continue;
12
+ const directory = path.join(versions, name);
13
+ const used = await stat(path.join(directory, "last-used")).catch(() => stat(directory));
14
+ if (Date.now() - used.mtimeMs > KEEP_MS)
15
+ await rm(directory, { recursive: true, force: true });
16
+ }
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { EnvironmentInfo } from "../protocol.ts";
2
+ export interface HookInput {
3
+ hook_event_name?: string;
4
+ cwd?: string;
5
+ tool_name?: string;
6
+ tool_input?: {
7
+ command?: unknown;
8
+ };
9
+ }
10
+ export declare function runHook(input: HookInput): Promise<Record<string, unknown> | undefined>;
11
+ export declare function describeEnvironment(info: EnvironmentInfo): string;
@@ -0,0 +1,139 @@
1
+ // Claude Code 插件的钩子(DESIGN.md 第 4 节、第 12 节第 3 条):
2
+ // - 会话开始和每次压缩上下文之后,当前 worktree 有环境就把端口告诉 agent;
3
+ // - 已接入的项目里运行 docker compose up/start/run 时拦下;自己起数据库这类服务、或者 docker run 时提醒。
4
+ import { execFile } from "node:child_process";
5
+ import { existsSync, realpathSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { NapboxClient, NapboxError } from "../client.js";
8
+ import { composeSubcommand, isDockerRun, simpleCommands, startsOwnService } from "./shell-command.js";
9
+ const BLOCKED_COMPOSE = new Set(["up", "start", "run"]);
10
+ const RUN_INSTEAD = "要像 docker compose run 那样单独跑一次某个服务(测试、检查),用 run 工具(命令行是 npx napbox run <服务> [-- 命令]):临时起一个环境,在前台跑它,跑完销毁,返回它的退出码和输出。";
11
+ const TEMP_INSTEAD = "测试要一套用完就扔的(比如空的数据库),给 up 工具传 temp 起一个临时环境(命令行是 npx napbox up --temp <名字>),用完调 down 传同一个名字。";
12
+ /** 大多数 Bash 命令到这里就结束,不用查 git 和 napboxd */
13
+ const MIGHT_MATTER = /docker|postgres|pg_ctl|mysql|mongod|redis-server|minio|brew/;
14
+ export async function runHook(input) {
15
+ const cwd = input.cwd || process.cwd();
16
+ if (input.hook_event_name === "SessionStart") {
17
+ const note = await sessionNote(cwd);
18
+ return note
19
+ ? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: note } }
20
+ : undefined;
21
+ }
22
+ if (input.hook_event_name === "PreToolUse" && input.tool_name === "Bash") {
23
+ const command = input.tool_input?.command;
24
+ return typeof command === "string" ? await checkCommand(command, cwd) : undefined;
25
+ }
26
+ return undefined;
27
+ }
28
+ async function sessionNote(cwd) {
29
+ let info;
30
+ try {
31
+ info = await new NapboxClient().environment({ cwd });
32
+ }
33
+ catch (error) {
34
+ if (error instanceof NapboxError && error.code === "daemon_unavailable") {
35
+ return (await isOnboarded(cwd))
36
+ ? "这个项目用 napbox 管理服务,但 napbox 的后台程序没有运行。需要服务时调用 napbox 的 setup 工具。"
37
+ : undefined;
38
+ }
39
+ // 这里没有环境(environment_not_found、not_in_repository 等):什么都不说
40
+ if (error instanceof NapboxError)
41
+ return undefined;
42
+ throw error;
43
+ }
44
+ return describeEnvironment(info);
45
+ }
46
+ export function describeEnvironment(info) {
47
+ const lines = [`这个 worktree 的 napbox 环境是 ${info.name}(${info.state})。服务和端口:`];
48
+ for (const port of info.ports) {
49
+ lines.push(`- ${port.service}:${port.containerPort} → 127.0.0.1:${port.hostPort}(变量 ${port.variable})`);
50
+ }
51
+ if (info.state !== "running")
52
+ lines.push("休眠时一连接就会自动唤醒,不用手动启动。");
53
+ if (info.lastError)
54
+ lines.push(`最近出错:${info.lastError}`);
55
+ if (info.unsetVariables.length) {
56
+ lines.push(`compose 里用到但没有值的变量:${info.unsetVariables.join("、")}`);
57
+ }
58
+ lines.push("用 napbox 的工具(up、env、logs、exec、reset)管理这些服务,不要运行 docker compose up。");
59
+ return lines.join("\n");
60
+ }
61
+ async function checkCommand(command, cwd) {
62
+ if (!MIGHT_MATTER.test(command))
63
+ return undefined;
64
+ const commands = simpleCommands(command);
65
+ for (const { argv, cds } of commands) {
66
+ const subcommand = composeSubcommand(argv);
67
+ if (subcommand === undefined || !BLOCKED_COMPOSE.has(subcommand))
68
+ continue;
69
+ if (!(await isOnboarded(resolveCds(cwd, cds))))
70
+ continue;
71
+ return {
72
+ hookSpecificOutput: {
73
+ hookEventName: "PreToolUse",
74
+ permissionDecision: "deny",
75
+ permissionDecisionReason: `这个项目的服务由 napbox 管理,不能运行 docker compose ${subcommand}。改用 napbox 的 up 工具(命令行是 npx napbox up),它用同一份 compose 起服务,并返回分配好的端口和变量。服务里执行命令用 exec 工具。${subcommand === "run" ? RUN_INSTEAD : ""}如果用户明确要用 Docker,请用户自己在终端运行。`,
76
+ },
77
+ };
78
+ }
79
+ for (const { argv, cds } of commands) {
80
+ const reminder = ownServiceReminder(argv);
81
+ if (reminder === undefined || !(await isOnboarded(resolveCds(cwd, cds))))
82
+ continue;
83
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: reminder } };
84
+ }
85
+ return undefined;
86
+ }
87
+ /** 不拦、只提醒的命令:在宿主机上自己起数据库这类服务,或者 docker run 直接起一个容器 */
88
+ function ownServiceReminder(argv) {
89
+ const service = startsOwnService(argv);
90
+ if (service !== undefined) {
91
+ return `提醒:这个项目的服务由 napbox 管理。如果 ${service} 是 compose 里已有的服务,用 napbox 的 up 工具和它分配的端口,不要自己再起一份。${TEMP_INSTEAD}`;
92
+ }
93
+ if (isDockerRun(argv)) {
94
+ return `提醒:这个项目的服务由 napbox 管理。如果 docker run 要起的是 compose 里已有的服务,用 napbox 的工具,不要自己再起一份。${TEMP_INSTEAD}只跑一次拿结果的,用 run 工具(命令行是 npx napbox run <服务> [-- 命令])。`;
95
+ }
96
+ return undefined;
97
+ }
98
+ function resolveCds(cwd, cds) {
99
+ return cds.reduce((directory, target) => path.resolve(directory, target), cwd);
100
+ }
101
+ /** 已接入:worktree 根目录有 .napbox.yaml,或者这个仓库的某个 worktree 有环境记录(DESIGN.md 第 12 节第 3 条) */
102
+ async function isOnboarded(directory) {
103
+ const root = await git(directory, ["rev-parse", "--show-toplevel"]);
104
+ if (!root)
105
+ return false;
106
+ if (existsSync(path.join(root.trim(), ".napbox.yaml")))
107
+ return true;
108
+ let worktrees;
109
+ try {
110
+ const { environments } = await new NapboxClient().status();
111
+ worktrees = new Set(environments.filter((e) => !e.owner).map((e) => canonical(e.worktree)));
112
+ }
113
+ catch (error) {
114
+ if (error instanceof NapboxError && error.code === "daemon_unavailable")
115
+ return false;
116
+ throw error;
117
+ }
118
+ const listed = (await git(directory, ["worktree", "list", "--porcelain"])) ?? "";
119
+ return listed
120
+ .split("\n")
121
+ .filter((line) => line.startsWith("worktree "))
122
+ .some((line) => worktrees.has(canonical(line.slice("worktree ".length))));
123
+ }
124
+ /** 路径可能写成 /var 或 /private/var,比较前统一。已经不存在的路径原样比较 */
125
+ function canonical(file) {
126
+ try {
127
+ return realpathSync(file);
128
+ }
129
+ catch {
130
+ return file;
131
+ }
132
+ }
133
+ function git(directory, args) {
134
+ return new Promise((resolve) => {
135
+ execFile("git", ["-C", directory, ...args], (error, stdout) => {
136
+ resolve(error ? undefined : stdout);
137
+ });
138
+ });
139
+ }
@@ -0,0 +1,6 @@
1
+ export declare const packageRoot: string;
2
+ export declare const packageVersion: string;
3
+ /** 编译好的 napbox、napboxd、napbox-guard。发布的包在 native/ 下;仓库里没有,要用 NAPBOX_BIN 指向编译产物 */
4
+ export declare const nativeDirectory: () => string;
5
+ /** 插件把固定版本的包装在这里。可以随时删,下次用到时重新安装;napboxd 不放这里(DESIGN.md 第 4 节) */
6
+ export declare const cacheDirectory: () => string;
@@ -0,0 +1,11 @@
1
+ // 包自己的位置和版本。发布后是 dist/agent/ 下的 .js,仓库里是 src/agent/ 下的 .ts,往上两层都是包的根目录。
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ export const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
7
+ export const packageVersion = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
8
+ /** 编译好的 napbox、napboxd、napbox-guard。发布的包在 native/ 下;仓库里没有,要用 NAPBOX_BIN 指向编译产物 */
9
+ export const nativeDirectory = () => process.env.NAPBOX_BIN || path.join(packageRoot, "native");
10
+ /** 插件把固定版本的包装在这里。可以随时删,下次用到时重新安装;napboxd 不放这里(DESIGN.md 第 4 节) */
11
+ export const cacheDirectory = () => path.join(homedir(), "Library/Caches/napbox");
@@ -0,0 +1 @@
1
+ export declare function serveMcp(): Promise<void>;
@@ -0,0 +1,132 @@
1
+ // 标准输入输出上的 MCP 服务(JSON-RPC,一行一条)。只提供工具,协议部分照 layermap 手写,不加 SDK 依赖。
2
+ import { createInterface } from "node:readline";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { NapboxClient } from "../client.js";
6
+ import { packageVersion } from "./locations.js";
7
+ import { callTool, listTools } from "./tools.js";
8
+ const ROOTS_WAIT_MS = 5_000;
9
+ const INSTRUCTIONS = "napbox 用项目的 compose 文件给每个 worktree 起一套隔离的开发服务(数据库、对象存储、后端),不用 Docker,端口不冲突,同一个环境的端口固定不变,闲置自动休眠,连接时自动唤醒。需要这些服务时用 up 工具,不要运行 docker compose up。测试要一套用完就扔的服务时给 up 传 temp,要单独跑一次某个服务时用 run 工具,不要运行 docker run。前端、小程序要连后端时,由项目的启动脚本从 up 或 env 取地址交给它们,不要把端口写进代码。新项目要用这些服务时也照这样:写 compose.yaml(ports 只写容器端口)用 up 起,项目的脚本和 README 里不要用 docker,写法见 napbox 技能。";
10
+ export function serveMcp() {
11
+ const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
12
+ const client = new NapboxClient();
13
+ const calls = new Map();
14
+ const replies = new Map();
15
+ // 环境按 worktree 识别,所以要知道会话在哪工作。客户端不一定在那里启动服务:Claude Code 桌面版
16
+ // 在主 checkout 启动,会话却在别的 worktree。客户端能列出 roots 时以第一个 root 为准。
17
+ let sessionDirectory = process.cwd();
18
+ let listsRoots = false;
19
+ let rooted = Promise.resolve();
20
+ let requests = 0;
21
+ const readRoots = () => {
22
+ const id = `napbox-roots-${++requests}`;
23
+ const reply = new Promise((resolve) => replies.set(id, resolve));
24
+ send({ id, method: "roots/list" });
25
+ rooted = Promise.race([reply, delay(ROOTS_WAIT_MS, undefined, { ref: false })]).then((message) => {
26
+ replies.delete(id);
27
+ const roots = message?.result?.roots;
28
+ const root = Array.isArray(roots)
29
+ ? roots.find((item) => typeof item?.uri === "string" && item.uri.startsWith("file:"))
30
+ : undefined;
31
+ if (root && id === `napbox-roots-${requests}`)
32
+ sessionDirectory = fileURLToPath(root.uri);
33
+ });
34
+ };
35
+ const call = async (id, params) => {
36
+ const controller = new AbortController();
37
+ calls.set(id, controller);
38
+ try {
39
+ const { value, isError } = await callTool(String(params.name), params.arguments, {
40
+ client,
41
+ sessionDirectory: async () => {
42
+ await rooted;
43
+ return sessionDirectory;
44
+ },
45
+ signal: controller.signal,
46
+ });
47
+ send({
48
+ id,
49
+ result: { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError },
50
+ });
51
+ }
52
+ catch (error) {
53
+ // 取消的请求不用回应(MCP 规定)
54
+ if (controller.signal.aborted)
55
+ return;
56
+ send({
57
+ id,
58
+ result: {
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: JSON.stringify({ error: { code: "internal_error", message: String(error) } }),
63
+ },
64
+ ],
65
+ isError: true,
66
+ },
67
+ });
68
+ }
69
+ finally {
70
+ calls.delete(id);
71
+ }
72
+ };
73
+ const handle = (message) => {
74
+ const { id, method, params = {} } = message;
75
+ if (method === undefined) {
76
+ if (id !== undefined && id !== null)
77
+ replies.get(id)?.(message);
78
+ return;
79
+ }
80
+ if (id === undefined || id === null) {
81
+ if (listsRoots && (method === "notifications/initialized" || method === "notifications/roots/list_changed")) {
82
+ readRoots();
83
+ }
84
+ if (method === "notifications/cancelled") {
85
+ calls.get(params.requestId)?.abort();
86
+ }
87
+ return;
88
+ }
89
+ switch (method) {
90
+ case "initialize":
91
+ listsRoots = Boolean(params.capabilities?.roots);
92
+ return send({
93
+ id,
94
+ result: {
95
+ protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18",
96
+ capabilities: { tools: { listChanged: false } },
97
+ serverInfo: { name: "napbox", title: "napbox", version: packageVersion },
98
+ instructions: INSTRUCTIONS,
99
+ },
100
+ });
101
+ case "ping":
102
+ return send({ id, result: {} });
103
+ case "tools/list":
104
+ return send({ id, result: { tools: listTools() } });
105
+ case "tools/call":
106
+ return void call(id, params);
107
+ default:
108
+ return send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
109
+ }
110
+ };
111
+ return new Promise((resolve) => {
112
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
113
+ lines.on("line", (line) => {
114
+ if (!line.trim())
115
+ return;
116
+ let message;
117
+ try {
118
+ message = JSON.parse(line);
119
+ }
120
+ catch {
121
+ return send({ id: null, error: { code: -32700, message: "Parse error" } });
122
+ }
123
+ handle(message);
124
+ });
125
+ // 会话结束:结束还在跑的 exec,进程退出时连接随之断开
126
+ lines.on("close", () => {
127
+ for (const controller of calls.values())
128
+ controller.abort();
129
+ resolve();
130
+ });
131
+ });
132
+ }
@@ -0,0 +1,20 @@
1
+ export interface SetupResult {
2
+ version: string;
3
+ /** started:新启动;restarted:换了版本;unchanged:已经是这个版本;kept:有环境在运行,没换 */
4
+ daemon: "started" | "restarted" | "unchanged" | "kept";
5
+ daemonVersion: string;
6
+ pid: number;
7
+ message?: string;
8
+ runningEnvironments?: string[];
9
+ }
10
+ export interface SetupOptions {
11
+ restart: boolean;
12
+ log: (line: string) => void;
13
+ }
14
+ export declare function setup({ restart, log }: SetupOptions): Promise<SetupResult>;
15
+ export type SetupAgent = "claude" | "codex";
16
+ export declare function registerMcp(agent: SetupAgent): Promise<{
17
+ agent: SetupAgent;
18
+ server: string;
19
+ command: string[];
20
+ }>;
@@ -0,0 +1,255 @@
1
+ // napbox setup(DESIGN.md 第 4 节):把后台程序放到固定位置,准备内核和 vminit,注册成登录项并启动。
2
+ // 不放在 npm 或 npx 的目录里运行:那些目录会变、会被清理,登录项要一直找得到程序。
3
+ import { execFile as execFileCallback } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { chmod, cp, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import path from "node:path";
9
+ import { promisify } from "node:util";
10
+ import { NapboxClient, NapboxError, supportDirectory } from "../client.js";
11
+ import { cacheDirectory, nativeDirectory, packageRoot, packageVersion } from "./locations.js";
12
+ const execFile = promisify(execFileCallback);
13
+ const BASE_LABEL = "io.github.coffeecoproject.napbox";
14
+ const MINIMUM_MACOS = 26;
15
+ /** napboxd 收到 SIGTERM 后要停掉所有环境,launchd 默认只等 20 秒 */
16
+ const EXIT_TIMEOUT_SECONDS = 120;
17
+ const START_TIMEOUT_MS = 20_000;
18
+ const failed = (message) => new NapboxError("setup_failed", message);
19
+ /** 设了 NAPBOX_SUPPORT_DIR(测试用的独立目录)时用另一个名字,不和正式的登录项冲突 */
20
+ function launchLabel() {
21
+ const custom = process.env.NAPBOX_SUPPORT_DIR;
22
+ if (!custom)
23
+ return BASE_LABEL;
24
+ return `${BASE_LABEL}.${createHash("sha256").update(custom).digest("hex").slice(0, 8)}`;
25
+ }
26
+ const launchDomain = () => `gui/${process.getuid?.()}`;
27
+ async function launchctl(...args) {
28
+ return execFile("launchctl", args);
29
+ }
30
+ async function isRegistered() {
31
+ try {
32
+ await launchctl("print", `${launchDomain()}/${launchLabel()}`);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ async function checkPlatform() {
40
+ if (process.platform !== "darwin" || process.arch !== "arm64") {
41
+ throw failed(`napbox 只支持 Apple 芯片的 Mac,这里是 ${process.platform} ${process.arch}`);
42
+ }
43
+ const { stdout } = await execFile("sw_vers", ["-productVersion"]);
44
+ if (Number.parseInt(stdout, 10) < MINIMUM_MACOS) {
45
+ throw failed(`napbox 要 macOS ${MINIMUM_MACOS} 或更新的版本,这里是 ${stdout.trim()}`);
46
+ }
47
+ }
48
+ async function runningDaemon(client) {
49
+ try {
50
+ return await client.status();
51
+ }
52
+ catch (error) {
53
+ if (error instanceof NapboxError && error.code === "daemon_unavailable")
54
+ return undefined;
55
+ throw error;
56
+ }
57
+ }
58
+ const fileHash = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
59
+ export async function setup({ restart, log }) {
60
+ await checkPlatform();
61
+ const source = nativeDirectory();
62
+ const sourceDaemon = path.join(source, "napboxd");
63
+ if (!existsSync(sourceDaemon))
64
+ throw failed(`没有 ${sourceDaemon}:包不完整`);
65
+ const client = new NapboxClient();
66
+ const current = await runningDaemon(client);
67
+ const registered = await isRegistered();
68
+ if (current && !registered) {
69
+ throw failed(`有一份不是登录项的 napboxd 在运行(pid ${current.pid}),先停掉它再运行 setup`);
70
+ }
71
+ const installDirectory = path.join(supportDirectory(), "daemon", packageVersion);
72
+ const installedDaemon = path.join(installDirectory, "napboxd");
73
+ const sameProgram = existsSync(installedDaemon) && fileHash(installedDaemon) === fileHash(sourceDaemon);
74
+ const upToDate = current?.version === packageVersion && sameProgram;
75
+ if (current && !upToDate && !restart) {
76
+ const running = current.environments.filter((e) => e.state === "running").map((e) => e.name);
77
+ if (running.length) {
78
+ return {
79
+ version: packageVersion,
80
+ daemon: "kept",
81
+ daemonVersion: current.version,
82
+ pid: current.pid,
83
+ runningEnvironments: running,
84
+ message: `有 ${running.length} 个环境在运行,后台程序还是 ${current.version},没有换。换版本会停掉这些环境、断开连接;要现在换就加 restart。`,
85
+ };
86
+ }
87
+ }
88
+ if (!sameProgram)
89
+ await install(source, installDirectory);
90
+ await prepare(installedDaemon, log);
91
+ if (current && upToDate) {
92
+ return { version: packageVersion, daemon: "unchanged", daemonVersion: current.version, pid: current.pid };
93
+ }
94
+ await writeLaunchAgent(installedDaemon);
95
+ if (registered)
96
+ await stopRegistered(client);
97
+ await bootstrap();
98
+ const started = await waitForDaemon(client);
99
+ if (started.version !== packageVersion) {
100
+ throw failed(`启动后 napboxd 的版本是 ${started.version},包的版本是 ${packageVersion},这个包打得不对`);
101
+ }
102
+ await removeOldVersions(path.dirname(installDirectory));
103
+ return {
104
+ version: packageVersion,
105
+ daemon: current ? "restarted" : "started",
106
+ daemonVersion: started.version,
107
+ pid: started.pid,
108
+ };
109
+ }
110
+ /** 先放进临时目录再改名:不会留下一半 */
111
+ async function install(source, destination) {
112
+ const parent = path.dirname(destination);
113
+ await mkdir(parent, { recursive: true });
114
+ const staging = await mkdtemp(path.join(parent, ".install-"));
115
+ try {
116
+ for (const name of ["napboxd", "napbox-guard"]) {
117
+ await cp(path.join(source, name), path.join(staging, name));
118
+ await chmod(path.join(staging, name), 0o755);
119
+ }
120
+ await rm(destination, { recursive: true, force: true });
121
+ await rename(staging, destination);
122
+ }
123
+ finally {
124
+ await rm(staging, { recursive: true, force: true });
125
+ }
126
+ }
127
+ async function prepare(daemon, log) {
128
+ try {
129
+ const { stdout } = await execFile(daemon, ["prepare"], { maxBuffer: 1 << 20 });
130
+ for (const line of stdout.split("\n"))
131
+ if (line)
132
+ log(line);
133
+ }
134
+ catch (error) {
135
+ const { stderr, message } = error;
136
+ throw failed(`准备内核和 vminit 失败:${stderr?.trim() || message}`);
137
+ }
138
+ }
139
+ const xml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
140
+ function launchAgent(daemon) {
141
+ const log = xml(path.join(supportDirectory(), "napboxd.log"));
142
+ const custom = process.env.NAPBOX_SUPPORT_DIR;
143
+ const environment = custom
144
+ ? ` <key>EnvironmentVariables</key>
145
+ <dict>
146
+ <key>NAPBOX_SUPPORT_DIR</key>
147
+ <string>${xml(custom)}</string>
148
+ </dict>
149
+ `
150
+ : "";
151
+ // ProcessType:不写时 launchd 会限速,虚拟机跟着慢(DESIGN.md 第 2 节)。Interactive 和普通 app 一样不限。
152
+ // KeepAlive:崩溃了拉起来;正常退出(退出码 0)不拉,比如 launchctl bootout。
153
+ return `<?xml version="1.0" encoding="UTF-8"?>
154
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
155
+ <plist version="1.0">
156
+ <dict>
157
+ <key>Label</key>
158
+ <string>${launchLabel()}</string>
159
+ <key>ProgramArguments</key>
160
+ <array>
161
+ <string>${xml(daemon)}</string>
162
+ </array>
163
+ <key>RunAtLoad</key>
164
+ <true/>
165
+ <key>KeepAlive</key>
166
+ <dict>
167
+ <key>SuccessfulExit</key>
168
+ <false/>
169
+ </dict>
170
+ <key>ProcessType</key>
171
+ <string>Interactive</string>
172
+ <key>ExitTimeOut</key>
173
+ <integer>${EXIT_TIMEOUT_SECONDS}</integer>
174
+ <key>StandardOutPath</key>
175
+ <string>${log}</string>
176
+ <key>StandardErrorPath</key>
177
+ <string>${log}</string>
178
+ ${environment}</dict>
179
+ </plist>
180
+ `;
181
+ }
182
+ const launchAgentPath = () => path.join(homedir(), "Library/LaunchAgents", `${launchLabel()}.plist`);
183
+ async function writeLaunchAgent(daemon) {
184
+ const file = launchAgentPath();
185
+ await mkdir(path.dirname(file), { recursive: true });
186
+ await writeFile(`${file}.tmp`, launchAgent(daemon));
187
+ await rename(`${file}.tmp`, file);
188
+ }
189
+ /** 停掉正在运行的。bootout 发 SIGTERM,napboxd 停完环境才退出 */
190
+ async function stopRegistered(client) {
191
+ await launchctl("bootout", `${launchDomain()}/${launchLabel()}`).catch(() => { });
192
+ const deadline = Date.now() + (EXIT_TIMEOUT_SECONDS + 10) * 1000;
193
+ while ((await isRegistered()) || (await runningDaemon(client))) {
194
+ if (Date.now() > deadline)
195
+ throw failed("旧的 napboxd 一直没有退出");
196
+ await sleep(200);
197
+ }
198
+ }
199
+ /** launchd 刚清理完同名服务时可能短暂报错,等一下再试 */
200
+ async function bootstrap() {
201
+ for (let attempt = 0;; attempt++) {
202
+ try {
203
+ await launchctl("bootstrap", launchDomain(), launchAgentPath());
204
+ return;
205
+ }
206
+ catch (error) {
207
+ if (attempt >= 10) {
208
+ throw failed(`注册登录项失败:${error.stderr?.trim() || String(error)}`);
209
+ }
210
+ await sleep(500);
211
+ }
212
+ }
213
+ }
214
+ async function waitForDaemon(client) {
215
+ const deadline = Date.now() + START_TIMEOUT_MS;
216
+ for (;;) {
217
+ const status = await runningDaemon(client);
218
+ if (status)
219
+ return status;
220
+ if (Date.now() > deadline) {
221
+ throw failed(`napboxd 没有启动,看 ${path.join(supportDirectory(), "napboxd.log")}`);
222
+ }
223
+ await sleep(200);
224
+ }
225
+ }
226
+ async function removeOldVersions(directory) {
227
+ for (const name of await readdir(directory)) {
228
+ if (name !== packageVersion)
229
+ await rm(path.join(directory, name), { recursive: true, force: true });
230
+ }
231
+ }
232
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
233
+ /** agent 启动 MCP 服务用的命令。发布的包:缓存目录里的启动脚本,不依赖 npx 的目录;仓库里:直接运行自己 */
234
+ async function mcpCommand() {
235
+ const published = path.join(packageRoot, "dist/server-launcher.mjs");
236
+ if (!existsSync(published))
237
+ return [process.execPath, path.resolve(process.argv[1]), "mcp"];
238
+ const launcher = path.join(cacheDirectory(), "server.mjs");
239
+ await mkdir(cacheDirectory(), { recursive: true });
240
+ await cp(published, launcher);
241
+ return ["node", launcher];
242
+ }
243
+ export async function registerMcp(agent) {
244
+ const scope = agent === "claude" ? ["--scope", "user"] : [];
245
+ const command = await mcpCommand();
246
+ await execFile(agent, ["mcp", "remove", "napbox", ...scope]).catch(() => { });
247
+ try {
248
+ await execFile(agent, ["mcp", "add", ...scope, "napbox", "--", ...command]);
249
+ }
250
+ catch (error) {
251
+ const { stderr, message } = error;
252
+ throw failed(`加不进 ${agent}:${stderr?.trim() || message}`);
253
+ }
254
+ return { agent, server: "napbox", command };
255
+ }
@@ -0,0 +1,13 @@
1
+ export interface SimpleCommand {
2
+ argv: string[];
3
+ /** 这条命令之前出现过的 cd 的目标(只认字面路径),按顺序;相对路径相对于前一个 */
4
+ cds: string[];
5
+ }
6
+ /** 拆成简单命令。sh -c '...' 里的内容也拆开看 */
7
+ export declare function simpleCommands(command: string, depth?: number): SimpleCommand[];
8
+ /** docker compose 的子命令,不是 docker compose 时是 undefined */
9
+ export declare function composeSubcommand(argv: string[]): string | undefined;
10
+ /** 是不是 docker run(或者同一件事的另一种写法 docker container run):不靠 compose 直接起一个容器 */
11
+ export declare function isDockerRun(argv: string[]): boolean;
12
+ /** 自己在宿主机上起数据库这类服务的命令,返回一句话描述;不是时返回 undefined */
13
+ export declare function startsOwnService(argv: string[]): string | undefined;