@aiti-ai/aiti-folder 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.
- package/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/check-client.mjs +273 -0
- package/client/src/comp-i18n.ts +144 -0
- package/client/src/directory-browser.tsx +58 -0
- package/client/src/entry.tsx +82 -0
- package/client/src/folder-actions.ts +13 -0
- package/client/src/folder-browser.tsx +1568 -0
- package/client/src/folder-dock.test.mjs +241 -0
- package/client/src/folder-dock.tsx +646 -0
- package/client/src/folder-switcher.tsx +212 -0
- package/client/src/git-plan.tsx +282 -0
- package/client/src/git-toolbar.tsx +382 -0
- package/client/src/host-shim.ts +126 -0
- package/client/src/icons.tsx +135 -0
- package/client/src/jsx-runtime.ts +2 -0
- package/client/src/lib.ts +148 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-folder-client.mjs +4 -0
- package/dist/entry-e6L3SQB9.js +2411 -0
- package/dist/folder-browser-dlVFbFla.js +3830 -0
- package/icon.svg +1 -0
- package/index.ts +827 -0
- package/package.json +28 -4
- package/reload-instances.mjs +60 -0
- package/server/git-plan.test.mjs +62 -0
- package/server/git-plan.ts +196 -0
- package/tools/build-client.mjs +27 -0
- package/tools/deploy.mjs +100 -0
- package/vite.config.ts +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiti-ai/aiti-folder",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "pi-web 文件夹浏览器扩展(fileViewer folder 接管 + pathPicker 目录选择器)—— JSX + vite 构建 client bundle",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build:client": "vite build --config vite.config.ts",
|
|
8
|
+
"watch": "vite build --watch --config vite.config.ts",
|
|
9
|
+
"deploy": "node tools/deploy.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"trash": "^10.1.1"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"vite": "^6.4.3"
|
|
16
|
+
},
|
|
17
|
+
"soul": "目录树不是导航的终点,是操作的起点。",
|
|
18
|
+
"aitiExtProtocol": "1.0",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"contentHash": "e8d68bdbd51a25e2",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/aiti-ai/aiti-ai.git",
|
|
24
|
+
"directory": "extensions/aiti-folder"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/aiti-ai/aiti-ai#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/aiti-ai/aiti-ai/issues"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-plan · 启发式分组/偏好学习/plan CRUD 测试(git-ops M4)。
|
|
3
|
+
*/
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtempSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { groupHeuristic, learn, loadPlan, savePlan, newPlan } from "./git-plan.ts";
|
|
10
|
+
|
|
11
|
+
const stateDir = mkdtempSync(join(tmpdir(), "gitplan-"));
|
|
12
|
+
|
|
13
|
+
test("启发式:锁文件进 excluded;目录+类型聚类;conventional 前缀建议", () => {
|
|
14
|
+
const { groups, excluded } = groupHeuristic(stateDir, [
|
|
15
|
+
{ path: "package-lock.json", status: "modified" },
|
|
16
|
+
{ path: "README.md", status: "modified" },
|
|
17
|
+
{ path: "docs/guide.md", status: "untracked" },
|
|
18
|
+
{ path: "src/foo/a.ts", status: "untracked" },
|
|
19
|
+
{ path: "src/foo/b.ts", status: "untracked" },
|
|
20
|
+
{ path: "src/foo/a.test.ts", status: "untracked" },
|
|
21
|
+
{ path: "lib/util.ts", status: "modified" },
|
|
22
|
+
]);
|
|
23
|
+
assert.deepEqual(excluded, ["package-lock.json"]);
|
|
24
|
+
const byKey = Object.fromEntries(groups.map((g) => [g.key, g]));
|
|
25
|
+
// docs 聚合(README + docs/)
|
|
26
|
+
assert.ok(byKey["docs::docs"] || groups.some((g) => g.key.startsWith("docs")));
|
|
27
|
+
// src/foo 源码组(测试文件单独)
|
|
28
|
+
assert.ok(groups.some((g) => g.key === "src/foo::source" && g.files.includes("src/foo/a.ts")));
|
|
29
|
+
assert.ok(groups.some((g) => g.files.includes("src/foo/a.test.ts") && g.message.startsWith("test(")));
|
|
30
|
+
// 仅修改的源码组建议 fix:
|
|
31
|
+
const lib = groups.find((g) => g.key === "lib::source");
|
|
32
|
+
assert.ok(lib && lib.message.startsWith("fix("));
|
|
33
|
+
// 全新增组建议 feat:
|
|
34
|
+
const foo = groups.find((g) => g.key === "src/foo::source");
|
|
35
|
+
assert.ok(foo && foo.message.startsWith("feat("));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("偏好学习:move 共现 ≥2 → dirKey 对合并为一组", () => {
|
|
39
|
+
learn(stateDir, "move", { file: "lib/util.ts", toKey: "src/foo" });
|
|
40
|
+
learn(stateDir, "move", { file: "lib/other.ts", toKey: "src/foo" });
|
|
41
|
+
const { groups } = groupHeuristic(stateDir, [
|
|
42
|
+
{ path: "lib/util.ts", status: "modified" },
|
|
43
|
+
{ path: "src/foo/a.ts", status: "untracked" },
|
|
44
|
+
]);
|
|
45
|
+
assert.equal(groups.length, 1, "共现合并生效");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("偏好学习:exclude 精确路径 → 建议剔除", () => {
|
|
49
|
+
learn(stateDir, "exclude", { path: "generated/bundle.js" });
|
|
50
|
+
const { excluded } = groupHeuristic(stateDir, [{ path: "generated/bundle.js", status: "untracked" }]);
|
|
51
|
+
assert.deepEqual(excluded, ["generated/bundle.js"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("plan CRUD:roundtrip + cwd 哈希隔离", () => {
|
|
55
|
+
const cwdA = "/tmp/gitplan-a";
|
|
56
|
+
const plan = savePlan(stateDir, { ...newPlan(cwdA, "abc123"), groups: [{ id: "g1", files: ["a.ts"], message: "feat: a", state: "pending" }], excluded: [] });
|
|
57
|
+
assert.match(plan.id, /^plan-/);
|
|
58
|
+
const back = loadPlan(stateDir, cwdA);
|
|
59
|
+
assert.equal(back?.baseHEAD, "abc123");
|
|
60
|
+
assert.equal(back?.groups[0].message, "feat: a");
|
|
61
|
+
assert.equal(loadPlan(stateDir, "/tmp/gitplan-b"), null);
|
|
62
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-plan · aiti-folder 提交计划(原 folder 案 §5/§6,git-ops 融合方案 M4)。
|
|
3
|
+
*
|
|
4
|
+
* - 启发式分组(两级的第一级,零成本离线):目录前缀聚类 + 类型规则(锁文件/配置/文档/
|
|
5
|
+
* 测试/源码)+ 状态修饰(全新增组建议 feat:、仅修改组建议 fix:);锁文件默认进 excluded 建议。
|
|
6
|
+
* - 偏好学习(纯本地统计,不上模型):用户在对话框的纠偏(文件移组=dirKey 共现、剔除=pattern)
|
|
7
|
+
* 写入 state/grouping-prefs.json;启发式读入——共现 ≥2 的 dirKey 对合并为一组。
|
|
8
|
+
* - plan CRUD:state 层(不可再生)`<stateDir>/aiti-folder/plan-<hash(cwd)>.json`;
|
|
9
|
+
* 执行不走这里——客户端直调引擎 `/git/plan/execute`(断点语义)与 `/git/plan/rollback`。
|
|
10
|
+
*
|
|
11
|
+
* 服务端红线对照:纯 fs + 同步小 JSON(<8MB 千倍余量);无 git 子进程(读面全走引擎)。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
|
|
17
|
+
export interface PlanGroup {
|
|
18
|
+
id: string;
|
|
19
|
+
files: string[];
|
|
20
|
+
message: string;
|
|
21
|
+
state: "pending" | "committed" | "failed";
|
|
22
|
+
}
|
|
23
|
+
export interface CommitPlan {
|
|
24
|
+
id: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
baseHEAD: string;
|
|
27
|
+
groups: PlanGroup[];
|
|
28
|
+
excluded: string[];
|
|
29
|
+
createdAt: number;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Prefs {
|
|
34
|
+
/** dirKey 共现权重:用户把两个 dirKey 的文件分进同组 → 计数 +1;≥CO_CHANGE_MIN 合并 */
|
|
35
|
+
coChange: Record<string, number>;
|
|
36
|
+
/** 用户剔除过的路径(精确)→ 启发式直接建议 excluded */
|
|
37
|
+
excludedPaths: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const LOCK_RE = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|go\.sum)$/i;
|
|
41
|
+
const DOC_RE = /(^|\/)(docs?|README|CHANGELOG|LICENSE)|\.(md|mdx|txt)$/i;
|
|
42
|
+
const CFG_RE = /\.(json|ya?ml|toml|ini|conf|env|rc)$/i;
|
|
43
|
+
const TEST_RE = /(^|\/)(tests?|__tests__)(\/|$)|[.\-_](test|spec)\.[a-z]+$/i;
|
|
44
|
+
const CO_CHANGE_MIN = 2;
|
|
45
|
+
const PLAN_CAP = 20; // 计划文件体自防御上限(KB):正常计划远小于此
|
|
46
|
+
|
|
47
|
+
const dirKeyOf = (rel: string): string => {
|
|
48
|
+
const norm = rel.replace(/\\/g, "/");
|
|
49
|
+
const segs = norm.split("/");
|
|
50
|
+
if (segs.length <= 1) return "(root)";
|
|
51
|
+
// src/包名 二级;其余一级
|
|
52
|
+
if (/^(src|lib|app|packages)$/.test(segs[0]) && segs.length > 2) return `${segs[0]}/${segs[1]}`;
|
|
53
|
+
return segs[0];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const typeOf = (rel: string): "lock" | "docs" | "config" | "test" | "source" => {
|
|
57
|
+
if (LOCK_RE.test(rel)) return "lock";
|
|
58
|
+
if (DOC_RE.test(rel)) return "docs";
|
|
59
|
+
if (TEST_RE.test(rel)) return "test";
|
|
60
|
+
if (CFG_RE.test(rel)) return "config";
|
|
61
|
+
return "source";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const typeRank: Record<string, number> = { docs: 0, config: 1, test: 2, source: 3 };
|
|
65
|
+
|
|
66
|
+
const suggestType = (rel: string, statuses: string[]): string => {
|
|
67
|
+
const t = typeOf(rel);
|
|
68
|
+
if (t === "docs") return "docs";
|
|
69
|
+
if (t === "config" || t === "lock") return "chore";
|
|
70
|
+
if (t === "test") return "test";
|
|
71
|
+
// 源码:组内全为新增 → feat:;含修改 → fix:(启发式猜测,用户可改)
|
|
72
|
+
return statuses.every((s) => s === "untracked" || s === "added") ? "feat" : "fix";
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function loadPrefs(stateDir: string): Prefs {
|
|
76
|
+
try {
|
|
77
|
+
const p = JSON.parse(readFileSync(join(stateDir, "aiti-folder", "grouping-prefs.json"), "utf8"));
|
|
78
|
+
return { coChange: p.coChange ?? {}, excludedPaths: p.excludedPaths ?? [] };
|
|
79
|
+
} catch {
|
|
80
|
+
return { coChange: {}, excludedPaths: [] };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function savePrefs(stateDir: string, prefs: Prefs): void {
|
|
85
|
+
const f = join(stateDir, "aiti-folder", "grouping-prefs.json");
|
|
86
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
87
|
+
writeFileSync(f, JSON.stringify(prefs, null, 2));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 启发式分组:输入变更文件(相对路径 + 宿主 status 分类),输出组与 excluded 建议 */
|
|
91
|
+
export function groupHeuristic(
|
|
92
|
+
stateDir: string,
|
|
93
|
+
files: { path: string; status?: string }[],
|
|
94
|
+
): { groups: { key: string; message: string; files: string[] }[]; excluded: string[] } {
|
|
95
|
+
const prefs = loadPrefs(stateDir);
|
|
96
|
+
const excluded: string[] = [];
|
|
97
|
+
const buckets = new Map<string, { type: string; files: string[]; statuses: string[] }>();
|
|
98
|
+
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
const rel = String(f.path || "").replace(/\\/g, "/");
|
|
101
|
+
if (!rel) continue;
|
|
102
|
+
if (typeOf(rel) === "lock" || prefs.excludedPaths.includes(rel)) {
|
|
103
|
+
excluded.push(rel);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const dk = dirKeyOf(rel);
|
|
107
|
+
// 偏好:共现 ≥2 的 dirKey 对合并(合并到 key 排序在前的一方)
|
|
108
|
+
let key = `${dk}::${typeOf(rel)}`;
|
|
109
|
+
for (const [pair] of Object.entries(prefs.coChange)) {
|
|
110
|
+
const [a, b] = pair.split("→");
|
|
111
|
+
if ((a === dk || b === dk) && prefs.coChange[pair] >= CO_CHANGE_MIN) {
|
|
112
|
+
const other = a === dk ? b : a;
|
|
113
|
+
key = `${[dk, other].sort()[0]}::merged`;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const hit = buckets.get(key);
|
|
118
|
+
if (hit) {
|
|
119
|
+
hit.files.push(rel);
|
|
120
|
+
hit.statuses.push(String(f.status ?? "modified"));
|
|
121
|
+
} else {
|
|
122
|
+
buckets.set(key, { type: typeOf(rel), files: [rel], statuses: [String(f.status ?? "modified")] });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const groups = [...buckets.entries()]
|
|
127
|
+
.map(([key, b]) => {
|
|
128
|
+
const type = b.type === "lock" ? "chore" : b.type;
|
|
129
|
+
const verb = suggestType(b.files[0], b.statuses);
|
|
130
|
+
const dirLabel = key.split("::")[0] === "(root)" ? "misc" : key.split("::")[0];
|
|
131
|
+
const message = `${verb}(${dirLabel}): ${b.files.length} file(s)`;
|
|
132
|
+
return { key, message, files: b.files.sort() };
|
|
133
|
+
})
|
|
134
|
+
.sort((x, y) => (typeRank[x.key.split("::")[1]] ?? 3) - (typeRank[y.key.split("::")[1]] ?? 3) || x.key.localeCompare(y.key));
|
|
135
|
+
|
|
136
|
+
return { groups, excluded: excluded.sort() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 偏好学习:用户纠偏回写(move=共现;exclude=精确路径) */
|
|
140
|
+
export function learn(stateDir: string, action: "move" | "exclude", payload: { file?: string; toKey?: string; path?: string }): void {
|
|
141
|
+
const prefs = loadPrefs(stateDir);
|
|
142
|
+
if (action === "move" && payload.file && payload.toKey) {
|
|
143
|
+
const dk = dirKeyOf(String(payload.file).replace(/\\/g, "/"));
|
|
144
|
+
if (dk !== payload.toKey) {
|
|
145
|
+
const pair = [dk, payload.toKey].sort().join("→");
|
|
146
|
+
prefs.coChange[pair] = (prefs.coChange[pair] ?? 0) + 1;
|
|
147
|
+
}
|
|
148
|
+
} else if (action === "exclude" && payload.path) {
|
|
149
|
+
const rel = String(payload.path).replace(/\\/g, "/");
|
|
150
|
+
if (!prefs.excludedPaths.includes(rel)) prefs.excludedPaths.push(rel);
|
|
151
|
+
} else {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
savePrefs(stateDir, prefs);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── plan CRUD(state 层;按 cwd 哈希一仓一份)──
|
|
158
|
+
|
|
159
|
+
const planPath = (stateDir: string, cwd: string): string => {
|
|
160
|
+
const h = createHash("sha1").update(cwd).digest("hex").slice(0, 12);
|
|
161
|
+
return join(stateDir, "aiti-folder", `plan-${h}.json`);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export function loadPlan(stateDir: string, cwd: string): CommitPlan | null {
|
|
165
|
+
const f = planPath(stateDir, cwd);
|
|
166
|
+
try {
|
|
167
|
+
if (!existsSync(f)) return null;
|
|
168
|
+
if (readFileSync(f, "utf8").length > PLAN_CAP * 1024) return null; // 体积自防御:异常大计划拒读
|
|
169
|
+
const p = JSON.parse(readFileSync(f, "utf8")) as CommitPlan;
|
|
170
|
+
return p && Array.isArray(p.groups) ? p : null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function savePlan(stateDir: string, plan: CommitPlan): CommitPlan {
|
|
177
|
+
if (!plan || plan.cwd !== undefined && typeof plan.cwd !== "string") throw new Error("bad plan");
|
|
178
|
+
if (!Array.isArray(plan.groups)) throw new Error("bad plan groups");
|
|
179
|
+
plan.updatedAt = Date.now();
|
|
180
|
+
const f = planPath(stateDir, plan.cwd);
|
|
181
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
182
|
+
writeFileSync(f, JSON.stringify(plan, null, 2));
|
|
183
|
+
return plan;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function newPlan(cwd: string, baseHEAD: string): CommitPlan {
|
|
187
|
+
return {
|
|
188
|
+
id: "plan-" + createHash("sha1").update(cwd + Date.now()).digest("hex").slice(0, 8),
|
|
189
|
+
cwd,
|
|
190
|
+
baseHEAD,
|
|
191
|
+
groups: [],
|
|
192
|
+
excluded: [],
|
|
193
|
+
createdAt: Date.now(),
|
|
194
|
+
updatedAt: Date.now(),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 构建 aiti-folder JSX client bundle(Vite lib mode → client/aiti-folder-client.mjs)
|
|
4
|
+
*
|
|
5
|
+
* 源码:client/src/*.tsx;配置:vite.config.ts。
|
|
6
|
+
* 用法:node tools/build-client.mjs (或 npm run watch 自动重建)
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
14
|
+
|
|
15
|
+
const viteJs = resolve(root, "node_modules", "vite", "bin", "vite.js");
|
|
16
|
+
const viteBin = existsSync(viteJs) ? viteJs : "vite";
|
|
17
|
+
|
|
18
|
+
const r = spawnSync(process.execPath, [viteBin, "build", "--config", resolve(root, "vite.config.ts")], {
|
|
19
|
+
cwd: root,
|
|
20
|
+
stdio: "inherit",
|
|
21
|
+
shell: false,
|
|
22
|
+
});
|
|
23
|
+
if (r.status !== 0) {
|
|
24
|
+
console.error("[aiti-folder] vite build 失败(exit " + r.status + ")。若提示找不到 vite:cd pi-web-ext/aiti-folder && npm install");
|
|
25
|
+
process.exit(r.status ?? 1);
|
|
26
|
+
}
|
|
27
|
+
console.log("[aiti-folder] JSX client bundle → client/aiti-folder-client.mjs");
|
package/tools/deploy.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aiti-folder · 部署到 ~/.pi/web/extensions/ 与 ~/.pi/web-dev/extensions/(pi-web 双实例)
|
|
4
|
+
* 构建 JSX client bundle → 拷贝 index.ts + package.json + icon.svg + client/ → 两个扩展目录,
|
|
5
|
+
* 并在目标目录 npm install(扩展自带依赖,loader 从扩展目录解析 node_modules),热重载双实例。
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { deployCommit } from "../../_shared/deploy-commit.mjs";
|
|
13
|
+
|
|
14
|
+
// client TS 残留检查(.mjs 里是合法 JS 语法检查不报、运行时必炸;FAIL 即部署失败)
|
|
15
|
+
{
|
|
16
|
+
const { checkClientDir } = await import("../../_shared/check-client.mjs");
|
|
17
|
+
checkClientDir(fileURLToPath(new URL("../client", import.meta.url)), { log: (m) => console.log("[deploy] " + m) });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const root = resolve(import.meta.dirname, "..");
|
|
21
|
+
|
|
22
|
+
// 0) 客户端 bundle 构建(client/src/*.tsx → client/aiti-folder-client.mjs)
|
|
23
|
+
// 清理源目录陈旧 hash 分块(上次构建产物;emptyOutDir=false 不会自清)
|
|
24
|
+
try {
|
|
25
|
+
const { readdirSync: rds, unlinkSync: us } = await import("node:fs");
|
|
26
|
+
for (const f of rds(resolve(root, "dist"))) {
|
|
27
|
+
if (/^[A-Za-z][\w-]*-[\w-]{8}\.js$/.test(f)) us(resolve(root, "dist", f));
|
|
28
|
+
}
|
|
29
|
+
} catch { /* noop */ }
|
|
30
|
+
console.log("[deploy] 构建 JSX client bundle…");
|
|
31
|
+
try {
|
|
32
|
+
execSync("node tools/build-client.mjs", { cwd: root, stdio: "inherit" });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error("[deploy] 构建失败,中止部署:", e.message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const targets = [
|
|
39
|
+
resolve(homedir(), ".pi", "web", "extensions", "aiti-folder"),
|
|
40
|
+
resolve(homedir(), ".pi", "web-dev", "extensions", "aiti-folder"),
|
|
41
|
+
];
|
|
42
|
+
const extRepo = resolve(homedir(), ".pi", "web", "extensions");
|
|
43
|
+
|
|
44
|
+
for (const extDir of targets) {
|
|
45
|
+
mkdirSync(extDir, { recursive: true });
|
|
46
|
+
for (const f of ["index.ts", "icon.svg", "package.json"]) {
|
|
47
|
+
if (existsSync(resolve(root, f))) copyFileSync(resolve(root, f), resolve(extDir, f));
|
|
48
|
+
}
|
|
49
|
+
// server/ 模块(git-plan.ts 等):index.ts import 的服务端文件一并部署
|
|
50
|
+
if (existsSync(resolve(root, "server"))) {
|
|
51
|
+
const serverDir = resolve(extDir, "server");
|
|
52
|
+
mkdirSync(serverDir, { recursive: true });
|
|
53
|
+
for (const f of readdirSync(resolve(root, "server"))) {
|
|
54
|
+
if (f.endsWith(".ts") || f.endsWith(".mjs")) copyFileSync(resolve(root, "server", f), resolve(serverDir, f));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const clientDir = resolve(extDir, "dist");
|
|
58
|
+
mkdirSync(clientDir, { recursive: true });
|
|
59
|
+
// 拷贝主 bundle + 全部代码分块(懒加载 v2 D3:FolderBrowser 为独立 hash 分块)
|
|
60
|
+
for (const f of readdirSync(resolve(root, "dist"))) {
|
|
61
|
+
if (f.endsWith(".mjs") || f.endsWith(".js")) copyFileSync(resolve(root, "dist", f), resolve(clientDir, f));
|
|
62
|
+
}
|
|
63
|
+
// 清理目标目录陈旧 hash 分块(源目录已不含旧名——防历史分块累积)
|
|
64
|
+
for (const f of readdirSync(clientDir)) {
|
|
65
|
+
if (f.endsWith(".js") && !existsSync(resolve(root, "dist", f))) { try { unlinkSync(resolve(clientDir, f)); } catch { /* noop */ } }
|
|
66
|
+
}
|
|
67
|
+
// 扩展自带依赖(trash 等):目标目录 npm install(离线优先;package.json 比 lock 新才重装)
|
|
68
|
+
const lockFile = resolve(extDir, "node_modules", ".package-lock.json");
|
|
69
|
+
const pkgFile = resolve(extDir, "package.json");
|
|
70
|
+
const pkgNewer = existsSync(pkgFile) && (!existsSync(lockFile) || statSync(pkgFile).mtimeMs > statSync(lockFile).mtimeMs);
|
|
71
|
+
if (pkgNewer) {
|
|
72
|
+
try {
|
|
73
|
+
execSync("npm install --omit=dev --no-audit --no-fund --prefer-offline", { cwd: extDir, stdio: "ignore" });
|
|
74
|
+
console.log("[deploy] npm install → " + extDir);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.warn("[deploy] npm install 失败(" + extDir + "): " + String(e?.message || e));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
console.log("[deploy] 源码 → " + extDir);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 双实例热重载:实现收敛到 _shared/reload-instances.mjs(env basePath 双变量按机器设值)——允许失败,不影响 deploy 退出码
|
|
83
|
+
try {
|
|
84
|
+
const { reloadInstances } = await import("../../_shared/reload-instances.mjs");
|
|
85
|
+
await reloadInstances();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.log("[deploy] 自动 reload 未执行(" + String((e && e.message) || e) + ")— 需手动 POST /api/extensions/reload");
|
|
88
|
+
}
|
|
89
|
+
console.log("[deploy] 完成。client 刷新页面即新。");
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
deployCommit({ repo: extRepo, name: "aiti-folder", paths: ["aiti-folder"], log: (m) => console.log("[deploy] " + m) });
|
|
93
|
+
|
|
94
|
+
// ── dev-pack(方案 §3.2:源码+构建工具+AGENTS.md 直达部署目录,AI 可现场直改)——允许失败,不影响 deploy 退出码 ──
|
|
95
|
+
try {
|
|
96
|
+
const { packDevPack } = await import("../../_shared/dev-pack.mjs");
|
|
97
|
+
packDevPack({ extRoot: resolve(import.meta.dirname, ".."), log: (m) => console.log("[deploy] " + m) });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.log("[deploy] dev-pack 未执行(" + String((e && e.message) || e) + ")");
|
|
100
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-folder client 构建配置(Vite lib mode)——JSX 模板实例。
|
|
3
|
+
*
|
|
4
|
+
* client/src/entry.tsx(JSX + TS)→ 单个 ESM bundle:client/aiti-folder-client.mjs
|
|
5
|
+
* (与 index.ts 的 client 字段一致;产物覆盖原手写版,源码在 git 历史可回溯)。
|
|
6
|
+
*
|
|
7
|
+
* 模板与原理:pi-web-ext/templates/ext-jsx-client/ + docs/aiti-ext-jsx-vite-plan.md
|
|
8
|
+
* 三个必踩的坑(模板已修好,勿回退):jsx automatic / 正则 alias / external []。
|
|
9
|
+
*/
|
|
10
|
+
import { defineConfig } from "vite";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
esbuild: {
|
|
15
|
+
jsx: "automatic",
|
|
16
|
+
jsxImportSource: "react",
|
|
17
|
+
},
|
|
18
|
+
resolve: {
|
|
19
|
+
alias: [
|
|
20
|
+
// 正则精确匹配(字符串 alias 是前缀匹配,find 'react' 会吞掉 'react/jsx-runtime')
|
|
21
|
+
{ find: /^react\/jsx-dev-runtime$/, replacement: fileURLToPath(new URL("client/src/jsx-runtime.ts", import.meta.url)) },
|
|
22
|
+
{ find: /^react\/jsx-runtime$/, replacement: fileURLToPath(new URL("client/src/jsx-runtime.ts", import.meta.url)) },
|
|
23
|
+
{ find: /^react$/, replacement: fileURLToPath(new URL("client/src/host-shim.ts", import.meta.url)) },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
build: {
|
|
27
|
+
lib: {
|
|
28
|
+
entry: fileURLToPath(new URL("client/src/entry.tsx", import.meta.url)),
|
|
29
|
+
formats: ["es"],
|
|
30
|
+
fileName: () => "aiti-folder-client.mjs",
|
|
31
|
+
},
|
|
32
|
+
outDir: "dist",
|
|
33
|
+
emptyOutDir: true,
|
|
34
|
+
target: "es2020",
|
|
35
|
+
// 薄壳拆分(2026-09-14):chunk 由浏览器按需直接拉取(不再经宿主批量包二次
|
|
36
|
+
// minify),必须自身压缩;entry 共享 chunk 同理。
|
|
37
|
+
minify: true,
|
|
38
|
+
sourcemap: false,
|
|
39
|
+
rollupOptions: {
|
|
40
|
+
external: [], // npm 包全部内联进单个 .mjs
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|