personal-bot 0.2.3 → 0.2.4

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,162 @@
1
+ import { access, readFile, realpath } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { checkStableVersion, npmName } from "../../common/package-source.js";
7
+ import type { InstalledPackage, PackageChoice, PiInstallation, SetupOperations, SystemCommand } from "../../services/installation/setup-service.js";
8
+ import { findExecutable, npmGlobalBinary, SetupProcesses } from "./processes.js";
9
+
10
+ export const piPackageName = "@earendil-works/pi-coding-agent";
11
+ export class PiBootstrapRequired extends Error {}
12
+ const bridge = fileURLToPath(new URL("./pi-bridge.js", import.meta.url));
13
+ const guard = fileURLToPath(new URL("../../extension/setup-guard.js", import.meta.url));
14
+
15
+ async function packageRoot(command: string, version: string, env: NodeJS.ProcessEnv): Promise<string> {
16
+ const resolved = await realpath(command);
17
+ let directory = dirname(resolved);
18
+ while (true) {
19
+ try {
20
+ const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
21
+ if (pkg.name === piPackageName && pkg.version === version) return directory;
22
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
23
+ const parent = dirname(directory);
24
+ if (parent === directory) break;
25
+ directory = parent;
26
+ }
27
+ const roots = [join(dirname(dirname(resolved)), "install"), env.PI_MANAGED_INSTALL_ROOT].filter((root): root is string => !!root);
28
+ for (const root of roots) {
29
+ try {
30
+ // The native managed launcher resides beside install/. Only accept a matching
31
+ // launcher, not an unrelated PI_MANAGED_INSTALL_ROOT inherited from another Pi.
32
+ const launcher = await realpath(join(dirname(root), "bin/pi"));
33
+ if (launcher !== resolved) continue;
34
+ const current = (await readFile(join(root, "current-version"), "utf8")).trim();
35
+ if (current !== version || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(current)) continue;
36
+ const candidate = join(root, "releases", current, "node_modules", piPackageName);
37
+ const pkg = JSON.parse(await readFile(join(candidate, "package.json"), "utf8"));
38
+ if (pkg.name === piPackageName && pkg.version === version) return candidate;
39
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
40
+ }
41
+ throw new Error(`无法定位 ${command} 对应的 Pi 包。请先按此 Pi 的原生安装方式修复,不会覆盖该命令。`);
42
+ }
43
+
44
+ export class PiSetup implements SetupOperations {
45
+ constructor(readonly processes: SetupProcesses, readonly npm: string, readonly cwd: string) {}
46
+
47
+ async inspectPi(command?: string): Promise<PiInstallation | undefined> {
48
+ const executable = command ?? await findExecutable("pi", this.processes.env);
49
+ if (!executable) return undefined;
50
+ const version = await this.processes.run(executable, ["--version"], this.cwd);
51
+ if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)) throw new Error(`PATH 中的 ${executable} 不是可识别的 Pi。`);
52
+ return { command: executable, version, root: await packageRoot(executable, version, this.processes.env) };
53
+ }
54
+
55
+ async latest(name: string): Promise<string> {
56
+ const text = await this.processes.run(this.npm, ["view", `${name}@latest`, "version", "engines", "--json",
57
+ "--fetch-retries=0", "--fetch-timeout=15000"], this.cwd);
58
+ const value = JSON.parse(text) as { version?: string; engines?: { node?: string } };
59
+ if (typeof value.version !== "string") throw new Error(`无法取得 ${name} 的最新版本。`);
60
+ return checkStableVersion(value.version, value.engines?.node);
61
+ }
62
+
63
+ async inspectPackages(pi: PiInstallation | undefined, workspace: string): Promise<InstalledPackage[]> {
64
+ if (!pi) {
65
+ // A fresh machine can be planned without loading a Pi SDK. Existing declarations
66
+ // require Pi's own resolver; don't guess git cache paths or package identities.
67
+ const agentDir = this.processes.env.PI_CODING_AGENT_DIR ?? join(this.processes.env.HOME ?? homedir(), ".pi/agent");
68
+ for (const file of [join(agentDir, "settings.json"), join(workspace, ".pi/settings.json")]) {
69
+ try {
70
+ const settings: unknown = JSON.parse(await readFile(file, "utf8"));
71
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error(`无效的 Pi 配置:${file}`);
72
+ const packages = (settings as { packages?: unknown }).packages;
73
+ if (packages !== undefined && !Array.isArray(packages)) throw new Error(`packages 必须是数组:${file}`);
74
+ if (Array.isArray(packages) && packages.length > 0) {
75
+ throw new PiBootstrapRequired(`已有插件配置但未找到 Pi:${file}`);
76
+ }
77
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
78
+ }
79
+ return [];
80
+ }
81
+ const data = await this.processes.run(process.execPath, [bridge, "inspect", pi.root, workspace], this.cwd);
82
+ return JSON.parse(data) as InstalledPackage[];
83
+ }
84
+
85
+ async installTmux(command: SystemCommand): Promise<void> {
86
+ await this.processes.run(command.command, command.args, this.cwd, { interactive: true });
87
+ const tmux = await findExecutable("tmux", this.processes.env);
88
+ if (!tmux) throw new Error("tmux 安装后仍不在 PATH 中,请修复 PATH 后重试。");
89
+ await this.processes.run(tmux, ["-V"], this.cwd);
90
+ }
91
+
92
+ async ensurePi(existing: PiInstallation | undefined, version: string): Promise<PiInstallation> {
93
+ if (existing) {
94
+ if (existing.version !== version) await this.processes.run(existing.command, ["update"], this.cwd, { interactive: true });
95
+ const installed = await this.inspectPi(existing.command);
96
+ if (!installed || installed.version !== version) throw new Error(`Pi 实际版本 ${installed?.version ?? "未知"} 与目标 ${version} 不同。请用原安装方式更新后重试。`);
97
+ return installed;
98
+ }
99
+ const prefix = await this.processes.run(this.npm, ["prefix", "--global"], this.cwd);
100
+ if (!prefix || !prefix.startsWith("/")) throw new Error("无法确定 npm 全局安装目录。");
101
+ try { await access(prefix, constants.W_OK); }
102
+ catch { throw new Error(`npm 全局目录不可写:${prefix}。请先配置当前用户的 npm prefix 或 Node.js 版本管理器。`); }
103
+ await this.processes.run(this.npm, ["install", "--global", "--ignore-scripts", "--engine-strict",
104
+ `${piPackageName}@${version}`], this.cwd, { interactive: true });
105
+ const installed = await this.inspectPi(npmGlobalBinary(prefix));
106
+ if (!installed || installed.version !== version) throw new Error("Pi 安装版本验证失败。");
107
+ const onPath = await findExecutable("pi", this.processes.env);
108
+ if (!onPath || await realpath(onPath) !== await realpath(installed.command)) {
109
+ throw new Error(`Pi 已安装到 ${installed.command},请把 ${join(prefix, "bin")} 加入 PATH 后重新运行 setup。`);
110
+ }
111
+ return installed;
112
+ }
113
+
114
+ async verifyKeptPackage(pi: PiInstallation, workspace: string, choice: PackageChoice): Promise<string> {
115
+ const old = choice.existing[0];
116
+ if (choice.existing.length !== 1 || !old) throw new Error("保留来源不可用或存在多份来源,请先通过 pi config 处理。");
117
+ const current = (await this.inspectPackages(pi, workspace)).find(pkg => pkg.source === old.source && pkg.scope === old.scope);
118
+ if (!current?.installedPath || current.name !== npmName(choice.plugin.source) || JSON.stringify(current.entry) !== JSON.stringify(old.entry)) {
119
+ throw new Error("保留来源已变化或不可读取,请重新运行 setup。");
120
+ }
121
+ return current.source;
122
+ }
123
+
124
+ async installPackage(pi: PiInstallation, workspace: string, choice: PackageChoice): Promise<string> {
125
+ const name = npmName(choice.plugin.source);
126
+ if (!name || !choice.target) throw new Error("缺少目标包或目标版本。");
127
+ // Re-read just before mutation so a stale plan cannot replace a changed source.
128
+ const current = (await this.inspectPackages(pi, workspace)).filter(pkg => pkg.name === name);
129
+ for (const old of choice.existing) {
130
+ const actual = current.find(pkg => pkg.scope === old.scope && pkg.source === old.source);
131
+ if (!actual || JSON.stringify(actual.entry) !== JSON.stringify(old.entry)) throw new Error(`${name} 配置已改变,请重新运行 setup。`);
132
+ }
133
+ if (current.some(pkg => !choice.existing.some(old => old.scope === pkg.scope && old.source === pkg.source) &&
134
+ !(pkg.scope === "user" && npmName(pkg.source) && choice.existing.some(old => old.scope === "project" && npmName(old.source))))) {
135
+ throw new Error(`${name} 出现新的来源,请重新运行 setup。`);
136
+ }
137
+ const otherSources = choice.existing.filter(pkg => !npmName(pkg.source));
138
+ if (otherSources.length > 1 || otherSources.some(pkg => pkg.scope === "user")) {
139
+ throw new Error("存在不同身份的全局来源或多份自定义来源。请先用 pi config 调整加载范围后重试。");
140
+ }
141
+ const globals = choice.existing.filter(pkg => pkg.scope === "user" && npmName(pkg.source));
142
+ if (globals.length > 1 && globals.some(pkg => pkg.filtered)) throw new Error("存在多份全局 npm 资源过滤,请先用 pi config 调整后重试。");
143
+ const template = otherSources[0] ?? globals.find(pkg => pkg.filtered);
144
+ const target = `npm:${name}@latest`;
145
+ await this.processes.run(pi.command, ["install", "--local", target], workspace, { interactive: true });
146
+ const records = await this.inspectPackages(pi, workspace);
147
+ const installed = records.find(pkg => pkg.scope === "project" && pkg.source === target);
148
+ if (!installed?.installedPath || installed.version !== choice.target) {
149
+ throw new Error(`${name} 实际版本 ${installed?.version ?? "未知"} 与目标 ${choice.target} 不同,请检查 registry 后重试。`);
150
+ }
151
+ if (template) {
152
+ await this.processes.run(process.execPath, [bridge, "replace", pi.root, workspace,
153
+ JSON.stringify({ old: template, target })], this.cwd);
154
+ }
155
+ return installed.version;
156
+ }
157
+
158
+ async start(pi: PiInstallation, workspace: string): Promise<void> {
159
+ await access(guard, constants.R_OK);
160
+ await this.processes.run(pi.command, ["--extension", guard, "/pbot init"], resolve(workspace), { interactive: true, timeoutMs: 0 });
161
+ }
162
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, realpath, stat } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
5
+ import type { SystemCommand } from "../../services/installation/setup-service.js";
6
+
7
+ export async function findExecutable(name: string, env: NodeJS.ProcessEnv = process.env): Promise<string | undefined> {
8
+ const directories = (env.PATH ?? "").split(delimiter).filter(Boolean);
9
+ for (const directory of directories) {
10
+ const path = resolve(directory, name);
11
+ // npm exec prepends transient bins. Never use a cached peer as the user's Pi.
12
+ if (name === "pi" && path.split(/[\\/]/).includes("_npx")) continue;
13
+ try {
14
+ await access(path, constants.X_OK);
15
+ if (!(await stat(path)).isFile()) continue;
16
+ const target = await realpath(path);
17
+ if (name === "pi" && target.split(/[\\/]/).includes("_npx")) continue;
18
+ return path;
19
+ } catch (error) {
20
+ if (!["ENOENT", "ENOTDIR", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export class SetupProcesses {
27
+ constructor(readonly signal: AbortSignal, readonly env: NodeJS.ProcessEnv = process.env) {}
28
+
29
+ async run(command: string, args: string[], cwd: string,
30
+ options: { interactive?: boolean; timeoutMs?: number; input?: string } = {}): Promise<string> {
31
+ this.signal.throwIfAborted();
32
+ if (!isAbsolute(cwd)) throw new Error("进程工作目录必须是绝对路径。");
33
+ const interactive = options.interactive ?? false;
34
+ const timeoutMs = options.timeoutMs ?? (interactive ? 15 * 60_000 : 60_000);
35
+ return new Promise((resolveResult, reject) => {
36
+ const child = spawn(command, args, { cwd, env: this.env, shell: false, detached: !interactive,
37
+ stdio: interactive ? "inherit" : ["pipe", "pipe", "pipe"] });
38
+ const stdout: Buffer[] = [], stderr: Buffer[] = [];
39
+ let bytes = 0, failure: Error | undefined, escalation: NodeJS.Timeout | undefined;
40
+ const kill = (signal: NodeJS.Signals) => {
41
+ if (!child.pid) return;
42
+ try { if (interactive) child.kill(signal); else process.kill(-child.pid, signal); } catch { /* Process already exited. */ }
43
+ };
44
+ const stop = (reason: Error) => {
45
+ if (failure) return;
46
+ failure = reason;
47
+ kill("SIGTERM");
48
+ escalation = setTimeout(() => kill("SIGKILL"), 2000);
49
+ };
50
+ const cancel = () => stop(new Error("安装已取消。"));
51
+ const timer = timeoutMs > 0 ? setTimeout(() => stop(new Error(`命令超时:${command}`)), timeoutMs) : undefined;
52
+ this.signal.addEventListener("abort", cancel, { once: true });
53
+ const collect = (chunks: Buffer[]) => (chunk: Buffer) => {
54
+ bytes += chunk.length;
55
+ if (bytes > 2 * 1024 * 1024) stop(new Error(`命令输出超过限制:${command}`));
56
+ else chunks.push(chunk);
57
+ };
58
+ child.stdout?.on("data", collect(stdout)); child.stderr?.on("data", collect(stderr));
59
+ child.stdin?.on("error", () => { /* An exiting child may close stdin early. */ });
60
+ child.on("error", error => { failure = error; });
61
+ child.on("close", (code, exitSignal) => {
62
+ clearTimeout(timer); clearTimeout(escalation);
63
+ this.signal.removeEventListener("abort", cancel);
64
+ if (this.signal.aborted) return reject(this.signal.reason);
65
+ if (failure) return reject(failure);
66
+ if (code !== 0) return reject(new Error(`${command} 失败 (${exitSignal ?? code})\n${Buffer.concat(stderr).toString("utf8").slice(-8000)}`));
67
+ resolveResult(Buffer.concat(stdout).toString("utf8").trim());
68
+ });
69
+ child.stdin?.end(options.input);
70
+ if (this.signal.aborted) cancel();
71
+ });
72
+ }
73
+ }
74
+
75
+ export function tmuxInstallCommand(platform: string, commands: ReadonlyMap<string, string>, root: boolean): SystemCommand {
76
+ let command: string | undefined, args: string[];
77
+ if (platform === "darwin") {
78
+ command = commands.get("brew"); args = ["install", "tmux"];
79
+ } else {
80
+ const manager = ["apt-get", "dnf", "pacman", "zypper", "apk"].find(name => commands.has(name));
81
+ command = manager ? commands.get(manager) : undefined;
82
+ args = manager === "apt-get" ? ["install", "-y", "tmux"] : manager === "dnf" ? ["install", "-y", "tmux"]
83
+ : manager === "pacman" ? ["-S", "--needed", "--noconfirm", "tmux"]
84
+ : manager === "zypper" ? ["--non-interactive", "install", "tmux"] : ["add", "tmux"];
85
+ if (command && !root) {
86
+ const sudo = commands.get("sudo");
87
+ if (!sudo) throw new Error("安装 tmux 需要管理员权限;请手动安装 tmux 后重新运行 setup。");
88
+ return { command: sudo, args: [command, ...args] };
89
+ }
90
+ }
91
+ if (!command) throw new Error(platform === "darwin"
92
+ ? "未找到 Homebrew。请先通过现有环境安装 tmux,再运行 setup。"
93
+ : "未找到支持的系统包管理器。请手动安装 tmux,再运行 setup。");
94
+ return { command, args };
95
+ }
96
+
97
+ export async function inspectSystem(processes: SetupProcesses, cwd: string): Promise<{ npm: string; tmux?: SystemCommand }> {
98
+ if (!["linux", "darwin"].includes(process.platform)) throw new Error("setup 支持 Linux 和 macOS;Windows 请使用 WSL。");
99
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
100
+ if (major < 22 || major === 22 && minor < 19) throw new Error("需要 Node.js 22.19 或更高版本。");
101
+ const npm = await findExecutable("npm", processes.env);
102
+ if (!npm) throw new Error("未找到 npm。请先安装 Node.js/npm。");
103
+ const python = await findExecutable("python3", processes.env) ?? await findExecutable("python", processes.env);
104
+ if (!python) throw new Error("需要 Python 3.11+ 和 venv/ensurepip。请先安装后重试。");
105
+ try {
106
+ await processes.run(python, ["-I", "-B", "-c", [
107
+ "import sys, tempfile, venv, ensurepip",
108
+ "assert sys.version_info >= (3, 11), 'Python 3.11+ required'",
109
+ "with tempfile.TemporaryDirectory(prefix='pbot-python-check-') as path:",
110
+ " venv.EnvBuilder(with_pip=True).create(path)",
111
+ ].join("\n")], cwd);
112
+ } catch (error) {
113
+ processes.signal.throwIfAborted();
114
+ throw new Error("基础 Python 检查失败。需要 Python 3.11+ 和 venv/ensurepip;Debian/Ubuntu 还需 python3-venv。\n" + String(error));
115
+ }
116
+ const tmux = await findExecutable("tmux", processes.env);
117
+ if (tmux) { await processes.run(tmux, ["-V"], cwd); return { npm }; }
118
+ const names = process.platform === "darwin" ? ["brew"] : ["apt-get", "dnf", "pacman", "zypper", "apk", "sudo"];
119
+ const entries = await Promise.all(names.map(async name => [name, await findExecutable(name, processes.env)] as const));
120
+ const commands = new Map(entries.filter((entry): entry is readonly [string, string] => entry[1] !== undefined));
121
+ return { npm, tmux: tmuxInstallCommand(process.platform, commands, process.getuid?.() === 0) };
122
+ }
123
+
124
+ export function npmGlobalBinary(prefix: string): string { return join(prefix, "bin", "pi"); }
@@ -0,0 +1,18 @@
1
+ import { stat, realpath } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+
4
+ export async function resolveWorkspace(value: string, cwd: string, home: string): Promise<string> {
5
+ const path = resolve(cwd, value === "~" ? home : value.startsWith("~/") ? join(home, value.slice(2)) : value);
6
+ let existing = path;
7
+ while (true) {
8
+ try {
9
+ if (!(await stat(existing)).isDirectory()) throw new Error("工作区或其父路径不是目录:" + existing);
10
+ return resolve(await realpath(existing), relative(existing, path));
11
+ } catch (error) {
12
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
13
+ const parent = dirname(existing);
14
+ if (parent === existing) throw error;
15
+ existing = parent;
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,145 @@
1
+ import { npmName, ordinaryNpm } from "../../common/package-source.js";
2
+
3
+ export interface PluginSpec {
4
+ id: string;
5
+ label: string;
6
+ description: string;
7
+ source: string;
8
+ defaultSelected: boolean;
9
+ }
10
+ export interface InstalledPackage {
11
+ source: string;
12
+ scope: "user" | "project";
13
+ name?: string;
14
+ version?: string;
15
+ installedPath?: string;
16
+ filtered: boolean;
17
+ entry: unknown;
18
+ }
19
+ export interface PackageChoice {
20
+ plugin: PluginSpec;
21
+ required: boolean;
22
+ existing: InstalledPackage[];
23
+ selected: boolean;
24
+ keep: boolean;
25
+ target?: string;
26
+ error?: string;
27
+ }
28
+ export interface PiInstallation { command: string; root: string; version: string }
29
+ export interface SystemCommand { command: string; args: string[] }
30
+ export interface SetupPlan {
31
+ workspace: string;
32
+ pi?: PiInstallation;
33
+ piVersion: string;
34
+ tmux?: SystemCommand;
35
+ packages: PackageChoice[];
36
+ }
37
+ export interface SetupResult { name: string; state: "done" | "kept" | "skipped" | "failed"; detail: string }
38
+ export interface SetupOperations {
39
+ installTmux(command: SystemCommand): Promise<void>;
40
+ ensurePi(existing: PiInstallation | undefined, version: string): Promise<PiInstallation>;
41
+ installPackage(pi: PiInstallation, workspace: string, choice: PackageChoice): Promise<string>;
42
+ verifyKeptPackage(pi: PiInstallation, workspace: string, choice: PackageChoice): Promise<string>;
43
+ }
44
+
45
+ export const pbotPackage: PluginSpec = {
46
+ id: "pbot", label: "Personal Bot", description: "工作区个人助手", source: "npm:personal-bot", defaultSelected: true,
47
+ };
48
+ export function parseCatalog(value: unknown): PluginSpec[] {
49
+ if (!Array.isArray(value)) throw new Error("插件清单必须是数组。");
50
+ const ids = new Set(["pbot"]), names = new Set(["personal-bot"]);
51
+ return value.map((item: unknown) => {
52
+ if (!item || typeof item !== "object") throw new Error("无效插件清单项。");
53
+ const entry = item as Record<string, unknown>;
54
+ for (const field of ["id", "label", "description", "source"]) {
55
+ if (typeof entry[field] !== "string" || !entry[field].trim() || /[\x00-\x1f\x7f]/.test(entry[field])) {
56
+ throw new Error(`无效插件字段:${field}`);
57
+ }
58
+ }
59
+ const plugin = entry as unknown as PluginSpec;
60
+ const name = npmName(plugin.source);
61
+ if (!/^[a-z0-9-]+$/.test(plugin.id) || !name || !ordinaryNpm(plugin.source) ||
62
+ typeof plugin.defaultSelected !== "boolean" || ids.has(plugin.id) || names.has(name)) {
63
+ throw new Error(`无效或重复的插件:${plugin.id}`);
64
+ }
65
+ ids.add(plugin.id); names.add(name);
66
+ return { id: plugin.id, label: plugin.label, description: plugin.description,
67
+ source: `npm:${name}`, defaultSelected: plugin.defaultSelected };
68
+ });
69
+ }
70
+ export function packageChoices(catalog: PluginSpec[], installed: InstalledPackage[]): PackageChoice[] {
71
+ const first = !installed.some(pkg => pkg.name === "personal-bot");
72
+ return [pbotPackage, ...catalog].map(plugin => {
73
+ const matches = installed.filter(pkg => pkg.name === npmName(plugin.source));
74
+ // A project npm entry overrides the same npm identity in user settings. Different
75
+ // git/local identities still need an explicit decision before changing sources.
76
+ const localNpm = matches.some(pkg => pkg.scope === "project" && npmName(pkg.source));
77
+ const existing = matches.filter(pkg => !(localNpm && pkg.scope === "user" && npmName(pkg.source)));
78
+ const custom = existing.some(pkg => !ordinaryNpm(pkg.source));
79
+ return { plugin, required: plugin.id === "pbot", existing,
80
+ selected: plugin.id === "pbot" || (first ? plugin.defaultSelected : existing.length > 0), keep: custom };
81
+ });
82
+ }
83
+ export function parseSetupArgs(args: string[]): { action: "help" | "version" | "setup"; workspace?: string } {
84
+ if (!args.length || args.length === 1 && ["--help", "-h"].includes(args[0]!)) return { action: "help" };
85
+ if (args.length === 1 && ["--version", "-v"].includes(args[0]!)) return { action: "version" };
86
+ if (args[0] !== "setup") throw new Error("用法:pbot setup [--workspace <目录>]");
87
+ let workspace: string | undefined;
88
+ for (let index = 1; index < args.length; index++) {
89
+ const arg = args[index]!;
90
+ if ((arg === "--help" || arg === "-h") && args.length === 2) return { action: "help" };
91
+ if (arg !== "--workspace" || workspace !== undefined) throw new Error(`不支持的参数:${arg}`);
92
+ const value = args[++index];
93
+ if (!value || value.startsWith("--") || /[\x00-\x1f\x7f]/.test(value)) throw new Error("--workspace 需要目录。");
94
+ workspace = value;
95
+ }
96
+ return workspace === undefined ? { action: "setup" } : { action: "setup", workspace };
97
+ }
98
+
99
+ export async function executeSetup(plan: SetupPlan, operations: SetupOperations,
100
+ report: (result: SetupResult) => void, signal: AbortSignal): Promise<{ pi?: PiInstallation; results: SetupResult[] }> {
101
+ const results: SetupResult[] = [];
102
+ const add = (result: SetupResult) => { results.push(result); report(result); };
103
+ let pi: PiInstallation;
104
+ try {
105
+ signal.throwIfAborted();
106
+ if (plan.tmux) { await operations.installTmux(plan.tmux); add({ name: "tmux", state: "done", detail: "已安装" }); }
107
+ signal.throwIfAborted();
108
+ pi = await operations.ensurePi(plan.pi, plan.piVersion);
109
+ add({ name: "Pi", state: "done", detail: pi.version });
110
+ } catch (error) {
111
+ signal.throwIfAborted();
112
+ add({ name: "运行环境", state: "failed", detail: String(error) });
113
+ return { results };
114
+ }
115
+ for (const choice of plan.packages) {
116
+ signal.throwIfAborted();
117
+ const name = choice.plugin.label;
118
+ if (!choice.selected) { add({ name, state: "skipped", detail: "本次不处理" }); continue; }
119
+ if (choice.error) {
120
+ add({ name, state: "failed", detail: choice.error });
121
+ if (choice.required) return { results };
122
+ continue;
123
+ }
124
+ if (choice.keep) {
125
+ try {
126
+ const source = await operations.verifyKeptPackage(pi, plan.workspace, choice);
127
+ add({ name, state: "kept", detail: source });
128
+ } catch (error) {
129
+ signal.throwIfAborted();
130
+ add({ name, state: "failed", detail: String(error) });
131
+ if (choice.required) return { results };
132
+ }
133
+ continue;
134
+ }
135
+ try {
136
+ const version = await operations.installPackage(pi, plan.workspace, choice);
137
+ add({ name, state: "done", detail: version });
138
+ } catch (error) {
139
+ signal.throwIfAborted();
140
+ add({ name, state: "failed", detail: String(error) });
141
+ if (choice.required) return { results };
142
+ }
143
+ }
144
+ return { pi, results };
145
+ }
@@ -0,0 +1,40 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export class SetupUI {
4
+ constructor(private readonly controller: AbortController) {}
5
+ text(value: string) { console.log(value); }
6
+ async ask(label: string, fallback = ""): Promise<string> {
7
+ this.controller.signal.throwIfAborted();
8
+ const reader = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
9
+ reader.on("SIGINT", () => this.controller.abort(new Error("安装已取消。")));
10
+ reader.on("close", () => { if (!answered) this.controller.abort(new Error("输入已关闭。")); });
11
+ let answered = false;
12
+ try {
13
+ const value = await reader.question(label + (fallback ? " [" + fallback + "]" : "") + ": ", { signal: this.controller.signal });
14
+ answered = true;
15
+ return value.trim() || fallback;
16
+ } finally { answered = true; reader.close(); }
17
+ }
18
+ async confirm(label: string, fallback = true): Promise<boolean> {
19
+ while (true) {
20
+ const answer = (await this.ask(label + (fallback ? " [Y/n]" : " [y/N]"))).toLowerCase();
21
+ if (!answer) return fallback;
22
+ if (["y", "yes", "是"].includes(answer)) return true;
23
+ if (["n", "no", "否"].includes(answer)) return false;
24
+ this.text("请输入 y 或 n。");
25
+ }
26
+ }
27
+ async selectMany(labels: string[], defaults: number[]): Promise<number[]> {
28
+ labels.forEach((label, index) => this.text(" " + (defaults.includes(index) ? "[x] " : "[ ] ") + (index + 1) + ". " + label));
29
+ while (true) {
30
+ const answer = await this.ask("选择插件编号(逗号分隔,回车保留默认,0 不选)");
31
+ if (!answer) return defaults;
32
+ if (answer === "0") return [];
33
+ const parts = answer.split(/[,,\s]+/);
34
+ if (parts.every(part => /^\d+$/.test(part) && Number(part) >= 1 && Number(part) <= labels.length)) {
35
+ return [...new Set(parts.map(part => Number(part) - 1))];
36
+ }
37
+ this.text("请输入列表中的编号。");
38
+ }
39
+ }
40
+ }