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.
- package/CONTEXT.md +4 -0
- package/README.md +36 -6
- package/README.zh-CN.md +36 -6
- package/bin/pbot.mjs +3 -0
- package/docs/README.md +1 -0
- package/docs/installation-design.md +224 -0
- package/docs/release.md +2 -2
- package/docs/technical-design.md +2 -2
- package/docs/validation.md +16 -2
- package/lib/common/package-source.js +16 -0
- package/lib/extension/setup-cli.js +136 -0
- package/lib/extension/setup-guard.js +11 -0
- package/lib/integrations/installation/pi-bridge.js +131 -0
- package/lib/integrations/installation/pi-setup.js +193 -0
- package/lib/integrations/installation/processes.js +155 -0
- package/lib/integrations/installation/workspace.js +21 -0
- package/lib/services/installation/setup-service.js +121 -0
- package/lib/ui/setup.js +52 -0
- package/npm-shrinkwrap.json +29 -4
- package/package.json +26 -4
- package/resources/installer/plugins.json +16 -0
- package/scripts/README.md +4 -0
- package/src/common/package-source.ts +16 -0
- package/src/extension/setup-cli.ts +109 -0
- package/src/extension/setup-guard.ts +12 -0
- package/src/integrations/installation/pi-bridge.ts +107 -0
- package/src/integrations/installation/pi-setup.ts +162 -0
- package/src/integrations/installation/processes.ts +124 -0
- package/src/integrations/installation/workspace.ts +18 -0
- package/src/services/installation/setup-service.ts +145 -0
- package/src/ui/setup.ts +40 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { npmName, ordinaryNpm } from "../../common/package-source.js";
|
|
2
|
+
export const pbotPackage = {
|
|
3
|
+
id: "pbot", label: "Personal Bot", description: "工作区个人助手", source: "npm:personal-bot", defaultSelected: true,
|
|
4
|
+
};
|
|
5
|
+
export function parseCatalog(value) {
|
|
6
|
+
if (!Array.isArray(value))
|
|
7
|
+
throw new Error("插件清单必须是数组。");
|
|
8
|
+
const ids = new Set(["pbot"]), names = new Set(["personal-bot"]);
|
|
9
|
+
return value.map((item) => {
|
|
10
|
+
if (!item || typeof item !== "object")
|
|
11
|
+
throw new Error("无效插件清单项。");
|
|
12
|
+
const entry = item;
|
|
13
|
+
for (const field of ["id", "label", "description", "source"]) {
|
|
14
|
+
if (typeof entry[field] !== "string" || !entry[field].trim() || /[\x00-\x1f\x7f]/.test(entry[field])) {
|
|
15
|
+
throw new Error(`无效插件字段:${field}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const plugin = entry;
|
|
19
|
+
const name = npmName(plugin.source);
|
|
20
|
+
if (!/^[a-z0-9-]+$/.test(plugin.id) || !name || !ordinaryNpm(plugin.source) ||
|
|
21
|
+
typeof plugin.defaultSelected !== "boolean" || ids.has(plugin.id) || names.has(name)) {
|
|
22
|
+
throw new Error(`无效或重复的插件:${plugin.id}`);
|
|
23
|
+
}
|
|
24
|
+
ids.add(plugin.id);
|
|
25
|
+
names.add(name);
|
|
26
|
+
return { id: plugin.id, label: plugin.label, description: plugin.description,
|
|
27
|
+
source: `npm:${name}`, defaultSelected: plugin.defaultSelected };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function packageChoices(catalog, installed) {
|
|
31
|
+
const first = !installed.some(pkg => pkg.name === "personal-bot");
|
|
32
|
+
return [pbotPackage, ...catalog].map(plugin => {
|
|
33
|
+
const matches = installed.filter(pkg => pkg.name === npmName(plugin.source));
|
|
34
|
+
// A project npm entry overrides the same npm identity in user settings. Different
|
|
35
|
+
// git/local identities still need an explicit decision before changing sources.
|
|
36
|
+
const localNpm = matches.some(pkg => pkg.scope === "project" && npmName(pkg.source));
|
|
37
|
+
const existing = matches.filter(pkg => !(localNpm && pkg.scope === "user" && npmName(pkg.source)));
|
|
38
|
+
const custom = existing.some(pkg => !ordinaryNpm(pkg.source));
|
|
39
|
+
return { plugin, required: plugin.id === "pbot", existing,
|
|
40
|
+
selected: plugin.id === "pbot" || (first ? plugin.defaultSelected : existing.length > 0), keep: custom };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function parseSetupArgs(args) {
|
|
44
|
+
if (!args.length || args.length === 1 && ["--help", "-h"].includes(args[0]))
|
|
45
|
+
return { action: "help" };
|
|
46
|
+
if (args.length === 1 && ["--version", "-v"].includes(args[0]))
|
|
47
|
+
return { action: "version" };
|
|
48
|
+
if (args[0] !== "setup")
|
|
49
|
+
throw new Error("用法:pbot setup [--workspace <目录>]");
|
|
50
|
+
let workspace;
|
|
51
|
+
for (let index = 1; index < args.length; index++) {
|
|
52
|
+
const arg = args[index];
|
|
53
|
+
if ((arg === "--help" || arg === "-h") && args.length === 2)
|
|
54
|
+
return { action: "help" };
|
|
55
|
+
if (arg !== "--workspace" || workspace !== undefined)
|
|
56
|
+
throw new Error(`不支持的参数:${arg}`);
|
|
57
|
+
const value = args[++index];
|
|
58
|
+
if (!value || value.startsWith("--") || /[\x00-\x1f\x7f]/.test(value))
|
|
59
|
+
throw new Error("--workspace 需要目录。");
|
|
60
|
+
workspace = value;
|
|
61
|
+
}
|
|
62
|
+
return workspace === undefined ? { action: "setup" } : { action: "setup", workspace };
|
|
63
|
+
}
|
|
64
|
+
export async function executeSetup(plan, operations, report, signal) {
|
|
65
|
+
const results = [];
|
|
66
|
+
const add = (result) => { results.push(result); report(result); };
|
|
67
|
+
let pi;
|
|
68
|
+
try {
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
if (plan.tmux) {
|
|
71
|
+
await operations.installTmux(plan.tmux);
|
|
72
|
+
add({ name: "tmux", state: "done", detail: "已安装" });
|
|
73
|
+
}
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
pi = await operations.ensurePi(plan.pi, plan.piVersion);
|
|
76
|
+
add({ name: "Pi", state: "done", detail: pi.version });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
signal.throwIfAborted();
|
|
80
|
+
add({ name: "运行环境", state: "failed", detail: String(error) });
|
|
81
|
+
return { results };
|
|
82
|
+
}
|
|
83
|
+
for (const choice of plan.packages) {
|
|
84
|
+
signal.throwIfAborted();
|
|
85
|
+
const name = choice.plugin.label;
|
|
86
|
+
if (!choice.selected) {
|
|
87
|
+
add({ name, state: "skipped", detail: "本次不处理" });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (choice.error) {
|
|
91
|
+
add({ name, state: "failed", detail: choice.error });
|
|
92
|
+
if (choice.required)
|
|
93
|
+
return { results };
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (choice.keep) {
|
|
97
|
+
try {
|
|
98
|
+
const source = await operations.verifyKeptPackage(pi, plan.workspace, choice);
|
|
99
|
+
add({ name, state: "kept", detail: source });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
signal.throwIfAborted();
|
|
103
|
+
add({ name, state: "failed", detail: String(error) });
|
|
104
|
+
if (choice.required)
|
|
105
|
+
return { results };
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const version = await operations.installPackage(pi, plan.workspace, choice);
|
|
111
|
+
add({ name, state: "done", detail: version });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
add({ name, state: "failed", detail: String(error) });
|
|
116
|
+
if (choice.required)
|
|
117
|
+
return { results };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { pi, results };
|
|
121
|
+
}
|
package/lib/ui/setup.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
export class SetupUI {
|
|
3
|
+
controller;
|
|
4
|
+
constructor(controller) {
|
|
5
|
+
this.controller = controller;
|
|
6
|
+
}
|
|
7
|
+
text(value) { console.log(value); }
|
|
8
|
+
async ask(label, fallback = "") {
|
|
9
|
+
this.controller.signal.throwIfAborted();
|
|
10
|
+
const reader = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
11
|
+
reader.on("SIGINT", () => this.controller.abort(new Error("安装已取消。")));
|
|
12
|
+
reader.on("close", () => { if (!answered)
|
|
13
|
+
this.controller.abort(new Error("输入已关闭。")); });
|
|
14
|
+
let answered = false;
|
|
15
|
+
try {
|
|
16
|
+
const value = await reader.question(label + (fallback ? " [" + fallback + "]" : "") + ": ", { signal: this.controller.signal });
|
|
17
|
+
answered = true;
|
|
18
|
+
return value.trim() || fallback;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
answered = true;
|
|
22
|
+
reader.close();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async confirm(label, fallback = true) {
|
|
26
|
+
while (true) {
|
|
27
|
+
const answer = (await this.ask(label + (fallback ? " [Y/n]" : " [y/N]"))).toLowerCase();
|
|
28
|
+
if (!answer)
|
|
29
|
+
return fallback;
|
|
30
|
+
if (["y", "yes", "是"].includes(answer))
|
|
31
|
+
return true;
|
|
32
|
+
if (["n", "no", "否"].includes(answer))
|
|
33
|
+
return false;
|
|
34
|
+
this.text("请输入 y 或 n。");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async selectMany(labels, defaults) {
|
|
38
|
+
labels.forEach((label, index) => this.text(" " + (defaults.includes(index) ? "[x] " : "[ ] ") + (index + 1) + ". " + label));
|
|
39
|
+
while (true) {
|
|
40
|
+
const answer = await this.ask("选择插件编号(逗号分隔,回车保留默认,0 不选)");
|
|
41
|
+
if (!answer)
|
|
42
|
+
return defaults;
|
|
43
|
+
if (answer === "0")
|
|
44
|
+
return [];
|
|
45
|
+
const parts = answer.split(/[,,\s]+/);
|
|
46
|
+
if (parts.every(part => /^\d+$/.test(part) && Number(part) >= 1 && Number(part) <= labels.length)) {
|
|
47
|
+
return [...new Set(parts.map(part => Number(part) - 1))];
|
|
48
|
+
}
|
|
49
|
+
this.text("请输入列表中的编号。");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "personal-bot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "personal-bot",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.4",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"ajv": "8.17.1",
|
|
12
|
-
"ajv-formats": "3.0.1"
|
|
12
|
+
"ajv-formats": "3.0.1",
|
|
13
|
+
"semver": "7.8.5"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"pbot": "bin/pbot.mjs"
|
|
13
17
|
},
|
|
14
18
|
"devDependencies": {
|
|
15
19
|
"@earendil-works/pi-ai": "1.4.0",
|
|
16
20
|
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
17
21
|
"@earendil-works/pi-tui": "1.4.0",
|
|
18
22
|
"@types/node": "24.10.1",
|
|
23
|
+
"@types/semver": "7.7.1",
|
|
19
24
|
"dependency-cruiser": "18.5.0",
|
|
20
25
|
"typebox": "1.3.27",
|
|
21
26
|
"typescript": "5.9.3"
|
|
@@ -28,6 +33,20 @@
|
|
|
28
33
|
"@earendil-works/pi-coding-agent": "*",
|
|
29
34
|
"@earendil-works/pi-tui": "*",
|
|
30
35
|
"typebox": "*"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@earendil-works/pi-ai": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@earendil-works/pi-coding-agent": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"@earendil-works/pi-tui": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"typebox": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
31
50
|
}
|
|
32
51
|
},
|
|
33
52
|
"node_modules/@anthropic-ai/sdk": {
|
|
@@ -759,6 +778,13 @@
|
|
|
759
778
|
"dev": true,
|
|
760
779
|
"license": "MIT"
|
|
761
780
|
},
|
|
781
|
+
"node_modules/@types/semver": {
|
|
782
|
+
"version": "7.7.1",
|
|
783
|
+
"resolved": "https://registry.npmmirror.com/@types/semver/-/semver-7.7.1.tgz",
|
|
784
|
+
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
|
785
|
+
"dev": true,
|
|
786
|
+
"license": "MIT"
|
|
787
|
+
},
|
|
762
788
|
"node_modules/acorn": {
|
|
763
789
|
"version": "8.18.0",
|
|
764
790
|
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
|
|
@@ -1965,7 +1991,6 @@
|
|
|
1965
1991
|
"version": "7.8.5",
|
|
1966
1992
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
|
1967
1993
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
|
1968
|
-
"dev": true,
|
|
1969
1994
|
"license": "ISC",
|
|
1970
1995
|
"bin": {
|
|
1971
1996
|
"semver": "bin/semver.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "personal-bot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/realqiyan/personal-bot.git"
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
},
|
|
13
13
|
"description": "A personal assistant connecting knowledge, research, and action. Currently implemented as a Pi extension.",
|
|
14
14
|
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"pbot": "./bin/pbot.mjs"
|
|
17
|
+
},
|
|
15
18
|
"keywords": [
|
|
16
19
|
"pi-package"
|
|
17
20
|
],
|
|
@@ -25,6 +28,8 @@
|
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
27
30
|
"npm-shrinkwrap.json",
|
|
31
|
+
"bin",
|
|
32
|
+
"lib",
|
|
28
33
|
"src",
|
|
29
34
|
"resources",
|
|
30
35
|
"scripts/setup-python.py",
|
|
@@ -42,11 +47,12 @@
|
|
|
42
47
|
"scripts": {
|
|
43
48
|
"check": "tsc --noEmit && npm run check:dependencies",
|
|
44
49
|
"check:dependencies": "depcruise --config .dependency-cruiser.cjs src",
|
|
45
|
-
"build": "node scripts/clean-dist.mjs && tsc",
|
|
50
|
+
"build": "node scripts/clean-dist.mjs && tsc && npm run build:cli",
|
|
51
|
+
"build:cli": "node scripts/build-cli.mjs",
|
|
46
52
|
"test": "npm run build && node --test --test-concurrency=1 tests/*/*.test.mjs",
|
|
47
53
|
"test:smoke": "npm run build && node --test --test-concurrency=1 tests/smoke/*.test.mjs",
|
|
48
54
|
"test:e2e": "npm run build && node --test --test-concurrency=1 tests/e2e/*.test.mjs",
|
|
49
|
-
"prepack": "npm run check"
|
|
55
|
+
"prepack": "npm run check && npm run build:cli"
|
|
50
56
|
},
|
|
51
57
|
"peerDependencies": {
|
|
52
58
|
"@earendil-works/pi-ai": "*",
|
|
@@ -54,15 +60,31 @@
|
|
|
54
60
|
"@earendil-works/pi-tui": "*",
|
|
55
61
|
"typebox": "*"
|
|
56
62
|
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@earendil-works/pi-ai": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@earendil-works/pi-coding-agent": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@earendil-works/pi-tui": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"typebox": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
57
77
|
"dependencies": {
|
|
58
78
|
"ajv": "8.17.1",
|
|
59
|
-
"ajv-formats": "3.0.1"
|
|
79
|
+
"ajv-formats": "3.0.1",
|
|
80
|
+
"semver": "7.8.5"
|
|
60
81
|
},
|
|
61
82
|
"devDependencies": {
|
|
62
83
|
"@earendil-works/pi-ai": "1.4.0",
|
|
63
84
|
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
64
85
|
"@earendil-works/pi-tui": "1.4.0",
|
|
65
86
|
"@types/node": "24.10.1",
|
|
87
|
+
"@types/semver": "7.7.1",
|
|
66
88
|
"dependency-cruiser": "18.5.0",
|
|
67
89
|
"typebox": "1.3.27",
|
|
68
90
|
"typescript": "5.9.3"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "task-conductor",
|
|
4
|
+
"label": "Task Conductor",
|
|
5
|
+
"description": "周期任务、流程和 DAG 的接入与使用引导;服务另行准备",
|
|
6
|
+
"source": "npm:task-conductor-plugin",
|
|
7
|
+
"defaultSelected": true
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "web-access",
|
|
11
|
+
"label": "Pi Web Access",
|
|
12
|
+
"description": "网页搜索与内容获取",
|
|
13
|
+
"source": "npm:pi-web-access",
|
|
14
|
+
"defaultSelected": true
|
|
15
|
+
}
|
|
16
|
+
]
|
package/scripts/README.md
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
脚本不接受解释器路径,不修改工作区资料或历史记录。路径、激活和失败处理见 [Python 说明](../python/README.md)。
|
|
8
8
|
|
|
9
|
+
## 安装 CLI 构建
|
|
10
|
+
|
|
11
|
+
`npm run build:cli` 调用 `build-cli.mjs`,清理生成目录 lib,再按 tsconfig.cli.json 编译独立 CLI、Pi 检查桥接和启动保护。bin/pbot.mjs 转交已生成的入口。npm test 的构建与 prepack 都执行此步骤;扩展运行仍由 Pi 加载 src 源码。消费者直接使用发布包中的 lib,无需安装开发依赖。
|
|
12
|
+
|
|
9
13
|
## 构建清理
|
|
10
14
|
|
|
11
15
|
`clean-dist.mjs` 由 npm run build 调用,仅清理仓库内生成的 dist/,供 TypeScript 重新编译测试导入。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { prerelease, satisfies, valid } from "semver";
|
|
2
|
+
|
|
3
|
+
export function npmName(source: string): string | undefined {
|
|
4
|
+
return /^npm:((?:@[a-z0-9._-]+\/)?[a-z0-9._-]+)(?:@[^\s]+)?$/.exec(source)?.[1];
|
|
5
|
+
}
|
|
6
|
+
export function ordinaryNpm(source: string): boolean {
|
|
7
|
+
const name = npmName(source);
|
|
8
|
+
return name !== undefined && (source === `npm:${name}` || source === `npm:${name}@latest`);
|
|
9
|
+
}
|
|
10
|
+
export function checkStableVersion(version: string, nodeRange?: string): string {
|
|
11
|
+
if (!valid(version) || prerelease(version)) throw new Error(`latest 不是稳定版本:${version}`);
|
|
12
|
+
if (nodeRange && !satisfies(process.versions.node, nodeRange)) {
|
|
13
|
+
throw new Error(`目标版本要求 Node.js ${nodeRange},当前为 ${process.versions.node}。请先升级 Node.js。`);
|
|
14
|
+
}
|
|
15
|
+
return version;
|
|
16
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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 type { InstalledPackage, SetupPlan } from "../services/installation/setup-service.js";
|
|
7
|
+
import { PiBootstrapRequired, PiSetup, piPackageName } from "../integrations/installation/pi-setup.js";
|
|
8
|
+
import { inspectSystem, SetupProcesses } from "../integrations/installation/processes.js";
|
|
9
|
+
import { resolveWorkspace } from "../integrations/installation/workspace.js";
|
|
10
|
+
import { SetupUI } from "../ui/setup.js";
|
|
11
|
+
|
|
12
|
+
const root = new URL("../../", import.meta.url);
|
|
13
|
+
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
14
|
+
|
|
15
|
+
export async function main(args: string[]): Promise<number> {
|
|
16
|
+
let options: ReturnType<typeof parseSetupArgs>;
|
|
17
|
+
try { options = parseSetupArgs(args); }
|
|
18
|
+
catch (error) { console.error(String(error)); return 2; }
|
|
19
|
+
const manifest = JSON.parse(await readFile(new URL("package.json", root), "utf8")) as { version: string };
|
|
20
|
+
if (options.action === "version") { console.log(manifest.version); return 0; }
|
|
21
|
+
if (options.action === "help") {
|
|
22
|
+
console.log("Personal Bot " + manifest.version + "\n\n用法:pbot setup [--workspace <目录>]\n" +
|
|
23
|
+
" npx personal-bot@latest setup\n\nsetup 安装或升级 Pi、pbot 和选中的插件,然后启动 Pi 执行 /pbot init。\n" +
|
|
24
|
+
"支持 Linux/macOS,需要 Node.js 22.19+、npm 和 Python 3.11+。\n--help 显示帮助;--version 显示安装器版本。");
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
28
|
+
console.error("setup 需要交互终端。请在终端运行 npx personal-bot@latest setup。"); return 2;
|
|
29
|
+
}
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const cancel = () => controller.abort(new Error("安装已取消;已完成的步骤保留。"));
|
|
32
|
+
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
|
|
33
|
+
const ui = new SetupUI(controller), processes = new SetupProcesses(controller.signal);
|
|
34
|
+
try {
|
|
35
|
+
const cwd = process.cwd();
|
|
36
|
+
ui.text("Personal Bot " + manifest.version + " — 安装与升级\n正在检查 Node/npm、Python 和 tmux…");
|
|
37
|
+
const system = await inspectSystem(processes, cwd);
|
|
38
|
+
const supplied = options.workspace ?? await ui.ask("工作区目录(当前、已有或新建目录)", cwd);
|
|
39
|
+
const workspace = await resolveWorkspace(supplied, cwd, homedir());
|
|
40
|
+
const host = new PiSetup(processes, system.npm, cwd);
|
|
41
|
+
let pi = await host.inspectPi();
|
|
42
|
+
let installed: InstalledPackage[];
|
|
43
|
+
try { installed = await host.inspectPackages(pi, workspace); }
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof PiBootstrapRequired)) throw error;
|
|
46
|
+
const version = await host.latest(piPackageName);
|
|
47
|
+
ui.text(error.message + "\n需先安装 Pi 才能使用其原生接口核对这些来源。目标版本:" + version);
|
|
48
|
+
if (!await ui.confirm("先通过 npm 安装 Pi,再继续选择工作区插件")) return 130;
|
|
49
|
+
pi = await host.ensurePi(undefined, version);
|
|
50
|
+
installed = await host.inspectPackages(pi, workspace);
|
|
51
|
+
}
|
|
52
|
+
const catalog = parseCatalog(JSON.parse(await readFile(new URL("resources/installer/plugins.json", root), "utf8")));
|
|
53
|
+
const choices = packageChoices(catalog, installed), optional = choices.filter(choice => !choice.required);
|
|
54
|
+
ui.text("\nPersonal Bot 必装。选择配套插件(取消勾选不会卸载已有插件):");
|
|
55
|
+
const selected = await ui.selectMany(optional.map(choice => choice.plugin.label + " — " + choice.plugin.description),
|
|
56
|
+
optional.flatMap((choice, index) => choice.selected ? [index] : []));
|
|
57
|
+
optional.forEach((choice, index) => { choice.selected = selected.includes(index); });
|
|
58
|
+
for (const choice of choices.filter(item => item.selected)) {
|
|
59
|
+
if (choice.existing.some(pkg => !ordinaryNpm(pkg.source))) {
|
|
60
|
+
ui.text("\n" + choice.plugin.label + " 的已有来源:\n" + choice.existing.map(pkg =>
|
|
61
|
+
" " + pkg.scope + ": " + pkg.source + " (" + (pkg.version ?? "版本未知") + ")").join("\n"));
|
|
62
|
+
choice.keep = !await ui.confirm("切换到 npm 最新稳定版", false);
|
|
63
|
+
}
|
|
64
|
+
if (choice.keep) {
|
|
65
|
+
if (choice.existing.length !== 1 || !choice.existing[0]?.installedPath) choice.error = "保留来源未安装或存在多份来源,请先通过 Pi 原生配置处理。";
|
|
66
|
+
} else {
|
|
67
|
+
const others = choice.existing.filter(pkg => !npmName(pkg.source));
|
|
68
|
+
if (others.length > 1 || others.some(pkg => pkg.scope === "user")) {
|
|
69
|
+
choice.error = "请先用 pi config 调整全局或重复来源的加载范围,再切换到 npm。";
|
|
70
|
+
} else {
|
|
71
|
+
try { choice.target = await host.latest(npmName(choice.plugin.source)!); }
|
|
72
|
+
catch (error) { controller.signal.throwIfAborted(); choice.error = String(error); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const plan: SetupPlan = { workspace, piVersion: await host.latest(piPackageName), packages: choices,
|
|
77
|
+
...(pi ? { pi } : {}), ...(system.tmux ? { tmux: system.tmux } : {}) };
|
|
78
|
+
ui.text("\n安装计划\n工作区:" + workspace + "\nPi:" + (pi ? pi.command + " (" + pi.version + ")" : "通过 npm 全局安装") + " → " + plan.piVersion);
|
|
79
|
+
if (system.tmux) ui.text("tmux:" + [system.tmux.command, ...system.tmux.args].map(quote).join(" "));
|
|
80
|
+
for (const choice of choices) {
|
|
81
|
+
ui.text(" " + choice.plugin.label + ": " + (!choice.selected ? "本次跳过" : choice.error ??
|
|
82
|
+
(choice.keep ? "保留 " + choice.existing[0]?.source : (choice.existing.map(pkg => pkg.version ?? "未知").join(", ") || "未安装") + " → " + choice.target)));
|
|
83
|
+
if (choice.selected && choice.existing.some(pkg => pkg.filtered)) ui.text(" 保留已有资源过滤;如 pbot 被禁用,请通过 pi config --local 启用。");
|
|
84
|
+
}
|
|
85
|
+
const blocking = choices.find(choice => choice.required && choice.error);
|
|
86
|
+
if (blocking) throw new Error(blocking.error);
|
|
87
|
+
ui.text("升级前请停止活动 pbot 子会话和相关 Python 脚本。Pi 为当前用户共用,插件只处理此工作区。");
|
|
88
|
+
if (!await ui.confirm("执行以上计划")) return 130;
|
|
89
|
+
await mkdir(workspace, { recursive: true });
|
|
90
|
+
if (await realpath(workspace) !== workspace) throw new Error("工作区路径已发生变化,请重新运行 setup。");
|
|
91
|
+
const result = await executeSetup(plan, host, item => ui.text("[" + item.state + "] " + item.name + ": " + item.detail), controller.signal);
|
|
92
|
+
const failed = result.results.some(item => item.state === "failed");
|
|
93
|
+
if (!result.pi) return 1;
|
|
94
|
+
ui.text("\n软件安装阶段结束。初始化、Python 环境和外部服务的状态由 Pi/pbot/插件分别报告。");
|
|
95
|
+
if (await ui.confirm(failed ? "部分插件失败,是否仍先启动 Pi" : "立即启动 Pi", !failed)) {
|
|
96
|
+
process.stdin.pause();
|
|
97
|
+
await host.start(result.pi, workspace);
|
|
98
|
+
} else {
|
|
99
|
+
ui.text("稍后执行:\ncd " + quote(workspace) + "\n" + quote(result.pi.command) + " --extension " +
|
|
100
|
+
quote(fileURLToPath(new URL("extension/setup-guard.js", new URL("../", import.meta.url)))) + " '/pbot init'");
|
|
101
|
+
}
|
|
102
|
+
return failed ? 1 : 0;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
105
|
+
return controller.signal.aborted ? 130 : 1;
|
|
106
|
+
} finally {
|
|
107
|
+
process.off("SIGINT", cancel); process.off("SIGTERM", cancel);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Native extension commands run before input handlers. Reaching this handler for
|
|
4
|
+
// /pbot init means it was not dispatched; never let the setup input reach a model.
|
|
5
|
+
export default function setupGuard(pi: ExtensionAPI) {
|
|
6
|
+
pi.on("input", (event, ctx) => {
|
|
7
|
+
if (event.text.trim() !== "/pbot init") return { action: "continue" };
|
|
8
|
+
ctx.ui.notify("pbot 未加载或命令存在冲突。请检查项目信任和 pi config --local 后重新运行 setup。", "error");
|
|
9
|
+
ctx.shutdown();
|
|
10
|
+
return { action: "handled" };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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 type { InstalledPackage } from "../../services/installation/setup-service.js";
|
|
6
|
+
import { npmName } from "../../common/package-source.js";
|
|
7
|
+
|
|
8
|
+
type PiSdk = typeof import("@earendil-works/pi-coding-agent");
|
|
9
|
+
async function sdkAt(root: string): Promise<PiSdk> {
|
|
10
|
+
const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
11
|
+
if (manifest.name !== "@earendil-works/pi-coding-agent") throw new Error("不是支持的 Pi 安装。");
|
|
12
|
+
return import(pathToFileURL(join(root, "dist/index.js")).href) as Promise<PiSdk>;
|
|
13
|
+
}
|
|
14
|
+
const sourceOf = (entry: unknown): string => {
|
|
15
|
+
if (typeof entry === "string") return entry;
|
|
16
|
+
if (entry && typeof entry === "object" && "source" in entry && typeof entry.source === "string") return entry.source;
|
|
17
|
+
throw new Error("无效的 Pi packages 声明。");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function inspectPackages(root: string, workspace: string): Promise<InstalledPackage[]> {
|
|
21
|
+
const sdk = await sdkAt(root), agentDir = sdk.getAgentDir();
|
|
22
|
+
// Inspect project declarations as data only. This does not load extensions, execute
|
|
23
|
+
// project npmCommand, grant project trust, or persist migrated settings.
|
|
24
|
+
const settings = sdk.SettingsManager.fromStorage({ withLock(scope, read) {
|
|
25
|
+
const file = scope === "global" ? join(agentDir, "settings.json") : join(workspace, sdk.CONFIG_DIR_NAME, "settings.json");
|
|
26
|
+
let content: string | undefined;
|
|
27
|
+
try { content = readFileSync(file, "utf8"); }
|
|
28
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
29
|
+
if (content !== undefined) {
|
|
30
|
+
const value: unknown = JSON.parse(content);
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`无效的 Pi 配置:${file}`);
|
|
32
|
+
const packages = (value as { packages?: unknown }).packages;
|
|
33
|
+
if (packages !== undefined) {
|
|
34
|
+
if (!Array.isArray(packages)) throw new Error(`packages 必须是数组:${file}`);
|
|
35
|
+
packages.forEach(sourceOf);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (read(content) !== undefined) throw new Error("安装检查不得修改 Pi 配置。");
|
|
39
|
+
} }, { projectTrusted: true });
|
|
40
|
+
const errors = settings.drainErrors();
|
|
41
|
+
if (errors.length) throw new Error(errors.map(item => `${item.scope}: ${item.error.message}`).join("\n"));
|
|
42
|
+
const manager = new sdk.DefaultPackageManager({ cwd: workspace, agentDir, settingsManager: settings });
|
|
43
|
+
const result: InstalledPackage[] = [];
|
|
44
|
+
for (const pkg of manager.listConfiguredPackages()) {
|
|
45
|
+
const entries = (pkg.scope === "project" ? settings.getProjectSettings() : settings.getGlobalSettings()).packages ?? [];
|
|
46
|
+
const entry = entries.find(item => sourceOf(item) === pkg.source);
|
|
47
|
+
let name = npmName(pkg.source), version: string | undefined;
|
|
48
|
+
if (pkg.installedPath) {
|
|
49
|
+
let file = pkg.installedPath;
|
|
50
|
+
if ((await stat(file)).isDirectory()) file = join(file, "package.json");
|
|
51
|
+
else file = ""; // Single-file extensions have no npm identity.
|
|
52
|
+
if (file) {
|
|
53
|
+
try {
|
|
54
|
+
const manifest = JSON.parse(await readFile(file, "utf8")) as { name?: string; version?: string };
|
|
55
|
+
if (name && manifest.name !== name) throw new Error(`安装包身份不匹配:${pkg.source}`);
|
|
56
|
+
name = typeof manifest.name === "string" ? manifest.name : name;
|
|
57
|
+
version = typeof manifest.version === "string" ? manifest.version : undefined;
|
|
58
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
result.push({ source: pkg.source, scope: pkg.scope, filtered: pkg.filtered, entry,
|
|
62
|
+
...(pkg.installedPath ? { installedPath: pkg.installedPath } : {}), ...(name ? { name } : {}), ...(version ? { version } : {}) });
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function replaceProjectSource(root: string, workspace: string, old: InstalledPackage, target: string): Promise<void> {
|
|
68
|
+
const fromProject = old.scope === "project" && !npmName(old.source);
|
|
69
|
+
const fromUser = old.scope === "user" && !!npmName(old.source);
|
|
70
|
+
if (!fromProject && !fromUser) throw new Error("此操作只替换工作区自定义来源,或继承全局 npm 资源过滤。");
|
|
71
|
+
const sdk = await sdkAt(root);
|
|
72
|
+
const settings = sdk.SettingsManager.create(workspace, sdk.getAgentDir(), { projectTrusted: true });
|
|
73
|
+
const errors = settings.drainErrors();
|
|
74
|
+
if (errors.length) throw new Error(errors.map(item => item.error.message).join("\n"));
|
|
75
|
+
const packages = settings.getProjectSettings().packages ?? [];
|
|
76
|
+
const originals = fromProject ? packages : settings.getGlobalSettings().packages ?? [];
|
|
77
|
+
const previous = originals.find(entry => sourceOf(entry) === old.source);
|
|
78
|
+
if (previous === undefined || JSON.stringify(previous) !== JSON.stringify(old.entry)) throw new Error("插件配置已发生变化,请重新运行 setup。");
|
|
79
|
+
if (!packages.some(entry => sourceOf(entry) === target)) throw new Error("目标包尚未安装,保留原声明。");
|
|
80
|
+
const manager = new sdk.DefaultPackageManager({ cwd: workspace, agentDir: sdk.getAgentDir(), settingsManager: settings });
|
|
81
|
+
const installed = manager.getInstalledPath(target, "project");
|
|
82
|
+
if (!installed || !npmName(target)) throw new Error("目标 npm 包尚未安装,保留原声明。");
|
|
83
|
+
const manifest = JSON.parse(await readFile(join(installed, "package.json"), "utf8"));
|
|
84
|
+
if (manifest.name !== npmName(target)) throw new Error("目标安装包身份不符,保留原声明。");
|
|
85
|
+
// Change only declarations; do not call remove(), which deletes managed git caches.
|
|
86
|
+
const replacement = typeof previous === "string" ? target : { ...previous, source: target };
|
|
87
|
+
const next = fromProject
|
|
88
|
+
? packages.filter(entry => sourceOf(entry) !== target).map(entry => sourceOf(entry) === old.source ? replacement : entry)
|
|
89
|
+
: packages.map(entry => sourceOf(entry) === target ? replacement : entry);
|
|
90
|
+
settings.setProjectPackages(next);
|
|
91
|
+
await settings.flush();
|
|
92
|
+
const failures = settings.drainErrors();
|
|
93
|
+
if (failures.length) throw new Error(failures.map(item => item.error.message).join("\n"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
const [action, root, workspace, input] = process.argv.slice(2);
|
|
98
|
+
if (!root || !workspace) throw new Error("缺少 Pi 路径或工作区。");
|
|
99
|
+
if (action === "inspect") console.log(JSON.stringify(await inspectPackages(root, workspace)));
|
|
100
|
+
else if (action === "replace" && input) {
|
|
101
|
+
const { old, target } = JSON.parse(input) as { old: InstalledPackage; target: string };
|
|
102
|
+
await replaceProjectSource(root, workspace, old, target);
|
|
103
|
+
} else throw new Error("无效的安装检查操作。");
|
|
104
|
+
}
|
|
105
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
106
|
+
await main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
107
|
+
}
|