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,136 @@
1
+ import { mkdir, readFile, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ import { executeSetup, packageChoices, parseCatalog, parseSetupArgs } from "../services/installation/setup-service.js";
5
+ import { npmName, ordinaryNpm } from "../common/package-source.js";
6
+ import { PiBootstrapRequired, PiSetup, piPackageName } from "../integrations/installation/pi-setup.js";
7
+ import { inspectSystem, SetupProcesses } from "../integrations/installation/processes.js";
8
+ import { resolveWorkspace } from "../integrations/installation/workspace.js";
9
+ import { SetupUI } from "../ui/setup.js";
10
+ const root = new URL("../../", import.meta.url);
11
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
12
+ export async function main(args) {
13
+ let options;
14
+ try {
15
+ options = parseSetupArgs(args);
16
+ }
17
+ catch (error) {
18
+ console.error(String(error));
19
+ return 2;
20
+ }
21
+ const manifest = JSON.parse(await readFile(new URL("package.json", root), "utf8"));
22
+ if (options.action === "version") {
23
+ console.log(manifest.version);
24
+ return 0;
25
+ }
26
+ if (options.action === "help") {
27
+ console.log("Personal Bot " + manifest.version + "\n\n用法:pbot setup [--workspace <目录>]\n" +
28
+ " npx personal-bot@latest setup\n\nsetup 安装或升级 Pi、pbot 和选中的插件,然后启动 Pi 执行 /pbot init。\n" +
29
+ "支持 Linux/macOS,需要 Node.js 22.19+、npm 和 Python 3.11+。\n--help 显示帮助;--version 显示安装器版本。");
30
+ return 0;
31
+ }
32
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
33
+ console.error("setup 需要交互终端。请在终端运行 npx personal-bot@latest setup。");
34
+ return 2;
35
+ }
36
+ const controller = new AbortController();
37
+ const cancel = () => controller.abort(new Error("安装已取消;已完成的步骤保留。"));
38
+ process.on("SIGINT", cancel);
39
+ process.on("SIGTERM", cancel);
40
+ const ui = new SetupUI(controller), processes = new SetupProcesses(controller.signal);
41
+ try {
42
+ const cwd = process.cwd();
43
+ ui.text("Personal Bot " + manifest.version + " — 安装与升级\n正在检查 Node/npm、Python 和 tmux…");
44
+ const system = await inspectSystem(processes, cwd);
45
+ const supplied = options.workspace ?? await ui.ask("工作区目录(当前、已有或新建目录)", cwd);
46
+ const workspace = await resolveWorkspace(supplied, cwd, homedir());
47
+ const host = new PiSetup(processes, system.npm, cwd);
48
+ let pi = await host.inspectPi();
49
+ let installed;
50
+ try {
51
+ installed = await host.inspectPackages(pi, workspace);
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof PiBootstrapRequired))
55
+ throw error;
56
+ const version = await host.latest(piPackageName);
57
+ ui.text(error.message + "\n需先安装 Pi 才能使用其原生接口核对这些来源。目标版本:" + version);
58
+ if (!await ui.confirm("先通过 npm 安装 Pi,再继续选择工作区插件"))
59
+ return 130;
60
+ pi = await host.ensurePi(undefined, version);
61
+ installed = await host.inspectPackages(pi, workspace);
62
+ }
63
+ const catalog = parseCatalog(JSON.parse(await readFile(new URL("resources/installer/plugins.json", root), "utf8")));
64
+ const choices = packageChoices(catalog, installed), optional = choices.filter(choice => !choice.required);
65
+ ui.text("\nPersonal Bot 必装。选择配套插件(取消勾选不会卸载已有插件):");
66
+ const selected = await ui.selectMany(optional.map(choice => choice.plugin.label + " — " + choice.plugin.description), optional.flatMap((choice, index) => choice.selected ? [index] : []));
67
+ optional.forEach((choice, index) => { choice.selected = selected.includes(index); });
68
+ for (const choice of choices.filter(item => item.selected)) {
69
+ if (choice.existing.some(pkg => !ordinaryNpm(pkg.source))) {
70
+ ui.text("\n" + choice.plugin.label + " 的已有来源:\n" + choice.existing.map(pkg => " " + pkg.scope + ": " + pkg.source + " (" + (pkg.version ?? "版本未知") + ")").join("\n"));
71
+ choice.keep = !await ui.confirm("切换到 npm 最新稳定版", false);
72
+ }
73
+ if (choice.keep) {
74
+ if (choice.existing.length !== 1 || !choice.existing[0]?.installedPath)
75
+ choice.error = "保留来源未安装或存在多份来源,请先通过 Pi 原生配置处理。";
76
+ }
77
+ else {
78
+ const others = choice.existing.filter(pkg => !npmName(pkg.source));
79
+ if (others.length > 1 || others.some(pkg => pkg.scope === "user")) {
80
+ choice.error = "请先用 pi config 调整全局或重复来源的加载范围,再切换到 npm。";
81
+ }
82
+ else {
83
+ try {
84
+ choice.target = await host.latest(npmName(choice.plugin.source));
85
+ }
86
+ catch (error) {
87
+ controller.signal.throwIfAborted();
88
+ choice.error = String(error);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ const plan = { workspace, piVersion: await host.latest(piPackageName), packages: choices,
94
+ ...(pi ? { pi } : {}), ...(system.tmux ? { tmux: system.tmux } : {}) };
95
+ ui.text("\n安装计划\n工作区:" + workspace + "\nPi:" + (pi ? pi.command + " (" + pi.version + ")" : "通过 npm 全局安装") + " → " + plan.piVersion);
96
+ if (system.tmux)
97
+ ui.text("tmux:" + [system.tmux.command, ...system.tmux.args].map(quote).join(" "));
98
+ for (const choice of choices) {
99
+ ui.text(" " + choice.plugin.label + ": " + (!choice.selected ? "本次跳过" : choice.error ??
100
+ (choice.keep ? "保留 " + choice.existing[0]?.source : (choice.existing.map(pkg => pkg.version ?? "未知").join(", ") || "未安装") + " → " + choice.target)));
101
+ if (choice.selected && choice.existing.some(pkg => pkg.filtered))
102
+ ui.text(" 保留已有资源过滤;如 pbot 被禁用,请通过 pi config --local 启用。");
103
+ }
104
+ const blocking = choices.find(choice => choice.required && choice.error);
105
+ if (blocking)
106
+ throw new Error(blocking.error);
107
+ ui.text("升级前请停止活动 pbot 子会话和相关 Python 脚本。Pi 为当前用户共用,插件只处理此工作区。");
108
+ if (!await ui.confirm("执行以上计划"))
109
+ return 130;
110
+ await mkdir(workspace, { recursive: true });
111
+ if (await realpath(workspace) !== workspace)
112
+ throw new Error("工作区路径已发生变化,请重新运行 setup。");
113
+ const result = await executeSetup(plan, host, item => ui.text("[" + item.state + "] " + item.name + ": " + item.detail), controller.signal);
114
+ const failed = result.results.some(item => item.state === "failed");
115
+ if (!result.pi)
116
+ return 1;
117
+ ui.text("\n软件安装阶段结束。初始化、Python 环境和外部服务的状态由 Pi/pbot/插件分别报告。");
118
+ if (await ui.confirm(failed ? "部分插件失败,是否仍先启动 Pi" : "立即启动 Pi", !failed)) {
119
+ process.stdin.pause();
120
+ await host.start(result.pi, workspace);
121
+ }
122
+ else {
123
+ ui.text("稍后执行:\ncd " + quote(workspace) + "\n" + quote(result.pi.command) + " --extension " +
124
+ quote(fileURLToPath(new URL("extension/setup-guard.js", new URL("../", import.meta.url)))) + " '/pbot init'");
125
+ }
126
+ return failed ? 1 : 0;
127
+ }
128
+ catch (error) {
129
+ console.error(error instanceof Error ? error.message : String(error));
130
+ return controller.signal.aborted ? 130 : 1;
131
+ }
132
+ finally {
133
+ process.off("SIGINT", cancel);
134
+ process.off("SIGTERM", cancel);
135
+ }
136
+ }
@@ -0,0 +1,11 @@
1
+ // Native extension commands run before input handlers. Reaching this handler for
2
+ // /pbot init means it was not dispatched; never let the setup input reach a model.
3
+ export default function setupGuard(pi) {
4
+ pi.on("input", (event, ctx) => {
5
+ if (event.text.trim() !== "/pbot init")
6
+ return { action: "continue" };
7
+ ctx.ui.notify("pbot 未加载或命令存在冲突。请检查项目信任和 pi config --local 后重新运行 setup。", "error");
8
+ ctx.shutdown();
9
+ return { action: "handled" };
10
+ });
11
+ }
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { npmName } from "../../common/package-source.js";
6
+ async function sdkAt(root) {
7
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
8
+ if (manifest.name !== "@earendil-works/pi-coding-agent")
9
+ throw new Error("不是支持的 Pi 安装。");
10
+ return import(pathToFileURL(join(root, "dist/index.js")).href);
11
+ }
12
+ const sourceOf = (entry) => {
13
+ if (typeof entry === "string")
14
+ return entry;
15
+ if (entry && typeof entry === "object" && "source" in entry && typeof entry.source === "string")
16
+ return entry.source;
17
+ throw new Error("无效的 Pi packages 声明。");
18
+ };
19
+ export async function inspectPackages(root, workspace) {
20
+ const sdk = await sdkAt(root), agentDir = sdk.getAgentDir();
21
+ // Inspect project declarations as data only. This does not load extensions, execute
22
+ // project npmCommand, grant project trust, or persist migrated settings.
23
+ const settings = sdk.SettingsManager.fromStorage({ withLock(scope, read) {
24
+ const file = scope === "global" ? join(agentDir, "settings.json") : join(workspace, sdk.CONFIG_DIR_NAME, "settings.json");
25
+ let content;
26
+ try {
27
+ content = readFileSync(file, "utf8");
28
+ }
29
+ catch (error) {
30
+ if (error.code !== "ENOENT")
31
+ throw error;
32
+ }
33
+ if (content !== undefined) {
34
+ const value = JSON.parse(content);
35
+ if (!value || typeof value !== "object" || Array.isArray(value))
36
+ throw new Error(`无效的 Pi 配置:${file}`);
37
+ const packages = value.packages;
38
+ if (packages !== undefined) {
39
+ if (!Array.isArray(packages))
40
+ throw new Error(`packages 必须是数组:${file}`);
41
+ packages.forEach(sourceOf);
42
+ }
43
+ }
44
+ if (read(content) !== undefined)
45
+ throw new Error("安装检查不得修改 Pi 配置。");
46
+ } }, { projectTrusted: true });
47
+ const errors = settings.drainErrors();
48
+ if (errors.length)
49
+ throw new Error(errors.map(item => `${item.scope}: ${item.error.message}`).join("\n"));
50
+ const manager = new sdk.DefaultPackageManager({ cwd: workspace, agentDir, settingsManager: settings });
51
+ const result = [];
52
+ for (const pkg of manager.listConfiguredPackages()) {
53
+ const entries = (pkg.scope === "project" ? settings.getProjectSettings() : settings.getGlobalSettings()).packages ?? [];
54
+ const entry = entries.find(item => sourceOf(item) === pkg.source);
55
+ let name = npmName(pkg.source), version;
56
+ if (pkg.installedPath) {
57
+ let file = pkg.installedPath;
58
+ if ((await stat(file)).isDirectory())
59
+ file = join(file, "package.json");
60
+ else
61
+ file = ""; // Single-file extensions have no npm identity.
62
+ if (file) {
63
+ try {
64
+ const manifest = JSON.parse(await readFile(file, "utf8"));
65
+ if (name && manifest.name !== name)
66
+ throw new Error(`安装包身份不匹配:${pkg.source}`);
67
+ name = typeof manifest.name === "string" ? manifest.name : name;
68
+ version = typeof manifest.version === "string" ? manifest.version : undefined;
69
+ }
70
+ catch (error) {
71
+ if (error.code !== "ENOENT")
72
+ throw error;
73
+ }
74
+ }
75
+ }
76
+ result.push({ source: pkg.source, scope: pkg.scope, filtered: pkg.filtered, entry,
77
+ ...(pkg.installedPath ? { installedPath: pkg.installedPath } : {}), ...(name ? { name } : {}), ...(version ? { version } : {}) });
78
+ }
79
+ return result;
80
+ }
81
+ export async function replaceProjectSource(root, workspace, old, target) {
82
+ const fromProject = old.scope === "project" && !npmName(old.source);
83
+ const fromUser = old.scope === "user" && !!npmName(old.source);
84
+ if (!fromProject && !fromUser)
85
+ throw new Error("此操作只替换工作区自定义来源,或继承全局 npm 资源过滤。");
86
+ const sdk = await sdkAt(root);
87
+ const settings = sdk.SettingsManager.create(workspace, sdk.getAgentDir(), { projectTrusted: true });
88
+ const errors = settings.drainErrors();
89
+ if (errors.length)
90
+ throw new Error(errors.map(item => item.error.message).join("\n"));
91
+ const packages = settings.getProjectSettings().packages ?? [];
92
+ const originals = fromProject ? packages : settings.getGlobalSettings().packages ?? [];
93
+ const previous = originals.find(entry => sourceOf(entry) === old.source);
94
+ if (previous === undefined || JSON.stringify(previous) !== JSON.stringify(old.entry))
95
+ throw new Error("插件配置已发生变化,请重新运行 setup。");
96
+ if (!packages.some(entry => sourceOf(entry) === target))
97
+ throw new Error("目标包尚未安装,保留原声明。");
98
+ const manager = new sdk.DefaultPackageManager({ cwd: workspace, agentDir: sdk.getAgentDir(), settingsManager: settings });
99
+ const installed = manager.getInstalledPath(target, "project");
100
+ if (!installed || !npmName(target))
101
+ throw new Error("目标 npm 包尚未安装,保留原声明。");
102
+ const manifest = JSON.parse(await readFile(join(installed, "package.json"), "utf8"));
103
+ if (manifest.name !== npmName(target))
104
+ throw new Error("目标安装包身份不符,保留原声明。");
105
+ // Change only declarations; do not call remove(), which deletes managed git caches.
106
+ const replacement = typeof previous === "string" ? target : { ...previous, source: target };
107
+ const next = fromProject
108
+ ? packages.filter(entry => sourceOf(entry) !== target).map(entry => sourceOf(entry) === old.source ? replacement : entry)
109
+ : packages.map(entry => sourceOf(entry) === target ? replacement : entry);
110
+ settings.setProjectPackages(next);
111
+ await settings.flush();
112
+ const failures = settings.drainErrors();
113
+ if (failures.length)
114
+ throw new Error(failures.map(item => item.error.message).join("\n"));
115
+ }
116
+ async function main() {
117
+ const [action, root, workspace, input] = process.argv.slice(2);
118
+ if (!root || !workspace)
119
+ throw new Error("缺少 Pi 路径或工作区。");
120
+ if (action === "inspect")
121
+ console.log(JSON.stringify(await inspectPackages(root, workspace)));
122
+ else if (action === "replace" && input) {
123
+ const { old, target } = JSON.parse(input);
124
+ await replaceProjectSource(root, workspace, old, target);
125
+ }
126
+ else
127
+ throw new Error("无效的安装检查操作。");
128
+ }
129
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
130
+ await main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
131
+ }
@@ -0,0 +1,193 @@
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 { findExecutable, npmGlobalBinary, SetupProcesses } from "./processes.js";
8
+ export const piPackageName = "@earendil-works/pi-coding-agent";
9
+ export class PiBootstrapRequired extends Error {
10
+ }
11
+ const bridge = fileURLToPath(new URL("./pi-bridge.js", import.meta.url));
12
+ const guard = fileURLToPath(new URL("../../extension/setup-guard.js", import.meta.url));
13
+ async function packageRoot(command, version, env) {
14
+ const resolved = await realpath(command);
15
+ let directory = dirname(resolved);
16
+ while (true) {
17
+ try {
18
+ const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
19
+ if (pkg.name === piPackageName && pkg.version === version)
20
+ return directory;
21
+ }
22
+ catch (error) {
23
+ if (error.code !== "ENOENT")
24
+ throw error;
25
+ }
26
+ const parent = dirname(directory);
27
+ if (parent === directory)
28
+ break;
29
+ directory = parent;
30
+ }
31
+ const roots = [join(dirname(dirname(resolved)), "install"), env.PI_MANAGED_INSTALL_ROOT].filter((root) => !!root);
32
+ for (const root of roots) {
33
+ try {
34
+ // The native managed launcher resides beside install/. Only accept a matching
35
+ // launcher, not an unrelated PI_MANAGED_INSTALL_ROOT inherited from another Pi.
36
+ const launcher = await realpath(join(dirname(root), "bin/pi"));
37
+ if (launcher !== resolved)
38
+ continue;
39
+ const current = (await readFile(join(root, "current-version"), "utf8")).trim();
40
+ if (current !== version || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(current))
41
+ continue;
42
+ const candidate = join(root, "releases", current, "node_modules", piPackageName);
43
+ const pkg = JSON.parse(await readFile(join(candidate, "package.json"), "utf8"));
44
+ if (pkg.name === piPackageName && pkg.version === version)
45
+ return candidate;
46
+ }
47
+ catch (error) {
48
+ if (error.code !== "ENOENT")
49
+ throw error;
50
+ }
51
+ }
52
+ throw new Error(`无法定位 ${command} 对应的 Pi 包。请先按此 Pi 的原生安装方式修复,不会覆盖该命令。`);
53
+ }
54
+ export class PiSetup {
55
+ processes;
56
+ npm;
57
+ cwd;
58
+ constructor(processes, npm, cwd) {
59
+ this.processes = processes;
60
+ this.npm = npm;
61
+ this.cwd = cwd;
62
+ }
63
+ async inspectPi(command) {
64
+ const executable = command ?? await findExecutable("pi", this.processes.env);
65
+ if (!executable)
66
+ return undefined;
67
+ const version = await this.processes.run(executable, ["--version"], this.cwd);
68
+ if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version))
69
+ throw new Error(`PATH 中的 ${executable} 不是可识别的 Pi。`);
70
+ return { command: executable, version, root: await packageRoot(executable, version, this.processes.env) };
71
+ }
72
+ async latest(name) {
73
+ const text = await this.processes.run(this.npm, ["view", `${name}@latest`, "version", "engines", "--json",
74
+ "--fetch-retries=0", "--fetch-timeout=15000"], this.cwd);
75
+ const value = JSON.parse(text);
76
+ if (typeof value.version !== "string")
77
+ throw new Error(`无法取得 ${name} 的最新版本。`);
78
+ return checkStableVersion(value.version, value.engines?.node);
79
+ }
80
+ async inspectPackages(pi, workspace) {
81
+ if (!pi) {
82
+ // A fresh machine can be planned without loading a Pi SDK. Existing declarations
83
+ // require Pi's own resolver; don't guess git cache paths or package identities.
84
+ const agentDir = this.processes.env.PI_CODING_AGENT_DIR ?? join(this.processes.env.HOME ?? homedir(), ".pi/agent");
85
+ for (const file of [join(agentDir, "settings.json"), join(workspace, ".pi/settings.json")]) {
86
+ try {
87
+ const settings = JSON.parse(await readFile(file, "utf8"));
88
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
89
+ throw new Error(`无效的 Pi 配置:${file}`);
90
+ const packages = settings.packages;
91
+ if (packages !== undefined && !Array.isArray(packages))
92
+ throw new Error(`packages 必须是数组:${file}`);
93
+ if (Array.isArray(packages) && packages.length > 0) {
94
+ throw new PiBootstrapRequired(`已有插件配置但未找到 Pi:${file}`);
95
+ }
96
+ }
97
+ catch (error) {
98
+ if (error.code !== "ENOENT")
99
+ throw error;
100
+ }
101
+ }
102
+ return [];
103
+ }
104
+ const data = await this.processes.run(process.execPath, [bridge, "inspect", pi.root, workspace], this.cwd);
105
+ return JSON.parse(data);
106
+ }
107
+ async installTmux(command) {
108
+ await this.processes.run(command.command, command.args, this.cwd, { interactive: true });
109
+ const tmux = await findExecutable("tmux", this.processes.env);
110
+ if (!tmux)
111
+ throw new Error("tmux 安装后仍不在 PATH 中,请修复 PATH 后重试。");
112
+ await this.processes.run(tmux, ["-V"], this.cwd);
113
+ }
114
+ async ensurePi(existing, version) {
115
+ if (existing) {
116
+ if (existing.version !== version)
117
+ await this.processes.run(existing.command, ["update"], this.cwd, { interactive: true });
118
+ const installed = await this.inspectPi(existing.command);
119
+ if (!installed || installed.version !== version)
120
+ throw new Error(`Pi 实际版本 ${installed?.version ?? "未知"} 与目标 ${version} 不同。请用原安装方式更新后重试。`);
121
+ return installed;
122
+ }
123
+ const prefix = await this.processes.run(this.npm, ["prefix", "--global"], this.cwd);
124
+ if (!prefix || !prefix.startsWith("/"))
125
+ throw new Error("无法确定 npm 全局安装目录。");
126
+ try {
127
+ await access(prefix, constants.W_OK);
128
+ }
129
+ catch {
130
+ throw new Error(`npm 全局目录不可写:${prefix}。请先配置当前用户的 npm prefix 或 Node.js 版本管理器。`);
131
+ }
132
+ await this.processes.run(this.npm, ["install", "--global", "--ignore-scripts", "--engine-strict",
133
+ `${piPackageName}@${version}`], this.cwd, { interactive: true });
134
+ const installed = await this.inspectPi(npmGlobalBinary(prefix));
135
+ if (!installed || installed.version !== version)
136
+ throw new Error("Pi 安装版本验证失败。");
137
+ const onPath = await findExecutable("pi", this.processes.env);
138
+ if (!onPath || await realpath(onPath) !== await realpath(installed.command)) {
139
+ throw new Error(`Pi 已安装到 ${installed.command},请把 ${join(prefix, "bin")} 加入 PATH 后重新运行 setup。`);
140
+ }
141
+ return installed;
142
+ }
143
+ async verifyKeptPackage(pi, workspace, choice) {
144
+ const old = choice.existing[0];
145
+ if (choice.existing.length !== 1 || !old)
146
+ throw new Error("保留来源不可用或存在多份来源,请先通过 pi config 处理。");
147
+ const current = (await this.inspectPackages(pi, workspace)).find(pkg => pkg.source === old.source && pkg.scope === old.scope);
148
+ if (!current?.installedPath || current.name !== npmName(choice.plugin.source) || JSON.stringify(current.entry) !== JSON.stringify(old.entry)) {
149
+ throw new Error("保留来源已变化或不可读取,请重新运行 setup。");
150
+ }
151
+ return current.source;
152
+ }
153
+ async installPackage(pi, workspace, choice) {
154
+ const name = npmName(choice.plugin.source);
155
+ if (!name || !choice.target)
156
+ throw new Error("缺少目标包或目标版本。");
157
+ // Re-read just before mutation so a stale plan cannot replace a changed source.
158
+ const current = (await this.inspectPackages(pi, workspace)).filter(pkg => pkg.name === name);
159
+ for (const old of choice.existing) {
160
+ const actual = current.find(pkg => pkg.scope === old.scope && pkg.source === old.source);
161
+ if (!actual || JSON.stringify(actual.entry) !== JSON.stringify(old.entry))
162
+ throw new Error(`${name} 配置已改变,请重新运行 setup。`);
163
+ }
164
+ if (current.some(pkg => !choice.existing.some(old => old.scope === pkg.scope && old.source === pkg.source) &&
165
+ !(pkg.scope === "user" && npmName(pkg.source) && choice.existing.some(old => old.scope === "project" && npmName(old.source))))) {
166
+ throw new Error(`${name} 出现新的来源,请重新运行 setup。`);
167
+ }
168
+ const otherSources = choice.existing.filter(pkg => !npmName(pkg.source));
169
+ if (otherSources.length > 1 || otherSources.some(pkg => pkg.scope === "user")) {
170
+ throw new Error("存在不同身份的全局来源或多份自定义来源。请先用 pi config 调整加载范围后重试。");
171
+ }
172
+ const globals = choice.existing.filter(pkg => pkg.scope === "user" && npmName(pkg.source));
173
+ if (globals.length > 1 && globals.some(pkg => pkg.filtered))
174
+ throw new Error("存在多份全局 npm 资源过滤,请先用 pi config 调整后重试。");
175
+ const template = otherSources[0] ?? globals.find(pkg => pkg.filtered);
176
+ const target = `npm:${name}@latest`;
177
+ await this.processes.run(pi.command, ["install", "--local", target], workspace, { interactive: true });
178
+ const records = await this.inspectPackages(pi, workspace);
179
+ const installed = records.find(pkg => pkg.scope === "project" && pkg.source === target);
180
+ if (!installed?.installedPath || installed.version !== choice.target) {
181
+ throw new Error(`${name} 实际版本 ${installed?.version ?? "未知"} 与目标 ${choice.target} 不同,请检查 registry 后重试。`);
182
+ }
183
+ if (template) {
184
+ await this.processes.run(process.execPath, [bridge, "replace", pi.root, workspace,
185
+ JSON.stringify({ old: template, target })], this.cwd);
186
+ }
187
+ return installed.version;
188
+ }
189
+ async start(pi, workspace) {
190
+ await access(guard, constants.R_OK);
191
+ await this.processes.run(pi.command, ["--extension", guard, "/pbot init"], resolve(workspace), { interactive: true, timeoutMs: 0 });
192
+ }
193
+ }
@@ -0,0 +1,155 @@
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
+ export async function findExecutable(name, env = process.env) {
6
+ const directories = (env.PATH ?? "").split(delimiter).filter(Boolean);
7
+ for (const directory of directories) {
8
+ const path = resolve(directory, name);
9
+ // npm exec prepends transient bins. Never use a cached peer as the user's Pi.
10
+ if (name === "pi" && path.split(/[\\/]/).includes("_npx"))
11
+ continue;
12
+ try {
13
+ await access(path, constants.X_OK);
14
+ if (!(await stat(path)).isFile())
15
+ continue;
16
+ const target = await realpath(path);
17
+ if (name === "pi" && target.split(/[\\/]/).includes("_npx"))
18
+ continue;
19
+ return path;
20
+ }
21
+ catch (error) {
22
+ if (!["ENOENT", "ENOTDIR", "EACCES"].includes(error.code ?? ""))
23
+ throw error;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+ export class SetupProcesses {
29
+ signal;
30
+ env;
31
+ constructor(signal, env = process.env) {
32
+ this.signal = signal;
33
+ this.env = env;
34
+ }
35
+ async run(command, args, cwd, options = {}) {
36
+ this.signal.throwIfAborted();
37
+ if (!isAbsolute(cwd))
38
+ throw new Error("进程工作目录必须是绝对路径。");
39
+ const interactive = options.interactive ?? false;
40
+ const timeoutMs = options.timeoutMs ?? (interactive ? 15 * 60_000 : 60_000);
41
+ return new Promise((resolveResult, reject) => {
42
+ const child = spawn(command, args, { cwd, env: this.env, shell: false, detached: !interactive,
43
+ stdio: interactive ? "inherit" : ["pipe", "pipe", "pipe"] });
44
+ const stdout = [], stderr = [];
45
+ let bytes = 0, failure, escalation;
46
+ const kill = (signal) => {
47
+ if (!child.pid)
48
+ return;
49
+ try {
50
+ if (interactive)
51
+ child.kill(signal);
52
+ else
53
+ process.kill(-child.pid, signal);
54
+ }
55
+ catch { /* Process already exited. */ }
56
+ };
57
+ const stop = (reason) => {
58
+ if (failure)
59
+ return;
60
+ failure = reason;
61
+ kill("SIGTERM");
62
+ escalation = setTimeout(() => kill("SIGKILL"), 2000);
63
+ };
64
+ const cancel = () => stop(new Error("安装已取消。"));
65
+ const timer = timeoutMs > 0 ? setTimeout(() => stop(new Error(`命令超时:${command}`)), timeoutMs) : undefined;
66
+ this.signal.addEventListener("abort", cancel, { once: true });
67
+ const collect = (chunks) => (chunk) => {
68
+ bytes += chunk.length;
69
+ if (bytes > 2 * 1024 * 1024)
70
+ stop(new Error(`命令输出超过限制:${command}`));
71
+ else
72
+ chunks.push(chunk);
73
+ };
74
+ child.stdout?.on("data", collect(stdout));
75
+ child.stderr?.on("data", collect(stderr));
76
+ child.stdin?.on("error", () => { });
77
+ child.on("error", error => { failure = error; });
78
+ child.on("close", (code, exitSignal) => {
79
+ clearTimeout(timer);
80
+ clearTimeout(escalation);
81
+ this.signal.removeEventListener("abort", cancel);
82
+ if (this.signal.aborted)
83
+ return reject(this.signal.reason);
84
+ if (failure)
85
+ return reject(failure);
86
+ if (code !== 0)
87
+ return reject(new Error(`${command} 失败 (${exitSignal ?? code})\n${Buffer.concat(stderr).toString("utf8").slice(-8000)}`));
88
+ resolveResult(Buffer.concat(stdout).toString("utf8").trim());
89
+ });
90
+ child.stdin?.end(options.input);
91
+ if (this.signal.aborted)
92
+ cancel();
93
+ });
94
+ }
95
+ }
96
+ export function tmuxInstallCommand(platform, commands, root) {
97
+ let command, args;
98
+ if (platform === "darwin") {
99
+ command = commands.get("brew");
100
+ args = ["install", "tmux"];
101
+ }
102
+ else {
103
+ const manager = ["apt-get", "dnf", "pacman", "zypper", "apk"].find(name => commands.has(name));
104
+ command = manager ? commands.get(manager) : undefined;
105
+ args = manager === "apt-get" ? ["install", "-y", "tmux"] : manager === "dnf" ? ["install", "-y", "tmux"]
106
+ : manager === "pacman" ? ["-S", "--needed", "--noconfirm", "tmux"]
107
+ : manager === "zypper" ? ["--non-interactive", "install", "tmux"] : ["add", "tmux"];
108
+ if (command && !root) {
109
+ const sudo = commands.get("sudo");
110
+ if (!sudo)
111
+ throw new Error("安装 tmux 需要管理员权限;请手动安装 tmux 后重新运行 setup。");
112
+ return { command: sudo, args: [command, ...args] };
113
+ }
114
+ }
115
+ if (!command)
116
+ throw new Error(platform === "darwin"
117
+ ? "未找到 Homebrew。请先通过现有环境安装 tmux,再运行 setup。"
118
+ : "未找到支持的系统包管理器。请手动安装 tmux,再运行 setup。");
119
+ return { command, args };
120
+ }
121
+ export async function inspectSystem(processes, cwd) {
122
+ if (!["linux", "darwin"].includes(process.platform))
123
+ throw new Error("setup 支持 Linux 和 macOS;Windows 请使用 WSL。");
124
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
125
+ if (major < 22 || major === 22 && minor < 19)
126
+ throw new Error("需要 Node.js 22.19 或更高版本。");
127
+ const npm = await findExecutable("npm", processes.env);
128
+ if (!npm)
129
+ throw new Error("未找到 npm。请先安装 Node.js/npm。");
130
+ const python = await findExecutable("python3", processes.env) ?? await findExecutable("python", processes.env);
131
+ if (!python)
132
+ throw new Error("需要 Python 3.11+ 和 venv/ensurepip。请先安装后重试。");
133
+ try {
134
+ await processes.run(python, ["-I", "-B", "-c", [
135
+ "import sys, tempfile, venv, ensurepip",
136
+ "assert sys.version_info >= (3, 11), 'Python 3.11+ required'",
137
+ "with tempfile.TemporaryDirectory(prefix='pbot-python-check-') as path:",
138
+ " venv.EnvBuilder(with_pip=True).create(path)",
139
+ ].join("\n")], cwd);
140
+ }
141
+ catch (error) {
142
+ processes.signal.throwIfAborted();
143
+ throw new Error("基础 Python 检查失败。需要 Python 3.11+ 和 venv/ensurepip;Debian/Ubuntu 还需 python3-venv。\n" + String(error));
144
+ }
145
+ const tmux = await findExecutable("tmux", processes.env);
146
+ if (tmux) {
147
+ await processes.run(tmux, ["-V"], cwd);
148
+ return { npm };
149
+ }
150
+ const names = process.platform === "darwin" ? ["brew"] : ["apt-get", "dnf", "pacman", "zypper", "apk", "sudo"];
151
+ const entries = await Promise.all(names.map(async (name) => [name, await findExecutable(name, processes.env)]));
152
+ const commands = new Map(entries.filter((entry) => entry[1] !== undefined));
153
+ return { npm, tmux: tmuxInstallCommand(process.platform, commands, process.getuid?.() === 0) };
154
+ }
155
+ export function npmGlobalBinary(prefix) { return join(prefix, "bin", "pi"); }
@@ -0,0 +1,21 @@
1
+ import { stat, realpath } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ export async function resolveWorkspace(value, cwd, home) {
4
+ const path = resolve(cwd, value === "~" ? home : value.startsWith("~/") ? join(home, value.slice(2)) : value);
5
+ let existing = path;
6
+ while (true) {
7
+ try {
8
+ if (!(await stat(existing)).isDirectory())
9
+ throw new Error("工作区或其父路径不是目录:" + existing);
10
+ return resolve(await realpath(existing), relative(existing, path));
11
+ }
12
+ catch (error) {
13
+ if (error.code !== "ENOENT")
14
+ throw error;
15
+ const parent = dirname(existing);
16
+ if (parent === existing)
17
+ throw error;
18
+ existing = parent;
19
+ }
20
+ }
21
+ }