@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
package/recognize.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cmd · 命令识别(服务端)
|
|
3
|
+
*
|
|
4
|
+
* 现成方案优先(不造轮子):
|
|
5
|
+
* - 命令存在性:`command-exists`(跨平台 which/where,含 builtin)
|
|
6
|
+
* - shell 词法解析:`shell-quote`(正确处理引号/转义,不执行)
|
|
7
|
+
* - 安装建议:Linux 调 Ubuntu 原生 `/usr/lib/command-not-found`(查 apt/snap 数据库)
|
|
8
|
+
* 自制部分:词库(数据)、词库扫描正则(一次交替 matchAll)、危险检测、描述。
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { extractParams } from "./store.js";
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const commandExistsPkg: (name: string) => Promise<string | null> = require("command-exists");
|
|
18
|
+
const { parse: shellParse } = require("shell-quote") as { parse: (s: string) => unknown[] };
|
|
19
|
+
const cmdExists = (name: string) => Promise.resolve(commandExistsPkg(name)).then((p) => !!p).catch(() => false);
|
|
20
|
+
|
|
21
|
+
// ---------------- 内置常用命令词库(分门别类;desc = 分类描述) ----------------
|
|
22
|
+
export const COMMAND_DICT: Array<{ group: string; desc: string; names: string[]; phrases: string[] }> = [
|
|
23
|
+
{ group: "包管理", desc: "安装/运行/管理项目依赖(npm/yarn/pnpm/bun)", names: ["npm", "yarn", "pnpm", "bun", "npx", "node", "deno", "corepack", "npm-run", "pnpm-dlx"], phrases: ["npm run", "npm install", "npm i", "npm ci", "npm start", "npm test", "npm publish", "yarn add", "yarn install", "yarn dev", "yarn build", "pnpm install", "pnpm add", "pnpm dev", "pnpm build", "pnpm test", "pnpm run", "pnpm dlx", "bun install", "bun run", "bunx", "corepack enable", "npm exec"] },
|
|
24
|
+
{ group: "构建/前端", desc: "构建/测试/前端工具链(make/tsc/vite/jest)", names: ["make", "mvn", "gradle", "cargo", "go", "rustc", "tsc", "tsx", "vite", "next", "webpack", "eslint", "prettier", "jest", "vitest", "playwright", "cypress"], phrases: ["npm run build", "pnpm run build", "yarn build", "cargo build", "cargo run", "cargo test", "go build", "go run", "go test", "go mod tidy", "tsc --noEmit", "vite dev", "next dev", "next build", "eslint --fix", "prettier --write", "vitest run", "jest --watch"] },
|
|
25
|
+
{ group: "版本控制", desc: "代码版本管理(Git)", names: ["git", "gh"], phrases: ["git status", "git add", "git stash", "git diff", "git log", "git commit", "git push", "git pull", "git clone", "git branch", "git checkout", "git merge", "git rebase", "git reset", "git clean", "git fetch", "git rm", "git mv", "git stash pop", "git config", "git init", "git remote", "git tag", "git show", "git blame", "git cherry-pick", "git switch", "git restore", "git worktree", "gh pr", "gh repo", "gh issue"] },
|
|
26
|
+
{ group: "系统/文件", desc: "Linux 系统与文件操作", names: ["sudo", "su", "ls", "cd", "pwd", "cat", "tail", "head", "grep", "find", "ps", "kill", "pkill", "top", "htop", "free", "df", "du", "uptime", "uname", "whoami", "date", "sleep", "clear", "echo", "env", "export", "which", "type", "alias", "history", "touch", "mkdir", "cp", "mv", "rm", "chmod", "chown", "ln", "tar", "zip", "unzip", "tree", "wc", "sort", "uniq", "cut", "tr", "sed", "awk", "tee", "xargs", "watch", "less", "more", "stat", "file", "diff", "patch", "locate", "updatedb"], phrases: ["sudo apt", "sudo systemctl", "ls -la", "tail -f", "grep -r", "find .", "ps aux", "ps -ef", "df -h", "du -sh", "free -h", "chmod +x", "tar -xzf", "tar -czf", "unzip -o", "rm -rf", "mkdir -p", "cp -r", "mv -f", "chown -R", "ln -s", "wc -l", "sort -n", "uniq -c", "sed -i", "xargs -I", "watch -n", "kill -9"] },
|
|
27
|
+
{ group: "网络", desc: "网络诊断/传输(curl/ssh/rsync)", names: ["curl", "wget", "ping", "ip", "ss", "netstat", "ssh", "scp", "rsync", "nslookup", "dig", "traceroute", "nc", "telnet", "ufw", "iptables", "firewall-cmd"], phrases: ["curl -s", "curl -L", "wget -O", "ping -c", "ssh -L", "ssh -R", "scp -r", "rsync -av", "ip addr", "ip link", "ss -tlnp", "netstat -tlnp", "dig +short", "traceroute -n", "nc -l"] },
|
|
28
|
+
{ group: "容器/编排", desc: "容器与编排(Docker/K8s)", names: ["docker", "docker-compose", "kubectl", "helm", "podman", "minikube", "kind"], phrases: ["docker ps", "docker ps -a", "docker images", "docker run", "docker exec", "docker logs", "docker build", "docker pull", "docker push", "docker rm", "docker rmi", "docker-compose up", "docker-compose down", "docker-compose logs", "docker-compose build", "kubectl get", "kubectl logs", "kubectl describe", "kubectl apply", "kubectl delete", "kubectl exec", "kubectl port-forward", "helm install", "helm upgrade", "helm list"] },
|
|
29
|
+
{ group: "数据库", desc: "数据库客户端(MySQL/Postgres/Redis)", names: ["mysql", "psql", "sqlite3", "mongosh", "redis-cli"], phrases: ["mysql -u", "mysql -e", "psql -c", "psql -d", "sqlite3", "redis-cli", "mongosh"] },
|
|
30
|
+
{ group: "进程/后台/服务", desc: "进程管理/后台运行/系统服务", names: ["nohup", "setsid", "disown", "bg", "fg", "jobs", "wait", "killall", "pgrep", "strace", "ltrace", "gdb", "timeout", "nice", "renice", "pm2", "systemctl", "journalctl", "service", "daemonize", "taskset", "ulimit"], phrases: ["nohup ... &", "systemctl status", "systemctl start", "systemctl stop", "systemctl restart", "systemctl enable", "journalctl -f", "journalctl -u", "service restart", "service status", "pm2 start", "pm2 logs", "pm2 restart", "pm2 stop", "pm2 list", "strace -f", "gdb --batch", "timeout 10"] },
|
|
31
|
+
{ group: "云/基础设施", desc: "云平台与 IaC(AWS/GCP/Terraform)", names: ["gcloud", "aws", "az", "terraform"], phrases: ["gcloud compute", "aws s3", "aws ec2", "aws lambda", "terraform init", "terraform plan", "terraform apply", "az login", "az group"] },
|
|
32
|
+
{ group: "编辑器/查看", desc: "编辑器/文本处理/查看工具", names: ["vim", "nano", "code", "open", "xdg-open", "man", "rg", "ag", "jq", "yq"], phrases: ["code .", "code --goto", "jq .", "rg -n", "man ls"] },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// 扁平:命令名集合 + 多词短语(长优先)
|
|
36
|
+
const DICT_NAMES = new Set<string>();
|
|
37
|
+
const DICT_PHRASES: string[] = [];
|
|
38
|
+
for (const g of COMMAND_DICT) {
|
|
39
|
+
for (const n of g.names) DICT_NAMES.add(n);
|
|
40
|
+
for (const p of g.phrases) if (!DICT_PHRASES.includes(p)) DICT_PHRASES.push(p);
|
|
41
|
+
}
|
|
42
|
+
DICT_PHRASES.sort((a, b) => b.length - a.length);
|
|
43
|
+
|
|
44
|
+
// ---------------- 一次性交替正则(加载时构建,一次 matchAll 全命中) ----------------
|
|
45
|
+
function escRe(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
46
|
+
function buildMasterRegex(phrases: string[], names: string[]): RegExp {
|
|
47
|
+
const all = [...phrases, ...names].sort((a, b) => b.length - a.length);
|
|
48
|
+
return new RegExp("(^|[^\\w./-])(" + all.map(escRe).join("|") + ")(?=$|[^\\w./-])", "g");
|
|
49
|
+
}
|
|
50
|
+
const BUILTIN_MASTER_RE = buildMasterRegex(DICT_PHRASES, [...DICT_NAMES]);
|
|
51
|
+
const BUILTIN_PHRASE_SET = new Set(DICT_PHRASES);
|
|
52
|
+
let userReCache: { sig: string; re: RegExp | null } = { sig: "", re: null };
|
|
53
|
+
function getUserRegex(dict: UserDict): RegExp | null {
|
|
54
|
+
const sig = JSON.stringify(dict.extra);
|
|
55
|
+
if (userReCache.sig === sig) return userReCache.re;
|
|
56
|
+
const phrases: string[] = [];
|
|
57
|
+
const names: string[] = [];
|
|
58
|
+
for (const e of dict.extra) {
|
|
59
|
+
const parts = e.trim().split(/\s+/);
|
|
60
|
+
if (parts.length === 1) names.push(parts[0]);
|
|
61
|
+
else phrases.push(e.trim());
|
|
62
|
+
}
|
|
63
|
+
userReCache = { sig, re: phrases.length || names.length ? buildMasterRegex(phrases, names) : null };
|
|
64
|
+
return userReCache.re;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------- 用户自定义词库(data dir dict.json) ----------------
|
|
68
|
+
export interface UserDict { extra: string[] }
|
|
69
|
+
export function loadUserDict(configDir: string): UserDict {
|
|
70
|
+
try {
|
|
71
|
+
const f = path.join(configDir, "dict.json");
|
|
72
|
+
if (!fs.existsSync(f)) return { extra: [] };
|
|
73
|
+
const d = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
74
|
+
return { extra: Array.isArray(d.extra) ? d.extra.map(String).filter(Boolean) : [] };
|
|
75
|
+
} catch { return { extra: [] }; }
|
|
76
|
+
}
|
|
77
|
+
export function saveUserDict(configDir: string, extra: string[]): UserDict {
|
|
78
|
+
const f = path.join(configDir, "dict.json");
|
|
79
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
80
|
+
const clean = Array.from(new Set(extra.map((s) => String(s).trim()).filter(Boolean)));
|
|
81
|
+
fs.writeFileSync(f, JSON.stringify({ extra: clean }, null, 2) + "\n", { mode: 0o600 });
|
|
82
|
+
return { extra: clean };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------- 命令有效性校验(现成方案 command-exists:跨平台 which/where,含 builtin) ----------------
|
|
86
|
+
const existsCache = new Map<string, boolean>();
|
|
87
|
+
/** cwd 下 node_modules/.bin 兜底(command-exists 只查 PATH,不含项目本地 bin) */
|
|
88
|
+
function localBinLookup(first: string, cwd: string): boolean {
|
|
89
|
+
const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
90
|
+
for (const e of exts) {
|
|
91
|
+
try { fs.accessSync(path.join(cwd, "node_modules", ".bin", first + e), fs.constants.X_OK); return true; } catch {}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/** 校验命令存在(PATH via command-exists + cwd 相对路径 + 项目本地 bin),带缓存 */
|
|
96
|
+
export async function commandExists(name: string, cwd?: string): Promise<boolean | undefined> {
|
|
97
|
+
const base = String(name || "").trim();
|
|
98
|
+
if (!base) return undefined;
|
|
99
|
+
const first = base.split(/[\s;&|]+/)[0] || "";
|
|
100
|
+
if (!first) return undefined;
|
|
101
|
+
if (first.includes("/") || first.startsWith(".")) {
|
|
102
|
+
if (!cwd) return undefined;
|
|
103
|
+
try { fs.accessSync(path.resolve(cwd, first), fs.constants.X_OK); return true; } catch { return false; }
|
|
104
|
+
}
|
|
105
|
+
const key = first + "|" + (cwd || "");
|
|
106
|
+
if (existsCache.has(key)) return existsCache.get(key);
|
|
107
|
+
let ok = await cmdExists(first);
|
|
108
|
+
if (!ok && cwd) ok = localBinLookup(first, cwd);
|
|
109
|
+
existsCache.set(key, ok);
|
|
110
|
+
if (existsCache.size > 500) existsCache.clear();
|
|
111
|
+
return ok;
|
|
112
|
+
}
|
|
113
|
+
/** 批量校验(Promise.all + 缓存),供全文扫描一次收集 */
|
|
114
|
+
export async function verifyCommands(names: string[], cwd?: string): Promise<Map<string, boolean>> {
|
|
115
|
+
const uniq = [...new Set(names.map((n) => String(n || "").trim()).filter(Boolean))];
|
|
116
|
+
const out = new Map<string, boolean>();
|
|
117
|
+
const entries = await Promise.all(uniq.map(async (n) => [n, await commandExists(n, cwd)] as const));
|
|
118
|
+
for (const [n, ok] of entries) out.set(n, !!ok);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------- 危险模式 → confirm:true ----------------
|
|
123
|
+
const DANGER_PATTERNS: Array<[RegExp, string]> = [
|
|
124
|
+
[/rm\s+(-[a-z]*[rf][a-z]*\s+)*[\/~]?(?:\/|\.)\s*$/im, "rm 根目录/父级"],
|
|
125
|
+
[/rm\s+-[a-z]*rf/im, "rm -rf"],
|
|
126
|
+
[/mkfs\.|mkfs\s/im, "mkfs 格式化"],
|
|
127
|
+
[/dd\s+.*of=\/dev\//im, "dd 写块设备"],
|
|
128
|
+
[/>\s*\/dev\/sd[a-z]|>\s*\/dev\/nvm/im, "重定向到块设备"],
|
|
129
|
+
[/\bsudo\b/im, "sudo"],
|
|
130
|
+
[/curl\b.*\|\s*(?:ba)?sh\b/im, "curl|sh 管道执行"],
|
|
131
|
+
[/wget\b.*\|\s*(?:ba)?sh\b/im, "wget|sh 管道执行"],
|
|
132
|
+
[/chmod\s+[-+]?[0-7]{3,}\b/im, "chmod 权限变更"],
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export interface RecognizedCommand {
|
|
136
|
+
cmd: string;
|
|
137
|
+
danger: string[];
|
|
138
|
+
exists?: boolean;
|
|
139
|
+
params: string[];
|
|
140
|
+
label: string;
|
|
141
|
+
/** 未安装时的安装建议(command not found 结合) */
|
|
142
|
+
installSuggestion?: string;
|
|
143
|
+
/** cd xxx && … 前缀提取的工作目录(运行命令时作为 cwd) */
|
|
144
|
+
workdir?: string;
|
|
145
|
+
/** 去掉 cd xxx && 前缀后的实际命令(纯 cd 或非 cd 前缀时等于 cmd) */
|
|
146
|
+
action?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------- 安装建议(command not found 结合:Linux 原生工具优先) ----------------
|
|
150
|
+
const NPMISH = new Set(["npm", "yarn", "pnpm", "bun", "npx", "tsx", "vite", "next", "jest", "vitest", "eslint", "prettier", "playwright", "cypress", "corepack", "ts-node", "nodemon", "esbuild", "tailwindcss"]);
|
|
151
|
+
const IS_WIN = process.platform === "win32";
|
|
152
|
+
const IS_MAC = process.platform === "darwin";
|
|
153
|
+
export async function suggestInstall(cmd: string): Promise<string | null> {
|
|
154
|
+
const t = String(cmd || "").trim();
|
|
155
|
+
const first = (firstToken(t) || "").replace(/^\.\//, "");
|
|
156
|
+
if (!first) return null;
|
|
157
|
+
if (t.startsWith("./")) return "相对脚本:检查 cwd 下文件是否存在且可执行(chmod +x)";
|
|
158
|
+
// Linux:优先调 Ubuntu 原生 command-not-found(查 apt/snap 数据库,给准确包名/相近命令)
|
|
159
|
+
if (!IS_WIN && !IS_MAC) {
|
|
160
|
+
const native = nativeCommandNotFound(first);
|
|
161
|
+
if (native) return native;
|
|
162
|
+
}
|
|
163
|
+
const pkg = () => IS_WIN ? "winget install " + first : (IS_MAC ? "brew install " + first : "sudo apt install " + first);
|
|
164
|
+
if (NPMISH.has(first)) return "npm install -g " + first + "(或项目内 npm i -D " + first + ")";
|
|
165
|
+
for (const g of COMMAND_DICT) {
|
|
166
|
+
if (!g.names.includes(first)) continue;
|
|
167
|
+
if (g.group === "容器/编排") {
|
|
168
|
+
if (first === "docker") return "安装 Docker:https://docs.docker.com/engine/install/";
|
|
169
|
+
if (first === "kubectl") return "安装 kubectl:https://kubernetes.io/docs/tasks/tools/";
|
|
170
|
+
return pkg();
|
|
171
|
+
}
|
|
172
|
+
if (g.group === "云/基础设施") return first + ":按官方文档安装 CLI(gcloud/aws/az/terraform)";
|
|
173
|
+
if (g.group === "构建/前端") return "npm install -g " + first;
|
|
174
|
+
return pkg();
|
|
175
|
+
}
|
|
176
|
+
return "未找到安装建议:请确认该命令是否属于当前项目或需手动安装";
|
|
177
|
+
}
|
|
178
|
+
/** Linux 原生 command-not-found:查 apt/snap 数据库 → 准确安装包名/相近命令 */
|
|
179
|
+
let cnfCache = new Map<string, string>();
|
|
180
|
+
/** 异步 execFile(2026-09-12:command-not-found 5s 同步曾冻结事件循环;调用链 suggestInstall/checkCommand 均已 async 化) */
|
|
181
|
+
function cnfExec(first: string): Promise<string> {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
import("node:child_process").then(({ execFile }) => {
|
|
184
|
+
execFile("/usr/lib/command-not-found", [first], { encoding: "utf8", timeout: 5000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
185
|
+
resolve(String((err ? ((err as any).stdout || "") + ((err as any).stderr || "") : stdout) || ""));
|
|
186
|
+
});
|
|
187
|
+
}).catch(() => resolve(""));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async function nativeCommandNotFound(first: string): Promise<string | null> {
|
|
191
|
+
if (cnfCache.has(first)) return cnfCache.get(first) || null;
|
|
192
|
+
let text = "";
|
|
193
|
+
try {
|
|
194
|
+
text = await cnfExec(first);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
text = String((e as { stdout?: string; stderr?: string })?.stdout || "") + String((e as { stderr?: string })?.stderr || "");
|
|
197
|
+
}
|
|
198
|
+
if (!text.trim()) return null;
|
|
199
|
+
let out: string | null = null;
|
|
200
|
+
const install = text.match(/can be installed with:\s*([^\n]+)/i);
|
|
201
|
+
if (install) out = install[1].trim();
|
|
202
|
+
else {
|
|
203
|
+
const meant = text.match(/did you mean:\s*\n?\s*command '(\S+)'/i);
|
|
204
|
+
if (meant) out = "相近命令: " + meant[1];
|
|
205
|
+
else out = text.split("\n")[0].trim();
|
|
206
|
+
}
|
|
207
|
+
cnfCache.set(first, out || "");
|
|
208
|
+
if (cnfCache.size > 200) cnfCache.clear();
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------- 命令描述(管理面板识别:首命令 → 分类/描述/相关短语) ----------------
|
|
213
|
+
export interface CommandDescription {
|
|
214
|
+
found: boolean;
|
|
215
|
+
name?: string;
|
|
216
|
+
group?: string;
|
|
217
|
+
desc?: string;
|
|
218
|
+
related?: string[];
|
|
219
|
+
peers?: string[];
|
|
220
|
+
}
|
|
221
|
+
export function describeCommand(cmd: string): CommandDescription {
|
|
222
|
+
const t = String(cmd || "").trim();
|
|
223
|
+
const first = (firstToken(t) || "").replace(/^\.\//, "");
|
|
224
|
+
if (!first) return { found: false };
|
|
225
|
+
for (const g of COMMAND_DICT) {
|
|
226
|
+
if (g.names.includes(first)) {
|
|
227
|
+
return {
|
|
228
|
+
found: true,
|
|
229
|
+
name: first,
|
|
230
|
+
group: g.group,
|
|
231
|
+
desc: g.desc,
|
|
232
|
+
related: g.phrases.filter((p) => p.split(/\s+/)[0] === first).slice(0, 12),
|
|
233
|
+
peers: g.names.filter((n) => n !== first).slice(0, 12),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { found: false };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------- 工具函数 ----------------
|
|
241
|
+
/** shell 词法解析取首 token(shell-quote:正确处理引号/转义,不执行) */
|
|
242
|
+
export function firstToken(cmd: string): string | null {
|
|
243
|
+
const t = String(cmd || "").trim();
|
|
244
|
+
if (!t) return null;
|
|
245
|
+
try {
|
|
246
|
+
const toks = shellParse(t);
|
|
247
|
+
const first = toks[0];
|
|
248
|
+
if (typeof first === "string" && first) return first;
|
|
249
|
+
return null;
|
|
250
|
+
} catch { return null; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 判断一段文本是否像 shell 命令(行首/整行口径) */
|
|
254
|
+
export function looksLikeCommand(text: string): boolean {
|
|
255
|
+
const t = String(text || "").trim();
|
|
256
|
+
if (!t || t.length > 500) return false;
|
|
257
|
+
if (/[。!?!?,,]$/u.test(t)) return false;
|
|
258
|
+
if (/\p{Script=Han}/u.test(t)) {
|
|
259
|
+
const first = t.split(/\s+/)[0] || "";
|
|
260
|
+
if (!/^[\w./~-]+$/.test(first)) return false;
|
|
261
|
+
// 中文说明句(命令词开头的解释,如 "pnpm install 是安装依赖的命令")排除
|
|
262
|
+
if (/^[\w./~-]+(?:\s+\S+){0,3}\s+\S*[是的了]/.test(t)) return false;
|
|
263
|
+
}
|
|
264
|
+
if (/^\$\s+\S/.test(t)) return true;
|
|
265
|
+
const first = firstToken(t);
|
|
266
|
+
if (!first) return false;
|
|
267
|
+
if (first.startsWith("./")) return true;
|
|
268
|
+
return DICT_NAMES.has(first.replace(/^\.\//, ""));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 常见参数词(命令后跟的"参数样式"词:子命令/环境/动作);不在表内且非 flag/路径/数字的裸英文词视为叙述词 → 停
|
|
272
|
+
const PARAM_WORDS = new Set([
|
|
273
|
+
"dev", "build", "test", "start", "stop", "restart", "status", "logs", "up", "down", "run", "watch", "fix", "install", "add", "commit", "push", "pull", "fetch", "merge", "rebase", "check", "list", "get", "set", "rm", "mv", "cp", "edit", "new", "open", "clean", "init", "reset", "clone", "branch", "tag", "stash", "diff", "log", "show", "remote", "config", "stage", "publish", "version", "ci", "release", "preview", "production", "staging", "main", "develop", "origin", "upstream", "force", "verbose", "global", "local", "recursive", "interactive", "quiet", "all", "latest", "next", "prod", "default",
|
|
274
|
+
// K8s/Docker 资源
|
|
275
|
+
"pods", "pod", "deployments", "deployment", "services", "service", "containers", "container", "images", "image", "namespaces", "namespace", "configmaps", "configmap", "secrets", "secret", "volumes", "volume", "replicasets", "jobs", "cronjobs", "statefulsets", "daemonsets", "nodes", "node",
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
// ---------------- 贪婪尾参数:这些短语后跟的几乎必然是操作对象名(systemd 单元 / git 分支 / 容器 / 包名) ----------------
|
|
279
|
+
// 默认 isArgToken 只认 flag/路径/数字/已知参数词/命令名——裸操作对象(如 "systemctl restart pi-web" 的 pi-web、
|
|
280
|
+
// "git merge main" 的 main 之类普通词)会被误当叙述词停掉。这些短语的下一 token 放宽:形态为单词(\w./-)即截取;
|
|
281
|
+
// 但常见英文功能词(the/to/and…)仍停止,避免英文叙述把命令带歪。
|
|
282
|
+
const GREEDY_TAILS = new Set([
|
|
283
|
+
// systemd / 服务管理(下一 token = 单元/服务名)
|
|
284
|
+
"sudo systemctl", "systemctl status", "systemctl start", "systemctl stop", "systemctl restart", "systemctl enable", "systemctl disable", "systemctl reload", "systemctl is-active", "systemctl mask", "systemctl unmask",
|
|
285
|
+
"service start", "service stop", "service restart", "service status",
|
|
286
|
+
"pm2 start", "pm2 stop", "pm2 restart", "pm2 delete", "pm2 logs", "pm2 describe", "pm2 show",
|
|
287
|
+
// git(下一 token = 分支/目标)
|
|
288
|
+
"git checkout", "git switch", "git merge", "git rebase", "git cherry-pick", "git show", "git blame", "git tag", "git restore",
|
|
289
|
+
// docker / k8s / helm(下一 token = 容器/资源名)
|
|
290
|
+
"docker run", "docker exec", "docker logs", "docker stop", "docker start", "docker restart", "docker rm", "docker attach",
|
|
291
|
+
"kubectl logs", "kubectl describe", "kubectl delete", "kubectl exec", "kubectl get", "kubectl apply", "kubectl port-forward", "kubectl rollout",
|
|
292
|
+
"helm install", "helm upgrade", "helm uninstall",
|
|
293
|
+
// 包管理(下一 token = 包名)
|
|
294
|
+
"npm install", "npm i", "npm ci", "yarn add", "yarn install", "pnpm add", "pnpm install", "bun install", "bun add", "apt install", "apt-get install", "brew install", "sudo apt",
|
|
295
|
+
// 命令链常用(下一 token = 操作对象,裸词也截取):
|
|
296
|
+
// cd(路径 → workdir 提取)、git 子命令(文件/分支/远程)、npm/pnpm/yarn/bun run(脚本名)
|
|
297
|
+
"cd",
|
|
298
|
+
"git add", "git rm", "git mv", "git stash", "git commit", "git push", "git pull", "git fetch", "git log", "git diff", "git status", "git clone", "git remote", "git config", "git init", "git branch", "git reset", "git clean", "git blame", "git cherry-pick",
|
|
299
|
+
"npm run", "npm exec", "npx", "pnpm run", "pnpm dlx", "yarn run", "bun run", "bunx",
|
|
300
|
+
"docker build", "docker compose", "kubectl port-forward", "kubectl rollout",
|
|
301
|
+
]);
|
|
302
|
+
const GREEDY_STOP = new Set("the a an to of and or in on for with from by is are was be it this that then now after before when while but so if as at we you they i he she".split(" "));
|
|
303
|
+
/** 参数样式判定:flag(-x/--xx) / 路径(含 / . ~ =) / 纯数字 / 已知参数词 / 词库命令名(nohup pnpm dev)→ 可截;否则停(叙述词) */
|
|
304
|
+
function isArgToken(tok: string): boolean {
|
|
305
|
+
if (!tok) return false;
|
|
306
|
+
if (tok.startsWith("-")) return true; // flag
|
|
307
|
+
if (/[\/\\~=]/.test(tok) || /\.\w+$/.test(tok)) return true; // 路径/键值/带扩展名
|
|
308
|
+
if (/^\d+$/.test(tok)) return true; // 数字(端口/数量)
|
|
309
|
+
if (PARAM_WORDS.has(tok.toLowerCase())) return true;
|
|
310
|
+
return DICT_NAMES.has(tok); // 命令名作参数(nohup pnpm dev / docker run node)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------- 命令链贪婪捕获(&& || | ; & 后继续吞,组成完整命令) ----------------
|
|
314
|
+
// 不包含 > < 重定向(重定向后是文件不是命令,吞入会误把文件名当命令)。
|
|
315
|
+
const CHAIN_CONNECTOR = /^\s*(?:&&|\|\||[|;&])\s*/;
|
|
316
|
+
const ARG_TOKEN = "[\\w./~{}-]+";
|
|
317
|
+
const argRe = new RegExp("^\\s+(" + ARG_TOKEN + ")(?=\\s|$|[,。;!?,;!?])");
|
|
318
|
+
const QUOTED_ARG = /^\s*(?:"([^"]*)"|'([^']*)')/;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 贪婪命令链捕获:从命中词开始向后吞参数(flag/路径/数字/已知参数词/词库命令/引号串),
|
|
322
|
+
* 遇连接符(&& || | ; &)且下一词形似命令时继续吞下一段(≤4 段 ≤8 参/段 ≤300 字符)。
|
|
323
|
+
* 返回整条命令链字符串(与原文子串基本对齐,hover 高亮可用)。
|
|
324
|
+
*/
|
|
325
|
+
function captureChain(t: string, idx: number, cmd: string): string {
|
|
326
|
+
const parts = [cmd];
|
|
327
|
+
let rest = t.slice(idx + cmd.length);
|
|
328
|
+
let greedy = GREEDY_TAILS.has(cmd);
|
|
329
|
+
for (let seg = 0; seg < 4; seg++) {
|
|
330
|
+
let n = 0;
|
|
331
|
+
while (n < 8) {
|
|
332
|
+
const qm = rest.match(QUOTED_ARG);
|
|
333
|
+
if (qm) { parts.push(qm[0].trim()); rest = rest.slice(qm[0].length); n++; continue; } // 引号原样保留(hover 高亮与原文对齐)
|
|
334
|
+
const am = rest.match(argRe);
|
|
335
|
+
if (!am) break;
|
|
336
|
+
if (greedy && !GREEDY_STOP.has(am[1].toLowerCase())) {
|
|
337
|
+
parts.push(am[1]); rest = rest.slice(am[0].length); n++; continue;
|
|
338
|
+
}
|
|
339
|
+
if (!isArgToken(am[1])) break;
|
|
340
|
+
parts.push(am[1]); rest = rest.slice(am[0].length); n++;
|
|
341
|
+
}
|
|
342
|
+
// 连接符后接命令词才继续;词必须是词库命中 / 相对脚本(./x)/ 含路径层级,非功能词
|
|
343
|
+
const cm = rest.match(CHAIN_CONNECTOR);
|
|
344
|
+
if (!cm) break;
|
|
345
|
+
const nextRest = rest.slice(cm[0].length);
|
|
346
|
+
const nm = nextRest.match(/^([\w./~-]+)/);
|
|
347
|
+
if (!nm) break;
|
|
348
|
+
const nextWord = nm[1];
|
|
349
|
+
if (GREEDY_STOP.has(nextWord.toLowerCase())) break;
|
|
350
|
+
const nextFirst = nextWord.replace(/^\.\//, "").split("/")[0] || nextWord;
|
|
351
|
+
const plausible = DICT_NAMES.has(nextFirst) || nextWord.startsWith("./") || (nextWord.includes("/") && nextWord.split("/").length <= 3);
|
|
352
|
+
if (!plausible) break;
|
|
353
|
+
parts.push(cm[0].trim()); // 保留连接符(&&/|/;…),链文本与原文对齐(hover 高亮可精确匹配)
|
|
354
|
+
parts.push(nextWord);
|
|
355
|
+
rest = nextRest.slice(nextWord.length);
|
|
356
|
+
// 连接符后已是命令语境(词已通过命令词校验):后续段贪婪吞裸操作对象(如 systemctl restart pi-web 的 pi-web)
|
|
357
|
+
greedy = true;
|
|
358
|
+
}
|
|
359
|
+
let full = parts.join(" ");
|
|
360
|
+
if (full.length > 300) full = full.slice(0, 300).replace(/(?:&&|\|\||[|;&])\s*$/, "");
|
|
361
|
+
return full;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 命令链各段首命令词(去重,保留顺序);"cd x && npm i" → ["cd","npm"] */
|
|
365
|
+
export function chainCommandWords(cmd: string): string[] {
|
|
366
|
+
const segs = String(cmd || "").split(/\s*(?:&&|\|\||[|;&])\s*/).filter(Boolean);
|
|
367
|
+
const out: string[] = [];
|
|
368
|
+
for (const s of segs) {
|
|
369
|
+
const f = firstToken(s);
|
|
370
|
+
if (f && !out.includes(f)) out.push(f);
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 命令链整体存在性:全部段命令存在 → true;任一缺失 → false;有未知 → undefined */
|
|
376
|
+
async function chainExistence(cmd: string, cwd?: string): Promise<{ words: string[]; exists: boolean | undefined }> {
|
|
377
|
+
const words = chainCommandWords(cmd);
|
|
378
|
+
if (!words.length) return { words, exists: undefined };
|
|
379
|
+
const results = await verifyCommands(words, cwd);
|
|
380
|
+
const vals = words.map((w) => results.get(w));
|
|
381
|
+
let exists: boolean | undefined;
|
|
382
|
+
if (vals.every((v) => v === true)) exists = true;
|
|
383
|
+
else if (vals.some((v) => v === false)) exists = false;
|
|
384
|
+
return { words, exists };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** cd 前缀拆分:"cd /a && npm i" → { workdir:"/a", action:"npm i" };纯 "cd /a" 无后续动作不提取 */
|
|
388
|
+
export function splitCdPrefix(cmd: string): { workdir?: string; action?: string } {
|
|
389
|
+
const t = String(cmd || "").trim();
|
|
390
|
+
const m = t.match(/^cd\s+(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))(?:\s*(?:&&|\|\||[|;&])\s*)?(.*)$/);
|
|
391
|
+
if (!m) return {};
|
|
392
|
+
const workdir = (m[1] ?? m[2] ?? m[3] ?? "").trim();
|
|
393
|
+
const action = m[4]?.trim();
|
|
394
|
+
if (!action || !workdir || workdir === ".") return {};
|
|
395
|
+
return { workdir, action };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* workdir 解析为绝对路径:~ → HOME;绝对路径原样;相对路径结合会话上下文 cwd 解析。
|
|
400
|
+
* cwd 缺省(无上下文,如行内 code 场景)→ 保持相对路径原样,不臆造基准。
|
|
401
|
+
*/
|
|
402
|
+
export function resolveWorkdir(workdir: string | undefined, cwd?: string): string | undefined {
|
|
403
|
+
if (!workdir) return undefined;
|
|
404
|
+
const w = String(workdir).trim();
|
|
405
|
+
if (!w) return undefined;
|
|
406
|
+
const home = os.homedir();
|
|
407
|
+
if (w === "~") return home;
|
|
408
|
+
if (w.startsWith("~/")) return path.join(home, w.slice(2));
|
|
409
|
+
if (path.isAbsolute(w)) return w;
|
|
410
|
+
if (cwd) return path.resolve(cwd, w);
|
|
411
|
+
return w;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function checkCommand(cmd: string, cwd?: string): Promise<RecognizedCommand> {
|
|
415
|
+
const t = String(cmd || "").trim();
|
|
416
|
+
const danger = DANGER_PATTERNS.filter(([re]) => re.test(t)).map(([, label]) => label);
|
|
417
|
+
const params = extractParams(t);
|
|
418
|
+
const { workdir: rawWd, action } = splitCdPrefix(t);
|
|
419
|
+
// workdir 结合会话上下文 cwd 解析为绝对路径(cd xxx 的 xxx 相对当前会话目录)
|
|
420
|
+
const workdir = resolveWorkdir(rawWd, cwd);
|
|
421
|
+
let exists: boolean | undefined;
|
|
422
|
+
let installSuggestion: string | undefined;
|
|
423
|
+
const { words, exists: chainExists } = await chainExistence(t, cwd);
|
|
424
|
+
exists = chainExists;
|
|
425
|
+
// 安装建议:第一个缺失命令词(cd 等 builtin 恒存在会自然跳过)
|
|
426
|
+
if (chainExists === false && words.length) {
|
|
427
|
+
for (const w of words) {
|
|
428
|
+
const ok = await commandExists(w, cwd);
|
|
429
|
+
if (ok === false) { installSuggestion = await suggestInstall(w); break; }
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const label = t.length > 60 ? t.slice(0, 60) + "…" : t;
|
|
433
|
+
return { cmd: t, danger, exists, params, label, installSuggestion, workdir, action };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 裸命令上下文判定:命中词在原文中是否处于"命令语境"(行首 / $ 提示符 / ;&| 命令链边界)。
|
|
438
|
+
* 仅单命令名且未截到参数时使用——不在命令语境则视为普通词(如页面元素信息 "<button> + file" 里的 file),排除。
|
|
439
|
+
*/
|
|
440
|
+
export function isCommandContext(text: string, idx: number): boolean {
|
|
441
|
+
let i = idx - 1;
|
|
442
|
+
// 只跳空格/tab/全角空格——换行是行首边界,不能跳过(否则"a\nb"里 b 的前置字符变成 a,误判非命令语境)
|
|
443
|
+
while (i >= 0 && /[ \t\u3000]/.test(text[i])) i--;
|
|
444
|
+
if (i < 0) return true; // 文本开头
|
|
445
|
+
const c = text[i];
|
|
446
|
+
if (c === "\n") return true; // 行首
|
|
447
|
+
if (c === "$") { // $ 提示符("$ file");排除变量引用("echo $file" 中 $ 前有非空白)
|
|
448
|
+
let j = i - 1;
|
|
449
|
+
while (j >= 0 && /[ \t\u3000]/.test(text[j])) j--;
|
|
450
|
+
return j < 0 || text[j] === "\n";
|
|
451
|
+
}
|
|
452
|
+
return ";&|".includes(c); // 命令链边界(; && || | &)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 词库全文扫描:从段落文本提取所有命中命令(一次交替正则 matchAll → 批量校验)。
|
|
457
|
+
*/
|
|
458
|
+
export async function extractCommandsFromText(configDir: string, text: string, cwd?: string): Promise<RecognizedCommand[]> {
|
|
459
|
+
const t = String(text || "").slice(0, 50000);
|
|
460
|
+
const userDict = loadUserDict(configDir);
|
|
461
|
+
|
|
462
|
+
// 一次 matchAll 扫描(内置主正则 + 用户词库正则,均为加载/变更时构建)
|
|
463
|
+
const hits = new Map<string, number>(); // 词 -> 起始位置
|
|
464
|
+
const collect = (re: RegExp) => {
|
|
465
|
+
for (const m of t.matchAll(re)) {
|
|
466
|
+
const word = m[2];
|
|
467
|
+
const start = m.index + (m[1] ? m[1].length : 0);
|
|
468
|
+
if (!hits.has(word)) hits.set(word, start);
|
|
469
|
+
if (hits.size >= 12) break;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
collect(BUILTIN_MASTER_RE);
|
|
473
|
+
const userRe = getUserRegex(userDict);
|
|
474
|
+
if (userRe) collect(userRe);
|
|
475
|
+
|
|
476
|
+
// 预批量校验:command-exists 填缓存(checkCommand 复用)
|
|
477
|
+
const tokens = [...new Set(hits.keys().map((w) => firstToken(w) || "").filter(Boolean))];
|
|
478
|
+
if (tokens.length) await verifyCommands(tokens, cwd);
|
|
479
|
+
|
|
480
|
+
// 贪婪命令链捕获:命中词 → 吞参数(flag/路径/数字/已知参数词/引号串)→
|
|
481
|
+
// 遇连接符(&& || | ; &)且下一词形似命令则继续吞下一段;
|
|
482
|
+
// 普通英文叙述词(to/and/stage 等)与中文词同样自动停——英文文本不会把叙述词带进命令
|
|
483
|
+
const results: RecognizedCommand[] = [];
|
|
484
|
+
const seen = new Set<string>();
|
|
485
|
+
for (const [cmd, idx] of hits) {
|
|
486
|
+
const full = captureChain(t, idx, cmd);
|
|
487
|
+
if (seen.has(full)) continue;
|
|
488
|
+
seen.add(full);
|
|
489
|
+
|
|
490
|
+
// 裸命令排除:单命令名命中且未截到任何参数/连接符,且不在命令语境(行首 / $ 提示符 / ;&| 命令链)
|
|
491
|
+
// ——如页面元素信息 "<button> + file" 里的 file 只是普通词,识别为命令无意义
|
|
492
|
+
if (!full.includes(" ") && !isCommandContext(t, idx)) continue;
|
|
493
|
+
|
|
494
|
+
const c = await checkCommand(full, cwd);
|
|
495
|
+
// 未安装(command not found)不过滤——保留并带 ❌ 标记 + 安装建议
|
|
496
|
+
results.push(c);
|
|
497
|
+
}
|
|
498
|
+
// 重叠去重:长命令优先("nohup pnpm dev" 覆盖 "pnpm dev")
|
|
499
|
+
results.sort((a, b) => b.cmd.length - a.cmd.length);
|
|
500
|
+
const kept: RecognizedCommand[] = [];
|
|
501
|
+
for (const r of results) {
|
|
502
|
+
const sub = kept.some((k) => k.cmd.includes(r.cmd) && k.cmd !== r.cmd);
|
|
503
|
+
if (!sub) kept.push(r);
|
|
504
|
+
}
|
|
505
|
+
// 按原文出现顺序(去重后)
|
|
506
|
+
kept.sort((a, b) => t.indexOf(a.cmd) - t.indexOf(b.cmd));
|
|
507
|
+
return kept.slice(0, 8);
|
|
508
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _shared/reconcile-deps.mjs · 通知 pi-web 双实例对齐扩展共享依赖(各扩展 deploy.mjs 共用)
|
|
3
|
+
*
|
|
4
|
+
* 背景:扩展运行时依赖统一装进共享根 <webRoot>/node_modules(jiti 从扩展目录向上解析
|
|
5
|
+
* 自动命中;机制仿 pi 本体 npm: 包安装)。共享根的唯一写入方是 aiti-extman 的
|
|
6
|
+
* shared-deps reconcile——deploy.mjs 拷完文件后 POST 本通知即可,不在部署脚本里跑 npm。
|
|
7
|
+
*
|
|
8
|
+
* 用法(与 reload-instances 同款:允许失败,永不 throw、不影响 deploy 退出码):
|
|
9
|
+
* import { notifyReconcile } from "../../_shared/reconcile-deps.mjs";
|
|
10
|
+
* await notifyReconcile(); // 默认 "[deploy] " 前缀;建议在 reloadInstances() 之前调
|
|
11
|
+
*
|
|
12
|
+
* 失败无害:extman 在下一次 install/remove/reconcile-deps 时会自愈对齐;双实例各自
|
|
13
|
+
* reconcile 各自的 webRoot(~/.pi/web 与 ~/.pi/web-dev),互不相干。
|
|
14
|
+
*/
|
|
15
|
+
import { buildInstances } from "./reload-instances.mjs";
|
|
16
|
+
|
|
17
|
+
export async function notifyReconcile(prefix = "[deploy] ") {
|
|
18
|
+
for (const inst of buildInstances()) {
|
|
19
|
+
if (inst.base === null) {
|
|
20
|
+
console.log(`${prefix}reconcile ${inst.name} 跳过:basePath 非法`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(inst.base + "/api/extensions/aiti-extman/ops", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ action: "reconcile-deps", source: "deploy" }),
|
|
28
|
+
});
|
|
29
|
+
const body = await r.json().catch(() => null);
|
|
30
|
+
const err = body && body.deps && body.deps.error;
|
|
31
|
+
if (r.ok && body && body.ok) {
|
|
32
|
+
console.log(`${prefix}reconcile ${inst.name} → ✓(${Object.keys(body.deps?.deps || {}).length} 个共享依赖)`);
|
|
33
|
+
} else {
|
|
34
|
+
console.log(`${prefix}reconcile ${inst.name} → HTTP ${r.status} ⚠${err ? " " + err : "(extman 未就绪?下次安装时自动对齐)"}`);
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
const cause = e && e.cause ? String(e.cause.code || e.cause.message || e.cause) : String((e && e.message) || e);
|
|
38
|
+
console.log(`${prefix}reconcile ${inst.name} 不可达(${cause})— 下次安装时自动对齐`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _shared/reload-instances.mjs · pi-web 双实例热重载(各扩展 deploy.mjs 共用)
|
|
3
|
+
*
|
|
4
|
+
* dev 3001 / 生产 3002;端口可经 AITI_DEV_PORT / AITI_PROD_PORT 覆盖;
|
|
5
|
+
* basePath 双 env(AITI_DEV_BASE_PATH / AITI_PROD_BASE_PATH)按机器设值,
|
|
6
|
+
* 推荐值不带斜杠(48:dev 与 空;155:系统级 wdev 与 win)——git-bash 启动
|
|
7
|
+
* 原生程序时会把 / 开头的 env 值做 MSYS 路径转换(/wdev → D:/apps/Git/wdev)
|
|
8
|
+
* 致 URL 非法;不带斜杠则不触发转换。此处自动补 /(旧 /xxx 格式仍兼容),
|
|
9
|
+
* 兼容不了时提示 MSYS_NO_PATHCONV=1。
|
|
10
|
+
* 避免「server 端代码改了、实例没 reload 导致跑旧逻辑」。
|
|
11
|
+
*
|
|
12
|
+
* 用法(静态 import 或顶层 await import 均可):
|
|
13
|
+
* import { reloadInstances } from "../../_shared/reload-instances.mjs";
|
|
14
|
+
* await reloadInstances(); // 默认 "[deploy] " 前缀
|
|
15
|
+
*/
|
|
16
|
+
const BASE_PATH_RE = /^\/[A-Za-z0-9._-]*$/;
|
|
17
|
+
|
|
18
|
+
function buildInstance(name, portEnv, portDefault, pathEnv, pathDefault) {
|
|
19
|
+
const origin = `http://127.0.0.1:${process.env[portEnv] || portDefault}`;
|
|
20
|
+
const raw = process.env[pathEnv];
|
|
21
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") return { name, base: origin + pathDefault };
|
|
22
|
+
// 自动补 /:wdev → /wdev(推荐无斜杠格式,MSYS 不转换);/wdev → /wdev(旧格式兼容)
|
|
23
|
+
const p = "/" + String(raw).trim().replace(/^\/+|\/+$/g, "");
|
|
24
|
+
if (BASE_PATH_RE.test(p)) return { name, base: origin + p };
|
|
25
|
+
return { name, base: null, raw };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 双实例清单(dev/prod 各 {name, base});reload-instances 与 reconcile-deps 等共用。 */
|
|
29
|
+
export function buildInstances() {
|
|
30
|
+
return [
|
|
31
|
+
buildInstance("dev", "AITI_DEV_PORT", 3001, "AITI_DEV_BASE_PATH", "/dev"),
|
|
32
|
+
buildInstance("prod", "AITI_PROD_PORT", 3002, "AITI_PROD_BASE_PATH", ""),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function reloadInstances(prefix = "[deploy] ") {
|
|
37
|
+
// 批次合并(deploy-all 场景):子条目部署脚本被注入 PI_WEB_DEFER_RELOAD=1 时不各自 reload,
|
|
38
|
+
// 由 deploy-all 在全部条目完成后统一执行一轮(N 条目 × 2 实例 → 2 次 POST)。
|
|
39
|
+
// 避免批量部署时的 reload 风暴(2026-09-15 实测:65 条目逐个 reload 把 dev 实例打满)。
|
|
40
|
+
// 单独跑某个扩展 deploy.mjs 不带该 env → 行为不变(部署完立即 reload)。
|
|
41
|
+
if (process.env.PI_WEB_DEFER_RELOAD === "1") {
|
|
42
|
+
console.log(`${prefix}reload 已 defer(PI_WEB_DEFER_RELOAD=1,由 deploy-all 收尾统一执行)`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// 允许失败:网络异常/非 2xx/服务未启动/env 非法一律只打提示,永不 throw——不影响 deploy 退出码
|
|
46
|
+
for (const inst of buildInstances()) {
|
|
47
|
+
if (inst.base === null) {
|
|
48
|
+
const msysHint = /[\\:]/.test(inst.raw) ? "——疑似 git-bash MSYS 路径转换,用 MSYS_NO_PATHCONV=1 前缀重跑" : "";
|
|
49
|
+
console.log(`${prefix}reload ${inst.name} 跳过:AITI_${inst.name.toUpperCase()}_BASE_PATH="${inst.raw}" 非法(须形如 /xxx 或留空)${msysHint}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch(inst.base + "/api/extensions/reload", { method: "POST" });
|
|
54
|
+
console.log(`${prefix}reload ${inst.name} → HTTP ${r.status} ${r.ok ? "✓" : "⚠ 失败(请手动 POST /api/extensions/reload)"}`);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const cause = e && e.cause ? String(e.cause.code || e.cause.message || e.cause) : String((e && e.message) || e);
|
|
57
|
+
console.log(`${prefix}reload ${inst.name} 不可达(${cause})— 需手动 POST /api/extensions/reload`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|