mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat: 组合包配置集中到 my-pi/ 目录并改为整体重建
- 组合包拥有、可随时重建的配置、日志与缓存统一放在 <agentDir>/my-pi/:permission-system/(policy.jsonc、hard-guards.jsonc、 logs/)、rtk-optimizer/config.json、tool-search/(config.json、 catalog-v1.json)与 system-prompt-cover.md。hashline.json、lsp.json 由外部扩展读取固定路径,是唯一例外。 - 新增 config/bundle-manifest.json 与 scripts/rebuild-bundle-config.mjs: 先校验全部来源与目标,在临时目录构建完整树,再整体删除旧 my-pi/ (符号链接只删链接)并原子替换,不备份。install.sh / update.sh 每次 运行都调用它;扩展自身不再播种或迁移配置。 - update.sh 删除 pi update 失败后的 git clean 恢复逻辑:Pi 1.3.0 会在 下一次 pi update 时依据中断标记自行恢复。同时删除 Tool Search settings.json 快照迁移与封面部署脚本。 - Tool Search 改为读取 my-pi/tool-search/config.json(源为 config/tool-search.json,须与 BUNDLE_TOOL_SEARCH_DEFAULTS 一致), 不再读写 settings.json 的 toolSearch 段,移除 bundleDefaultsVersion。 - 默认权限策略开启 debugLog。 - 旧位置的残留文件不迁移、不清理;AGENTS.md / README 同步新约束。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
7e6a8faae1
commit
2682bd7fa7
@@ -0,0 +1,220 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
getGlobalLogsDir,
|
||||
getHardGuardConfigPath,
|
||||
} from "../pi-permission-system/src/config-paths.ts";
|
||||
import {
|
||||
BUNDLE_TOOL_SEARCH_DEFAULTS,
|
||||
getToolSearchCatalogPath,
|
||||
getToolSearchConfigPath,
|
||||
} from "../pi-tool-search/extensions/config.ts";
|
||||
import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts";
|
||||
import { rebuildBundleConfig } from "../scripts/rebuild-bundle-config.mjs";
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const rebuildScript = join(repositoryRoot, "scripts", "rebuild-bundle-config.mjs");
|
||||
const manifestPath = join(repositoryRoot, "config", "bundle-manifest.json");
|
||||
|
||||
interface ManifestFile {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
|
||||
version: number;
|
||||
directory: string;
|
||||
files: ManifestFile[];
|
||||
};
|
||||
|
||||
async function makeTempDir() {
|
||||
return mkdtemp(join(tmpdir(), "my-pi-rebuild-"));
|
||||
}
|
||||
|
||||
function modeOf(path: string): Promise<number> {
|
||||
return stat(path).then((info) => info.mode & 0o777);
|
||||
}
|
||||
|
||||
async function readSnapshot(path: string): Promise<string> {
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
|
||||
test("rebuild replaces the my-pi tree with byte-identical manifest targets and leaves outside files alone", async () => {
|
||||
const dir = await makeTempDir();
|
||||
try {
|
||||
// Pre-existing junk inside the managed directory must disappear entirely.
|
||||
await mkdir(join(dir, "my-pi", "permission-system"), { recursive: true });
|
||||
await writeFile(join(dir, "my-pi", "permission-system", "policy.jsonc"), "STALE POLICY", "utf8");
|
||||
await writeFile(join(dir, "my-pi", "leftover.txt"), "JUNK", "utf8");
|
||||
await mkdir(join(dir, "my-pi", "stray", "deep"), { recursive: true });
|
||||
|
||||
// Files outside my-pi/ must survive byte-identical — the script owns only my-pi/.
|
||||
const outside = [
|
||||
join(dir, "settings.json"),
|
||||
join(dir, "sessions", "abc", "session.jsonl"),
|
||||
join(dir, "extensions", "pi-permission-system", "policy.jsonc"),
|
||||
];
|
||||
for (const path of outside) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `outside ${path}\n`, "utf8");
|
||||
}
|
||||
const before = new Map(await Promise.all(outside.map(async (p) => [p, await readSnapshot(p)])));
|
||||
|
||||
await rebuildBundleConfig(dir);
|
||||
|
||||
const myPi = join(dir, "my-pi");
|
||||
const expectedFiles = new Set(manifest.files.map((file) => file.target));
|
||||
const actualFiles: string[] = [];
|
||||
const walk = async (root: string, rel: string) => {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
const next = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) await walk(join(root, entry.name), next);
|
||||
else actualFiles.push(next);
|
||||
}
|
||||
};
|
||||
await walk(myPi, "");
|
||||
assert.deepEqual(actualFiles.sort(), [...expectedFiles].sort(), "my-pi must contain exactly the manifest targets");
|
||||
|
||||
for (const file of manifest.files) {
|
||||
const target = join(myPi, file.target);
|
||||
assert.equal(await readFile(target, "utf8"), await readFile(join(repositoryRoot, file.source), "utf8"));
|
||||
assert.equal(await modeOf(target), 0o600, `${file.target} must be owner-only`);
|
||||
assert.equal(await modeOf(dirname(target)), 0o700, `${dirname(file.target)} must be owner-only`);
|
||||
}
|
||||
|
||||
for (const [path, content] of before) {
|
||||
assert.equal(await readSnapshot(path), content, `outside file ${path} must be untouched`);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an empty or relative agentDir without deleting anything", async () => {
|
||||
const dir = await makeTempDir();
|
||||
try {
|
||||
const sentinel = join(dir, "keep.txt");
|
||||
await writeFile(sentinel, "keep", "utf8");
|
||||
|
||||
await assert.rejects(rebuildBundleConfig(""), /absolute/);
|
||||
await assert.rejects(rebuildBundleConfig("relative/dir"), /absolute/);
|
||||
assert.equal(await readFile(sentinel, "utf8"), "keep");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI rejects a relative agentDir and reports the failure", async () => {
|
||||
const dir = await makeTempDir();
|
||||
try {
|
||||
await mkdir(join(dir, "my-pi"), { recursive: true });
|
||||
await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8");
|
||||
|
||||
const failure = spawnSync(process.execPath, [rebuildScript, "relative/dir"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(failure.status, 2);
|
||||
assert.match(failure.stderr ?? "", /absolute/);
|
||||
assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI prints rebuilt:<path> on success", async () => {
|
||||
const dir = await makeTempDir();
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [rebuildScript, dir], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.equal(result.stdout.trim(), `rebuilt:${join(dir, "my-pi")}`);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a manifest target escaping the directory is rejected without deleting my-pi", async () => {
|
||||
const dir = await makeTempDir();
|
||||
try {
|
||||
await mkdir(join(dir, "my-pi"), { recursive: true });
|
||||
await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8");
|
||||
|
||||
const badManifestPath = join(dir, "bad-manifest.json");
|
||||
await writeFile(
|
||||
badManifestPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
directory: "my-pi",
|
||||
files: [{ source: "config/tool-search.json", target: "../escape.txt" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await assert.rejects(
|
||||
rebuildBundleConfig(dir, { manifestPath: badManifestPath }),
|
||||
/escapes/,
|
||||
);
|
||||
assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here");
|
||||
await assert.rejects(readFile(join(dir, "escape.txt"), "utf8"), /ENOENT/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a symlinked my-pi is replaced, never followed", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const elsewhere = await makeTempDir();
|
||||
try {
|
||||
const targetContent = join(elsewhere, "precious.txt");
|
||||
await writeFile(targetContent, "do not touch", "utf8");
|
||||
await symlink(elsewhere, join(dir, "my-pi"), "dir");
|
||||
|
||||
await rebuildBundleConfig(dir);
|
||||
|
||||
// The link itself must be replaced by a real directory…
|
||||
const rebuilt = await stat(join(dir, "my-pi"));
|
||||
assert.ok(rebuilt.isDirectory());
|
||||
assert.ok(!(await lstat(join(dir, "my-pi"))).isSymbolicLink(), "my-pi must be a real directory now");
|
||||
|
||||
// …and the link target's contents must survive untouched.
|
||||
assert.equal(await readFile(targetContent, "utf8"), "do not touch");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await rm(elsewhere, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest targets match the paths the extensions actually read", () => {
|
||||
const targets = new Map(manifest.files.map((file) => [file.target, file.source]));
|
||||
const agentDir = "/agent";
|
||||
const base = `${agentDir}/${manifest.directory}`;
|
||||
|
||||
const expectTarget = (source: string, resolved: string, suffix: string) => {
|
||||
assert.ok(resolved.startsWith(`${base}/`), `${resolved} must live under ${base}`);
|
||||
assert.equal(
|
||||
targets.get(resolved.slice(base.length + 1)),
|
||||
source,
|
||||
`${resolved} must be a manifest target backed by ${source}`,
|
||||
);
|
||||
assert.ok(resolved.endsWith(suffix), `${resolved} must end with ${suffix}`);
|
||||
};
|
||||
|
||||
expectTarget("config/pi-permission-policy.jsonc", getGlobalConfigPath(agentDir), "my-pi/permission-system/policy.jsonc");
|
||||
expectTarget("config/pi-permission-hard-guards.jsonc", getHardGuardConfigPath(agentDir), "my-pi/permission-system/hard-guards.jsonc");
|
||||
expectTarget("config/tool-search.json", getToolSearchConfigPath(agentDir), "my-pi/tool-search/config.json");
|
||||
expectTarget("config/system-prompt-cover.md", resolveCoverPath(agentDir), "my-pi/system-prompt-cover.md");
|
||||
|
||||
// Runtime state that is created rather than deployed must still live under my-pi/.
|
||||
assert.ok(getGlobalLogsDir(agentDir).endsWith("my-pi/permission-system/logs"));
|
||||
assert.ok(getToolSearchCatalogPath(agentDir).endsWith("my-pi/tool-search/catalog-v1.json"));
|
||||
});
|
||||
|
||||
test("deployed tool-search config is exactly BUNDLE_TOOL_SEARCH_DEFAULTS", async () => {
|
||||
const deployed = JSON.parse(await readFile(join(repositoryRoot, "config", "tool-search.json"), "utf8"));
|
||||
assert.deepEqual(deployed, BUNDLE_TOOL_SEARCH_DEFAULTS);
|
||||
});
|
||||
Reference in New Issue
Block a user