feat(core,cli): harden install engine and extend skill lint and interview eval

- update only touches the recorded project root; project installs do not
  follow symlinks or junctions; repeated --force rotates .orig backups;
  uninstall never deletes outside the recorded root
- run npm/npx without a shell on Windows and pin optional tool versions
- skip the update check in CI and non-interactive output
- lint: cross-document section pointers, preflight row ids, gate rule
  numbers, per-document size budgets, gate copy equality
- eval: scenarios S19-S21, route and read-cost rejudging, interpretation
  card judging, temp-dir previews and table-form cards no longer fail
This commit is contained in:
Yun Chan 2026-10-06 16:55:38 +09:00
parent ee3bef2ce5
commit 9bcb45cbd3
122 changed files with 5836 additions and 831 deletions

View file

@ -19,9 +19,10 @@ after(async () => {
});
/** 빌드 산출물을 사용자가 실행하는 방식 그대로 돌린다 */
function run(args: string[]): string {
function run(args: string[], cwd?: string): string {
return execFileSync(process.execPath, [CLI, ...args, "--no-update-check"], {
encoding: "utf8",
...(cwd ? { cwd } : {}),
env: {
...process.env,
HOME: home,
@ -120,3 +121,45 @@ test("설치 → doctor → 제거 왕복", { skip: !built }, async () => {
run(["uninstall", "-y"]);
await assert.rejects(() => fs.access(skillMd));
});
test("update 는 project 설치를 기록된 root 에서 갱신하고 cwd 에는 아무것도 쓰지 않는다", { skip: !built }, async () => {
// 보안 감사 P0 재현: repoA 에 project 설치 → 버전을 낮춤 → 무관한 repoB 에서 update.
// 예전에는 repoB 의 AGENTS.md 에 블록이 주입되고 사용자가 만든 스킬이 백업 없이 덮였다.
const repoA = path.join(home, "repoA");
const repoB = path.join(home, "repoB");
await fs.mkdir(repoA, { recursive: true });
await fs.mkdir(path.join(repoB, ".claude", "skills", "designpaca"), { recursive: true });
await fs.writeFile(path.join(repoB, "AGENTS.md"), "# repoB\n", "utf8");
const userSkill = path.join(repoB, ".claude", "skills", "designpaca", "SKILL.md");
await fs.writeFile(userSkill, "user own skill\n", "utf8");
const listB = async () => (await fs.readdir(repoB, { recursive: true })).map(String).sort();
const beforeB = await listB();
run(["install", "-t", "agents-md,claude-code", "-s", "project", "-y"], repoA);
const manifestFile = path.join(home, ".designpaca", "manifest.json");
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8"));
assert.equal(manifest.installs.length, 2);
for (const rec of manifest.installs) rec.version = "0.0.1";
await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
run(["update"], repoB);
// repoB 는 그대로다
assert.deepEqual(await listB(), beforeB);
assert.equal(await fs.readFile(path.join(repoB, "AGENTS.md"), "utf8"), "# repoB\n");
assert.equal(await fs.readFile(userSkill, "utf8"), "user own skill\n");
// repoA 기록만 새 버전으로 갱신됐고, 새 기록이 생기지 않았다
const after = JSON.parse(await fs.readFile(manifestFile, "utf8"));
const version = run(["--version"]).trim();
assert.equal(after.installs.length, 2);
for (const rec of after.installs) {
assert.equal(rec.version, version);
assert.ok(path.resolve(rec.root).startsWith(path.resolve(repoA)), `기록 root 가 repoA 밖: ${rec.root}`);
}
assert.ok((await fs.readFile(path.join(repoA, "AGENTS.md"), "utf8")).includes("designpaca:start"));
run(["uninstall", "-y"], repoB);
await assert.rejects(() => fs.access(path.join(repoA, ".claude", "skills", "designpaca", "SKILL.md")));
assert.equal(await fs.readFile(userSkill, "utf8"), "user own skill\n");
});