@aiti-ai/aiti-cmd 0.0.0-stage → 0.1.9
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.
Potentially problematic release.
This version of @aiti-ai/aiti-cmd might be problematic. Click here for more details.
- package/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/audit.ts +255 -0
- package/check-client.mjs +273 -0
- package/client/src/index.js +2216 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/ext-icons-deploy.mjs +24 -0
- package/icon.svg +1 -0
- package/icons/terminal.svg +1 -0
- package/index.ts +544 -0
- package/package.json +34 -4
- package/pi-commands.ts +286 -0
- package/recognize.ts +508 -0
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/runner.ts +594 -0
- package/skills/command-create/SKILL.md +47 -0
- package/skills/command-fill/SKILL.md +46 -0
- package/skills/command-fix/SKILL.md +49 -0
- package/skills/command-run/SKILL.md +41 -0
- package/skills/index.json +6 -0
- package/store.ts +228 -0
- package/tools/build-client.mjs +52 -0
- package/tools/deploy.mjs +120 -0
- package/tools/exec-integration.test.mjs +222 -0
- package/tools/recognize.test.mjs +163 -0
- package/README.md +0 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: command-fill
|
|
3
|
+
title: AI 帮我填命令表单
|
|
4
|
+
placeholder: 描述你想填的命令字段…(需从命令详情弹窗的 AI 按钮唤起)
|
|
5
|
+
outputs:
|
|
6
|
+
format: json-obj
|
|
7
|
+
schema:
|
|
8
|
+
name: 命令名(简短动宾)
|
|
9
|
+
cmd: shell 命令(必填)
|
|
10
|
+
tags: 标签数组(可选)
|
|
11
|
+
action:
|
|
12
|
+
type: fill
|
|
13
|
+
fields:
|
|
14
|
+
- key: name
|
|
15
|
+
label: 名称
|
|
16
|
+
- key: cmd
|
|
17
|
+
label: 命令
|
|
18
|
+
- key: tags
|
|
19
|
+
label: 标签
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# command-fill · 填命令表单
|
|
23
|
+
|
|
24
|
+
你是 aiti-cmd 表单助手。把 AI 结果直接操作到用户打开的「命令详情」弹窗表单里(不是只给 JSON 让用户自己贴)。
|
|
25
|
+
|
|
26
|
+
## 步骤
|
|
27
|
+
|
|
28
|
+
1. 先调工具 `self_ui`(缺省目标 AI 主窗口 xxx/ai)扫描当前打开的弹窗,拿到弹窗标题与字段清单(label/selector/type/当前值)——确认弹窗确实开着、字段名与你将输出的一致。
|
|
29
|
+
2. 按用户要求确定各字段的值。
|
|
30
|
+
3. 调工具 `form_fill` 把值填入弹窗表单:`fields` 传 `[{selector 或 label, value}]`(能用 label 就用 self_ui 返回的 label,selector 更准就用 selector);一次填完或分多次都行。
|
|
31
|
+
4. 返回里的 `filled` 表示填成功几个字段;若 `missed>0`,说明有字段没定位到,再 self_ui 确认实际字段名/选择器后重试。
|
|
32
|
+
5. 需要提交时调 form_fill 的 `button` 参数(如「保存」「确定」)点击,或 page_exec 点按钮。
|
|
33
|
+
6. 完成后用 self_ui 或 page_exec 抽查确认值已填入。
|
|
34
|
+
|
|
35
|
+
## 输出格式(务必遵守)
|
|
36
|
+
|
|
37
|
+
无论工具是否成功,**最后一行输出 JSON**:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{ "filled": { "字段名": "实际填入的值" }, "note": "一两句过程说明(中文)" }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `filled`: 真正写入弹窗的字段名 → 值
|
|
44
|
+
- 若工具链不可用未填入,`filled` 传空对象 `{}` 并在 `note` 说明,让前端用本地回填兜底
|
|
45
|
+
|
|
46
|
+
不要输出 JSON 代码块之外的无关文本。
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: command-fix
|
|
3
|
+
title: AI 优化/修复命令
|
|
4
|
+
placeholder: 描述要改哪条命令、怎么改(如:把打包命令加上 git 推送)…
|
|
5
|
+
outputs:
|
|
6
|
+
format: json-obj
|
|
7
|
+
schema:
|
|
8
|
+
id: 命令 id(从列表读取)
|
|
9
|
+
name: 命令名
|
|
10
|
+
cmd: 改进后的命令(必填)
|
|
11
|
+
tags: 标签数组
|
|
12
|
+
confirm: 危险命令需确认(布尔)
|
|
13
|
+
action:
|
|
14
|
+
type: api
|
|
15
|
+
method: PUT
|
|
16
|
+
path: /api/extensions/aiti-cmd/commands/{id}
|
|
17
|
+
mapping:
|
|
18
|
+
id: id
|
|
19
|
+
name: name
|
|
20
|
+
cmd: cmd
|
|
21
|
+
tags: tags
|
|
22
|
+
confirm: confirm
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# command-fix · 优化命令
|
|
26
|
+
|
|
27
|
+
你是 aiti-cmd 命令优化助手。用户想改进某条已收藏的命令。
|
|
28
|
+
|
|
29
|
+
## 步骤
|
|
30
|
+
|
|
31
|
+
1. 先用工具读取命令列表:`GET {basePath}/api/extensions/aiti-cmd/commands`(返回 `{ok, commands:[{id,name,cmd,tags,confirm}]}`)
|
|
32
|
+
2. 找到用户指的那条命令的 `id`
|
|
33
|
+
3. 按用户要求改进(如加参数、优化逻辑、更清晰的输出)
|
|
34
|
+
|
|
35
|
+
## 输出格式
|
|
36
|
+
|
|
37
|
+
只输出完整 JSON,不闲聊:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"id": "命令 id(必须来自列表)",
|
|
42
|
+
"name": "命令名",
|
|
43
|
+
"cmd": "改进后的 shell 命令",
|
|
44
|
+
"tags": ["标签"],
|
|
45
|
+
"confirm": false
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
前端会通过 `PUT {basePath}/api/extensions/aiti-cmd/commands/{id}` 更新(path 模板用 id 替换)。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: command-run
|
|
3
|
+
title: AI 帮我执行命令
|
|
4
|
+
placeholder: 描述要执行什么(如:起一个 watch 模式的前端构建,我要在面板里看日志)…
|
|
5
|
+
outputs:
|
|
6
|
+
format: json-obj
|
|
7
|
+
schema:
|
|
8
|
+
command: 要执行的 shell 命令(必填)
|
|
9
|
+
cwd: 工作目录(可选)
|
|
10
|
+
name: 会话显示名(可选,如「前端 watch」)
|
|
11
|
+
ttlMs: 会话保留时长毫秒(可选,长驻任务建议 86400000)
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# command-run · 执行命令
|
|
15
|
+
|
|
16
|
+
你是 aiti-cmd 执行助手,帮用户在后端 PTY runner 里起命令会话。**调用工具 `aiti_cmd_run` 执行**(不要用自带 bash——本 skill 的价值是常驻会话 + 实时面板 + 全程审计)。只输出简短说明,不闲聊。
|
|
17
|
+
|
|
18
|
+
## 执行
|
|
19
|
+
|
|
20
|
+
调用工具 `aiti_cmd_run`,参数:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"command": "shell 命令(必填)",
|
|
25
|
+
"cwd": "工作目录(可选)",
|
|
26
|
+
"name": "会话显示名(可选)",
|
|
27
|
+
"ttlMs": 86400000
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **适用**:长驻/交互式/用户要在 ⚡命令 面板围观的任务(dev server、watch、日志跟随、登录 PTY)
|
|
32
|
+
- **不适用**:要立刻拿输出的一次性命令(直接用自带 bash 更快)
|
|
33
|
+
|
|
34
|
+
## 安全(必须遵守)
|
|
35
|
+
|
|
36
|
+
1. 危险命令(rm -rf、git push --force、chmod -R 777、DROP TABLE 等)命中词库时工具会**拒绝并要求二次确认**——此时你必须把**命令原文**完整展示给用户,**获得明确同意后**才能以 `confirm: true` 重调;用户未同意就停止。
|
|
37
|
+
2. 不确定后果的命令先向用户说明再执行,不要默默跑。
|
|
38
|
+
|
|
39
|
+
## 结果告知
|
|
40
|
+
|
|
41
|
+
执行成功后告诉用户:会话 id、去 ⚡命令 面板看实时日志;需要停止在面板里点 ⏹ 终止。
|
package/store.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cmd · 常用命令存储(~/.pi/web/data/aiti-cmd/config/commands.json)
|
|
3
|
+
*
|
|
4
|
+
* v2:树形分组。结构 { version, groups: GroupItem[], commands: CommandItem[] }
|
|
5
|
+
* - groups:树(parentId 引用,根 parentId=null)
|
|
6
|
+
* - commands.groupId:所属分组(null=未分组)
|
|
7
|
+
* CRUD + 原子写(临时文件 + rename);{xxx} 占位符自动提取。
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export interface GroupItem {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
parentId: string | null;
|
|
17
|
+
sort: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CommandItem {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
cmd: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
tags?: string[];
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
confirm?: boolean;
|
|
29
|
+
/** 从 cmd 提取的占位符(运行前填参数) */
|
|
30
|
+
params?: string[];
|
|
31
|
+
/** 所属分组(null=未分组) */
|
|
32
|
+
groupId?: string | null;
|
|
33
|
+
/** 沙箱模式:none=无沙箱(默认);bwrap-ro=只读+工作目录可写;bwrap-offline=只读+断网;bwrap-tmp=临时可写盘(退出即丢) */
|
|
34
|
+
sandbox?: "none" | "bwrap-ro" | "bwrap-offline" | "bwrap-tmp";
|
|
35
|
+
/** 是否参与 aiti-ai-input 智能提示(suggest 联想/补全的来源;false=不提示;缺省/true=提示,默认开)
|
|
36
|
+
* 自动来源(source=ai-message/clipboard)未显式指定时默认 false(系统创建的条目不污染提示来源) */
|
|
37
|
+
suggest?: boolean;
|
|
38
|
+
createdAt: number;
|
|
39
|
+
lastRunAt?: number;
|
|
40
|
+
runCount?: number;
|
|
41
|
+
source?: "manual" | "ai-message" | "clipboard";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 合法沙箱模式(存储白名单;未知值一律丢弃→undefined) */
|
|
45
|
+
export const SANDBOX_MODES = ["none", "bwrap-ro", "bwrap-offline", "bwrap-tmp"] as const;
|
|
46
|
+
export type SandboxMode = (typeof SANDBOX_MODES)[number];
|
|
47
|
+
export function normSandbox(v: unknown): SandboxMode | undefined {
|
|
48
|
+
return (SANDBOX_MODES as readonly string[]).includes(String(v)) ? (v as SandboxMode) : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 提取 {xxx} 占位符(排除 ${var} shell 变量) */
|
|
52
|
+
export function extractParams(cmd: string): string[] {
|
|
53
|
+
const out: string[] = [];
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
const re = /\{([a-zA-Z_][\w.-]*)\}/g;
|
|
56
|
+
let m: RegExpExecArray | null;
|
|
57
|
+
while ((m = re.exec(cmd))) {
|
|
58
|
+
if (!seen.has(m[1])) { seen.add(m[1]); out.push(m[1]); }
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createStore(configDir: string) {
|
|
64
|
+
const file = path.join(configDir, "commands.json");
|
|
65
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
function load(): { groups: GroupItem[]; commands: CommandItem[] } {
|
|
68
|
+
try {
|
|
69
|
+
if (!fs.existsSync(file)) return { groups: [], commands: [] };
|
|
70
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
71
|
+
const groups = Array.isArray(data.groups) ? data.groups : [];
|
|
72
|
+
const commands = Array.isArray(data.commands) ? data.commands : [];
|
|
73
|
+
return { groups, commands };
|
|
74
|
+
} catch {
|
|
75
|
+
return { groups: [], commands: [] };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function save(groups: GroupItem[], commands: CommandItem[]) {
|
|
79
|
+
const tmp = file + ".tmp";
|
|
80
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 2, groups, commands }, null, 2) + "\n", { mode: 0o600 });
|
|
81
|
+
fs.renameSync(tmp, file);
|
|
82
|
+
}
|
|
83
|
+
function genId(prefix: string) {
|
|
84
|
+
return prefix + "_" + randomBytes(6).toString("hex");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 存量迁移:自动来源(非 manual)且未显式设置 suggest 的命令 → 默认不参与智能提示(一次)
|
|
88
|
+
function migrateAutoSuggest() {
|
|
89
|
+
const { groups, commands } = load();
|
|
90
|
+
let changed = false;
|
|
91
|
+
for (const c of commands) {
|
|
92
|
+
if (c.suggest === undefined && c.source && c.source !== "manual") { c.suggest = false; changed = true; }
|
|
93
|
+
}
|
|
94
|
+
if (changed) save(groups, commands);
|
|
95
|
+
}
|
|
96
|
+
migrateAutoSuggest();
|
|
97
|
+
|
|
98
|
+
// ---------- 命令 ----------
|
|
99
|
+
function list(): CommandItem[] {
|
|
100
|
+
return load().commands.sort((a, b) => (b.lastRunAt || b.createdAt) - (a.lastRunAt || a.createdAt));
|
|
101
|
+
}
|
|
102
|
+
function get(id: string): CommandItem | null {
|
|
103
|
+
return load().commands.find((c) => c.id === id) || null;
|
|
104
|
+
}
|
|
105
|
+
function create(input: Partial<CommandItem> & { name: string; cmd: string }): CommandItem {
|
|
106
|
+
const { groups, commands } = load();
|
|
107
|
+
const item: CommandItem = {
|
|
108
|
+
id: genId("c"),
|
|
109
|
+
name: String(input.name || input.cmd).trim(),
|
|
110
|
+
cmd: input.cmd,
|
|
111
|
+
cwd: input.cwd || undefined,
|
|
112
|
+
tags: input.tags || [],
|
|
113
|
+
env: input.env || undefined,
|
|
114
|
+
confirm: input.confirm ?? false,
|
|
115
|
+
params: extractParams(input.cmd),
|
|
116
|
+
groupId: input.groupId || null,
|
|
117
|
+
sandbox: normSandbox(input.sandbox),
|
|
118
|
+
// 显式 suggest 优先;缺省时手动创建的默认开、自动来源(ai-message/clipboard)默认关
|
|
119
|
+
suggest: input.suggest !== undefined ? input.suggest !== false : (!input.source || input.source === "manual"),
|
|
120
|
+
createdAt: Date.now(),
|
|
121
|
+
lastRunAt: undefined,
|
|
122
|
+
runCount: 0,
|
|
123
|
+
source: input.source || "manual",
|
|
124
|
+
};
|
|
125
|
+
commands.push(item);
|
|
126
|
+
save(groups, commands);
|
|
127
|
+
return item;
|
|
128
|
+
}
|
|
129
|
+
function update(id: string, patch: Partial<CommandItem>): CommandItem | null {
|
|
130
|
+
const { groups, commands } = load();
|
|
131
|
+
const idx = commands.findIndex((c) => c.id === id);
|
|
132
|
+
if (idx < 0) return null;
|
|
133
|
+
const clean = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
|
134
|
+
// 防御清洗:tags 可能以逗号字符串传入(客户端表单原始值)——统一成 string[]
|
|
135
|
+
if (typeof clean.tags === "string") clean.tags = (clean.tags as string).split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
|
136
|
+
if (clean.cwd === "") clean.cwd = undefined;
|
|
137
|
+
const next = { ...commands[idx], ...clean, id };
|
|
138
|
+
if (clean.cmd !== undefined) next.params = extractParams(next.cmd);
|
|
139
|
+
if (clean.name === undefined) next.name = next.name || next.cmd;
|
|
140
|
+
if (clean.groupId !== undefined) next.groupId = clean.groupId || null;
|
|
141
|
+
if (clean.sandbox !== undefined) next.sandbox = normSandbox(clean.sandbox);
|
|
142
|
+
next.cwd = next.cwd || undefined;
|
|
143
|
+
commands[idx] = next;
|
|
144
|
+
save(groups, commands);
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
147
|
+
function touchRun(id: string): void {
|
|
148
|
+
const { groups, commands } = load();
|
|
149
|
+
const idx = commands.findIndex((c) => c.id === id);
|
|
150
|
+
if (idx < 0) return;
|
|
151
|
+
commands[idx].lastRunAt = Date.now();
|
|
152
|
+
commands[idx].runCount = (commands[idx].runCount || 0) + 1;
|
|
153
|
+
save(groups, commands);
|
|
154
|
+
}
|
|
155
|
+
function remove(id: string): boolean {
|
|
156
|
+
const { groups, commands } = load();
|
|
157
|
+
const idx = commands.findIndex((c) => c.id === id);
|
|
158
|
+
if (idx < 0) return false;
|
|
159
|
+
commands.splice(idx, 1);
|
|
160
|
+
save(groups, commands);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- 分组(树) ----------
|
|
165
|
+
function listGroups(): GroupItem[] {
|
|
166
|
+
return load().groups;
|
|
167
|
+
}
|
|
168
|
+
function createGroup(name: string, parentId: string | null): GroupItem {
|
|
169
|
+
const { groups, commands } = load();
|
|
170
|
+
if (parentId && !groups.some((g) => g.id === parentId)) parentId = null;
|
|
171
|
+
const g: GroupItem = { id: genId("g"), name: String(name || "新分组").trim(), parentId, sort: 0, createdAt: Date.now() };
|
|
172
|
+
groups.push(g);
|
|
173
|
+
save(groups, commands);
|
|
174
|
+
return g;
|
|
175
|
+
}
|
|
176
|
+
function updateGroup(id: string, patch: Partial<GroupItem>): GroupItem | null {
|
|
177
|
+
const { groups, commands } = load();
|
|
178
|
+
const idx = groups.findIndex((g) => g.id === id);
|
|
179
|
+
if (idx < 0) return null;
|
|
180
|
+
const clean = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
|
181
|
+
let parentId = clean.parentId !== undefined ? clean.parentId : groups[idx].parentId;
|
|
182
|
+
// 防环:不能把分组移到自己的子孙下
|
|
183
|
+
if (parentId) {
|
|
184
|
+
const desc = new Set<string>();
|
|
185
|
+
const walk = (gid: string) => {
|
|
186
|
+
for (const g of groups) if (g.parentId === gid && !desc.has(g.id)) { desc.add(g.id); walk(g.id); }
|
|
187
|
+
};
|
|
188
|
+
walk(id);
|
|
189
|
+
if (desc.has(parentId)) parentId = groups[idx].parentId;
|
|
190
|
+
}
|
|
191
|
+
groups[idx] = { ...groups[idx], ...clean, id, parentId };
|
|
192
|
+
save(groups, commands);
|
|
193
|
+
return groups[idx];
|
|
194
|
+
}
|
|
195
|
+
/** 删除分组(级联:子孙分组一并删;其下命令移入未分组) */
|
|
196
|
+
function removeGroup(id: string): boolean {
|
|
197
|
+
const { groups, commands } = load();
|
|
198
|
+
if (!groups.some((g) => g.id === id)) return false;
|
|
199
|
+
const doomed = new Set<string>();
|
|
200
|
+
const walk = (gid: string) => {
|
|
201
|
+
for (const g of groups) if (g.parentId === gid && !doomed.has(g.id)) { doomed.add(g.id); walk(g.id); }
|
|
202
|
+
};
|
|
203
|
+
doomed.add(id);
|
|
204
|
+
walk(id);
|
|
205
|
+
const rest = groups.filter((g) => !doomed.has(g.id));
|
|
206
|
+
const nextCommands = commands.map((c) => (c.groupId && doomed.has(c.groupId) ? { ...c, groupId: null } : c));
|
|
207
|
+
save(rest, nextCommands);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 分组路径(用于 UI 显示/选择),如 "构建 / 前端" */
|
|
212
|
+
function groupPath(id: string | null | undefined): string {
|
|
213
|
+
if (!id) return "未分组";
|
|
214
|
+
const { groups } = load();
|
|
215
|
+
const parts: string[] = [];
|
|
216
|
+
let cur = groups.find((g) => g.id === id);
|
|
217
|
+
let guard = 0;
|
|
218
|
+
while (cur && guard++ < 20) {
|
|
219
|
+
parts.unshift(cur.name);
|
|
220
|
+
cur = cur.parentId ? groups.find((g) => g.id === cur!.parentId) : undefined;
|
|
221
|
+
}
|
|
222
|
+
return parts.join(" / ") || "未分组";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { list, get, create, update, touchRun, remove, listGroups, createGroup, updateGroup, removeGroup, groupPath };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export type CommandStore = ReturnType<typeof createStore>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aiti-cmd · 构建 client bundle(esbuild 打包 xterm.js + fit addon → dist/aiti-cmd-client.mjs)
|
|
4
|
+
*
|
|
5
|
+
* 用法:node tools/build-client.mjs
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { build } = require("esbuild");
|
|
11
|
+
|
|
12
|
+
// esbuild 从仓库根 node_modules 解析(node 向上查找)
|
|
13
|
+
function resolveEsbuild() {
|
|
14
|
+
try { return require("esbuild"); } catch {}
|
|
15
|
+
throw new Error("esbuild 未找到——请在仓库根 npm i -D esbuild");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
await resolveEsbuild().build({
|
|
19
|
+
entryPoints: ["client/src/index.js"],
|
|
20
|
+
bundle: true,
|
|
21
|
+
format: "esm",
|
|
22
|
+
platform: "browser",
|
|
23
|
+
target: ["es2020"],
|
|
24
|
+
loader: { ".css": "text" },
|
|
25
|
+
outfile: "dist/aiti-cmd-client.mjs",
|
|
26
|
+
logLevel: "warning",
|
|
27
|
+
banner: { js: "// aiti-cmd client bundle — built by tools/build-client.mjs (esbuild). Do not edit." },
|
|
28
|
+
});
|
|
29
|
+
console.log("[aiti-cmd] client bundle → dist/aiti-cmd-client.mjs");
|
|
30
|
+
|
|
31
|
+
// post-build patch:xterm 6.0.0 RenderService.dimensions getter 对 _renderer.value(Optional)
|
|
32
|
+
// 无空值防护——terminal dispose 后(renderer 已清)排在 RenderDebouncer 里的 rAF 回调
|
|
33
|
+
// 仍会执行 Viewport._sync → 读 _renderService.dimensions → 崩
|
|
34
|
+
// 「Cannot read properties of undefined (reading 'dimensions')」(上游 master 仍未修,无补丁版)。
|
|
35
|
+
// 修法:getter 加 ?.;Viewport _sync/_handleScroll 的 !this._renderService 守卫追加 .dimensions 判空。
|
|
36
|
+
// 属性名不被 esbuild 混淆,匹配对压缩产物稳定;替换次数断言保证上游变化时 fail loudly 不静默失效。
|
|
37
|
+
{
|
|
38
|
+
const outfile = "dist/aiti-cmd-client.mjs";
|
|
39
|
+
let s = readFileSync(outfile, "utf8");
|
|
40
|
+
const A = "return this._renderer.value.dimensions;";
|
|
41
|
+
const A2 = "return this._renderer.value?.dimensions;";
|
|
42
|
+
const B = "!this._renderService ||";
|
|
43
|
+
const B2 = "!this._renderService?.dimensions ||";
|
|
44
|
+
const cA = s.split(A).length - 1, cB = s.split(B).length - 1;
|
|
45
|
+
if (cA !== 1 || cB !== 2) {
|
|
46
|
+
console.error(`[aiti-cmd][patch] 匹配数不符:getter×${cA}(期望1) guard×${cB}(期望2)——xterm 上游形态变了,人工核对后调整 patch,产物保持未写`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
s = s.split(A).join(A2).split(B).join(B2);
|
|
50
|
+
writeFileSync(outfile, s);
|
|
51
|
+
console.log("[aiti-cmd][patch] xterm dimensions 空值防护已应用(getter×1, guard×2)");
|
|
52
|
+
}
|
package/tools/deploy.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aiti-cmd · 部署到 ~/.pi/web/extensions/ 与 ~/.pi/web-dev/extensions/(pi-web 双实例扩展目录)
|
|
4
|
+
*
|
|
5
|
+
* 步骤:
|
|
6
|
+
* 1) 构建 client bundle(tools/build-client.mjs,esbuild 打包 xterm.js)
|
|
7
|
+
* 2) 拷贝服务端源码(index/runner/store/recognize/audit)→ 两个扩展目录
|
|
8
|
+
* 3) 拷贝 client bundle → 两个扩展目录 client/
|
|
9
|
+
* 4) package.json(dependencies 声明 node-pty 等)拷入 + 清理扩展本地 node_modules 残留
|
|
10
|
+
* 5) 提交部署状态到扩展 git 仓库(~/.pi/web/extensions/)(失败不影响部署)
|
|
11
|
+
* 6) 通知双实例 extman reconcile 共享依赖 → <webRoot>/node_modules(aiti-extman ops)
|
|
12
|
+
* 7) macOS:node-pty spawn-helper 补可执行位(共享根/本地两处都探测)
|
|
13
|
+
* 8) 双实例热重载
|
|
14
|
+
*
|
|
15
|
+
* 用法:node tools/deploy.mjs
|
|
16
|
+
* 生效:POST /api/extensions/reload;client no-store 刷新页面即新
|
|
17
|
+
*/
|
|
18
|
+
import { copyFileSync, mkdirSync, existsSync, cpSync, chmodSync, readdirSync, rmSync } from "node:fs";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { execSync } from "node:child_process";
|
|
23
|
+
import { deployCommit } from "../../_shared/deploy-commit.mjs";
|
|
24
|
+
import { copyIcons } from "../../_shared/ext-icons-deploy.mjs";
|
|
25
|
+
import { notifyReconcile } from "../../_shared/reconcile-deps.mjs";
|
|
26
|
+
|
|
27
|
+
// client TS 残留检查(.mjs 里是合法 JS 语法检查不报、运行时必炸;FAIL 即部署失败)
|
|
28
|
+
{
|
|
29
|
+
const { checkClientDir } = await import("../../_shared/check-client.mjs");
|
|
30
|
+
checkClientDir(fileURLToPath(new URL("../client", import.meta.url)), { log: (m) => console.log("[deploy] " + m) });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const root = resolve(import.meta.dirname, ".."); // pi-web-ext/aiti-cmd
|
|
34
|
+
const targets = [
|
|
35
|
+
resolve(homedir(), ".pi", "web", "extensions", "aiti-cmd"),
|
|
36
|
+
resolve(homedir(), ".pi", "web-dev", "extensions", "aiti-cmd"),
|
|
37
|
+
];
|
|
38
|
+
const extRepo = resolve(targets[0], "..");
|
|
39
|
+
|
|
40
|
+
// 1) 构建 client
|
|
41
|
+
console.log("[deploy] 构建 client bundle…");
|
|
42
|
+
execSync("node " + resolve(root, "tools/build-client.mjs"), { cwd: root, stdio: "inherit" });
|
|
43
|
+
|
|
44
|
+
// 2) 服务端源码 + 3) client bundle(两个实例目录)
|
|
45
|
+
for (const extDir of targets) {
|
|
46
|
+
mkdirSync(extDir, { recursive: true });
|
|
47
|
+
for (const f of ["index.ts", "icon.svg", "runner.ts", "store.ts", "recognize.ts", "audit.ts", "pi-commands.ts"]) {
|
|
48
|
+
copyFileSync(resolve(root, f), resolve(extDir, f));
|
|
49
|
+
console.log("[deploy] 源码 → " + resolve(extDir, f));
|
|
50
|
+
}
|
|
51
|
+
const clientDir = resolve(extDir, "dist");
|
|
52
|
+
mkdirSync(clientDir, { recursive: true });
|
|
53
|
+
copyFileSync(resolve(root, "dist/aiti-cmd-client.mjs"), resolve(clientDir, "aiti-cmd-client.mjs"));
|
|
54
|
+
console.log("[deploy] client → " + resolve(clientDir, "aiti-cmd-client.mjs"));
|
|
55
|
+
// skills/ 帮助点目录(pi skills 标准:index.json + 每 skill 一个 SKILL.md)
|
|
56
|
+
const skillSrc = resolve(root, "skills");
|
|
57
|
+
if (existsSync(skillSrc)) {
|
|
58
|
+
const skillDest = resolve(extDir, "skills");
|
|
59
|
+
mkdirSync(skillDest, { recursive: true });
|
|
60
|
+
cpSync(skillSrc, skillDest, { recursive: true });
|
|
61
|
+
console.log("[deploy] skills → " + skillDest);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 4) 运行时依赖(node-pty / command-exists / shell-quote):package.json 一并部署(registry 读
|
|
66
|
+
// name/version/soul,dependencies 就是共享依赖的声明源)。依赖本体由 extman reconcile 统一装进
|
|
67
|
+
// <webRoot>/node_modules(jiti 从扩展目录向上解析自动命中,见步骤 6)——不再 npm i --prefix 装进
|
|
68
|
+
// 扩展目录(旧方式 --no-save 还漏声明 node-pty);本地 node_modules 残留每次部署清理,让解析稳定
|
|
69
|
+
// 走共享根。不能裸 npm i:部署目录若无 package.json,npm 会向上命中 ~/package.json「假成功」,
|
|
70
|
+
// 依赖全装进家目录 node_modules(历史踩坑)
|
|
71
|
+
for (const extDir of targets) {
|
|
72
|
+
copyFileSync(resolve(root, "package.json"), resolve(extDir, "package.json"));
|
|
73
|
+
const legacyNm = resolve(extDir, "node_modules");
|
|
74
|
+
if (existsSync(legacyNm)) {
|
|
75
|
+
rmSync(legacyNm, { recursive: true, force: true });
|
|
76
|
+
console.log("[deploy] 清理扩展本地 node_modules(依赖走共享根)→ " + legacyNm);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 5) 部署 commit(扩展 git 仓库;无变更跳过,失败不影响部署)
|
|
81
|
+
|
|
82
|
+
// 图标文件拷贝(icon: "icons/<name>.svg" → 宿主加载时内嵌;库在 _shared/icons/)
|
|
83
|
+
for (const t of targets) copyIcons(resolve(t, "icons"), ["terminal"], { log: (m2) => console.log(m2) });
|
|
84
|
+
|
|
85
|
+
deployCommit({ repo: extRepo, name: "aiti-cmd", paths: ["aiti-cmd"], ident: true, log: (m) => console.log("[deploy] " + m) });
|
|
86
|
+
|
|
87
|
+
// 6) 通知双实例 extman reconcile 共享依赖(依赖装进各自 <webRoot>/node_modules;await 等完成再 chmod/reload)。
|
|
88
|
+
// 允许失败:extman 未就绪/实例不可达仅提示,不影响部署退出码(下次安装时自动对齐)
|
|
89
|
+
await notifyReconcile();
|
|
90
|
+
|
|
91
|
+
// 7) macOS:node-pty prebuild 的 spawn-helper 必须带可执行位(解包/拷贝链路可能丢 +x,
|
|
92
|
+
// 丢失时 helper 起不来、所有 pty spawn 报 "posix_spawnp failed.");非 darwin prebuild 不存在则空转。
|
|
93
|
+
// 共享根(<webRoot>/node_modules,本部署刚 reconcile 出来)与扩展本地(残留兜底)两处都探测
|
|
94
|
+
for (const nm of [...targets, ...targets.map((t) => resolve(t, "..", ".."))].map((d) => resolve(d, "node_modules"))) {
|
|
95
|
+
const prebuilds = resolve(nm, "node-pty", "prebuilds");
|
|
96
|
+
if (!existsSync(prebuilds)) continue;
|
|
97
|
+
for (const d of readdirSync(prebuilds)) {
|
|
98
|
+
if (!d.startsWith("darwin-")) continue;
|
|
99
|
+
const helper = resolve(prebuilds, d, "spawn-helper");
|
|
100
|
+
if (existsSync(helper)) { chmodSync(helper, 0o755); console.log("[deploy] chmod +x " + helper); }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log("[deploy] 完成。生效:POST /api/extensions/reload(或重启 pi-web);client 刷新页面即新");
|
|
105
|
+
|
|
106
|
+
// ── 双实例热重载(basePath 双 env 按机器设值)——允许失败:服务未启动/共享模块缺失仅提示,不影响 deploy 退出码 ──
|
|
107
|
+
try {
|
|
108
|
+
const { reloadInstances } = await import("../../_shared/reload-instances.mjs");
|
|
109
|
+
await reloadInstances();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.log("[deploy] 自动 reload 未执行(" + String((e && e.message) || e) + ")— 需手动 POST /api/extensions/reload");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── dev-pack(方案 §3.2:源码+构建工具+AGENTS.md 直达部署目录,AI 可现场直改)——允许失败,不影响 deploy 退出码 ──
|
|
115
|
+
try {
|
|
116
|
+
const { packDevPack } = await import("../../_shared/dev-pack.mjs");
|
|
117
|
+
packDevPack({ extRoot: resolve(import.meta.dirname, ".."), log: (m) => console.log("[deploy] " + m) });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.log("[deploy] dev-pack 未执行(" + String((e && e.message) || e) + ")");
|
|
120
|
+
}
|