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:
parent
ee3bef2ce5
commit
9bcb45cbd3
122 changed files with 5836 additions and 831 deletions
|
|
@ -3,6 +3,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isAllowedSkillFile } from "./skill-allowlist.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const src = path.resolve(here, "../../skill");
|
||||
|
|
@ -14,15 +15,25 @@ const dest = path.resolve(here, "../dist/skill");
|
|||
// 배포물은 어느 OS 에서 만들었는지와 무관하게 같아야 한다.
|
||||
const TEXT = new Set([".md", ".txt", ".json", ".yml", ".yaml"]);
|
||||
|
||||
async function copyDir(from, to) {
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
const excluded = [];
|
||||
|
||||
async function copyDir(from, to, rel = "") {
|
||||
for (const e of await fs.readdir(from, { withFileTypes: true })) {
|
||||
if (e.name === "node_modules" || e.name === "package.json") continue;
|
||||
if (e.name === "node_modules") continue;
|
||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||
const s = path.join(from, e.name);
|
||||
const d = path.join(to, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await copyDir(s, d);
|
||||
} else if (TEXT.has(path.extname(e.name))) {
|
||||
await copyDir(s, d, r);
|
||||
continue;
|
||||
}
|
||||
// 허용 목록 밖(테스트·스크래치·dotfile·package.json 등)은 사용자 설치물에 싣지 않는다
|
||||
if (!e.isFile() || !isAllowedSkillFile(r)) {
|
||||
if (e.name !== "package.json") excluded.push(r);
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
if (TEXT.has(path.extname(e.name))) {
|
||||
const text = await fs.readFile(s, "utf8");
|
||||
await fs.writeFile(d, text.replace(/\r\n/g, "\n"), "utf8");
|
||||
} else {
|
||||
|
|
@ -40,3 +51,4 @@ await fs.writeFile(path.join(dest, ".designpaca_version"), `${pkg.version}\n`, "
|
|||
|
||||
const count = (await fs.readdir(dest, { recursive: true })).length;
|
||||
console.log(`스킬 번들 완료: ${count}개 항목 → dist/skill`);
|
||||
if (excluded.length > 0) console.log(` 설치물에서 제외: ${excluded.join(", ")}`);
|
||||
|
|
|
|||
21
packages/cli/scripts/skill-allowlist.mjs
Normal file
21
packages/cli/scripts/skill-allowlist.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// 사용자 설치물(dist/skill → 각 하네스의 스킬 디렉터리)에 실을 파일의 허용 목록.
|
||||
// bundle-skill.mjs(복사 시)와 verify-package.mjs(배포 직전)가 같은 규칙을 쓴다.
|
||||
//
|
||||
// 이유: 예전에는 packages/skill 아래를 통째로 복사해서 추적되지 않은 스크래치·.env·로컬 메모가
|
||||
// 로컬 publish 때 npm 에 실릴 수 있었고, 개발용 계약 테스트(design-gate-contract.test.mjs)가
|
||||
// 모든 사용자 스킬 디렉터리에 설치됐다. 새 종류의 파일을 실으려면 여기서 명시적으로 연다.
|
||||
|
||||
/** posix 상대 경로(rel) 가 설치물에 들어가도 되는가 */
|
||||
export function isAllowedSkillFile(rel) {
|
||||
const parts = rel.split("/");
|
||||
if (parts.some((p) => p.startsWith(".") && p !== ".designpaca_version")) return false;
|
||||
if (/\.test\.[cm]?[jt]s$/.test(rel)) return false;
|
||||
if (parts.length === 1) {
|
||||
return ["SKILL.md", "CHANGELOG.md", "THIRD_PARTY_NOTICES.md", ".designpaca_version"].includes(rel);
|
||||
}
|
||||
const [top] = parts;
|
||||
if (top === "references") return rel.endsWith(".md");
|
||||
if (top === "agents") return /\.ya?ml$/.test(rel);
|
||||
if (top === "tools") return parts.length === 2 && rel.endsWith(".mjs");
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isAllowedSkillFile } from "./skill-allowlist.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const must = ["dist/index.js", "dist/skill/SKILL.md", "dist/skill/.designpaca_version"];
|
||||
|
|
@ -26,4 +27,17 @@ if (stamp !== pkg.version) {
|
|||
console.error(`버전 불일치: package.json=${pkg.version} / 스킬 스탬프=${stamp}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// 허용 목록 밖 파일(테스트·스크래치·.env 등)이 설치물에 섞였으면 배포하지 않는다
|
||||
const skillDir = path.join(root, "dist/skill");
|
||||
const unexpected = [];
|
||||
for (const e of await fs.readdir(skillDir, { recursive: true, withFileTypes: true })) {
|
||||
if (!e.isFile() && !e.isSymbolicLink()) continue;
|
||||
const rel = path.relative(skillDir, path.join(e.parentPath ?? e.path, e.name)).split(path.sep).join("/");
|
||||
if (e.isSymbolicLink() || !isAllowedSkillFile(rel)) unexpected.push(rel);
|
||||
}
|
||||
if (unexpected.length) {
|
||||
console.error(`스킬 설치물에 허용 목록 밖 파일:\n${unexpected.map((m) => ` - ${m}`).join("\n")}`);
|
||||
console.error("scripts/skill-allowlist.mjs 를 확인하고 pnpm build 를 다시 실행해라.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`배포 점검 통과 (v${pkg.version})`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
applyPlan,
|
||||
planInstall,
|
||||
type EnvOptions,
|
||||
samePath,
|
||||
tildify,
|
||||
type Scope,
|
||||
type TargetId,
|
||||
|
|
@ -14,6 +16,13 @@ export interface InstallOptions {
|
|||
force?: boolean;
|
||||
/** 실제로 쓰지 않고 계획만 출력 */
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* 계획 기준 디렉터리. update 가 기록의 프로젝트·홈을 그대로 넘긴다 — 없으면 cwd·홈.
|
||||
* 실측 사고: 이걸 넘기지 않아 project 설치가 update 를 실행한 cwd 에 다시 깔렸다.
|
||||
*/
|
||||
env?: EnvOptions;
|
||||
/** 계획한 root 가 이 경로와 다르면 쓰지 않는다(update 의 방어선) */
|
||||
expectRoot?: string;
|
||||
}
|
||||
|
||||
export interface InstallOutcome {
|
||||
|
|
@ -21,6 +30,8 @@ export interface InstallOutcome {
|
|||
root: string;
|
||||
written: number;
|
||||
skipped: string[];
|
||||
/** 덮기 전에 남긴 백업(.orig) */
|
||||
backedUp?: string[];
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +41,10 @@ export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]
|
|||
const outcomes: InstallOutcome[] = [];
|
||||
|
||||
for (const target of opts.targets) {
|
||||
const plan = await planInstall(target, opts.scope, skill);
|
||||
const plan = await planInstall(target, opts.scope, skill, opts.env);
|
||||
if (!plan.blocked && opts.expectRoot !== undefined && !samePath(plan.root, opts.expectRoot)) {
|
||||
plan.blocked = `계획한 위치(${tildify(plan.root)})가 설치 기록(${tildify(opts.expectRoot)})과 다릅니다`;
|
||||
}
|
||||
if (plan.blocked) {
|
||||
outcomes.push({ target, root: "", written: 0, skipped: [], blocked: plan.blocked });
|
||||
continue;
|
||||
|
|
@ -39,12 +53,21 @@ export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]
|
|||
outcomes.push({ target, root: plan.root, written: plan.actions.length, skipped: [] });
|
||||
continue;
|
||||
}
|
||||
const res = await applyPlan(plan, skill.version, { force: opts.force });
|
||||
let res;
|
||||
try {
|
||||
res = await applyPlan(plan, skill.version, { force: opts.force, expectRoot: opts.expectRoot });
|
||||
} catch (err) {
|
||||
// 링크 거부·꼬인 마커 등은 이 대상만 막고 다른 대상은 계속 진행한다
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
outcomes.push({ target, root: plan.root, written: 0, skipped: [], blocked: msg });
|
||||
continue;
|
||||
}
|
||||
outcomes.push({
|
||||
target,
|
||||
root: plan.root,
|
||||
written: res.written.length,
|
||||
skipped: res.skipped,
|
||||
backedUp: res.backedUp,
|
||||
});
|
||||
}
|
||||
return outcomes;
|
||||
|
|
@ -63,6 +86,10 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void
|
|||
console.log(warn(` 직접 수정하신 파일이라 그대로 두었습니다 (--force 로 덮어쓸 수 있습니다):`));
|
||||
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
|
||||
}
|
||||
if (o.backedUp && o.backedUp.length > 0) {
|
||||
console.log(warn(` 기존 파일을 덮기 전에 백업해 두었습니다:`));
|
||||
for (const b of fold(o.backedUp)) console.log(dim(` ${tildify(b)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export async function runUninstall(opts: {
|
|||
}
|
||||
|
||||
console.log(heading(opts.dryRun ? "제거 계획" : "제거"));
|
||||
let code = 0;
|
||||
for (const rec of targets) {
|
||||
const res = await removeInstall(rec, { force: opts.force, dryRun: opts.dryRun });
|
||||
const verb = opts.dryRun ? "제거할 파일" : "제거";
|
||||
|
|
@ -28,6 +29,13 @@ export async function runUninstall(opts: {
|
|||
);
|
||||
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
||||
}
|
||||
if (res.refused.length > 0) {
|
||||
code = 1;
|
||||
console.log(
|
||||
warn(` 설치 기록과 맞지 않아 손대지 않은 파일 ${res.refused.length}개 (기록 밖 경로·링크·꼬인 마커)`),
|
||||
);
|
||||
for (const p of res.refused.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
return code;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { inspectDrift, readManifest, tildify } from "@designpaca/core";
|
||||
import { envForRecord, inspectDrift, readManifest, tildify } from "@designpaca/core";
|
||||
import { getVersion } from "../skill.ts";
|
||||
import { runInstall, printOutcomes } from "./install.ts";
|
||||
import { dim, heading, info, ok, warn } from "../ui.ts";
|
||||
import { bad, dim, heading, info, ok, warn } from "../ui.ts";
|
||||
|
||||
/**
|
||||
* 설치 기록을 그대로 따라가며 재설치한다.
|
||||
|
|
@ -42,15 +42,28 @@ export async function runUpdate(opts: { force?: boolean; dryRun?: boolean } = {}
|
|||
}
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const rec of manifest.installs) {
|
||||
console.log(info(`${rec.target} (${rec.scope}) ${dim(tildify(rec.root))} — ${rec.version} → ${version}`));
|
||||
// 기록이 있던 바로 그 자리를 다시 계획한다. cwd 를 쓰면 project 설치가 지금 디렉터리에 깔린다.
|
||||
let env;
|
||||
try {
|
||||
env = await envForRecord(rec);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(bad(`${rec.target}: ${err instanceof Error ? err.message : String(err)} — 건너뜁니다`));
|
||||
continue;
|
||||
}
|
||||
const outcomes = await runInstall({
|
||||
targets: [rec.target],
|
||||
scope: rec.scope,
|
||||
force: opts.force,
|
||||
dryRun: opts.dryRun,
|
||||
env,
|
||||
expectRoot: rec.root,
|
||||
});
|
||||
if (outcomes.some((o) => o.blocked)) failed++;
|
||||
printOutcomes(outcomes, opts.dryRun);
|
||||
}
|
||||
return 0;
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { readIfExists, updateCachePath, writeAtomic } from "@designpaca/core";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
/** 명령을 마친 뒤 종료를 붙잡는 최대 시간. 느린 네트워크에서 체감 지연을 줄이려고 짧게 둔다 */
|
||||
const TIMEOUT_MS = 1200;
|
||||
/** 레지스트리·캐시 값은 터미널에 그대로 찍히므로 semver 모양만 받는다(이스케이프 주입 차단) */
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const REGISTRY = "https://registry.npmjs.org/designpaca/latest";
|
||||
|
||||
interface Cache {
|
||||
|
|
@ -25,6 +29,9 @@ function isNewer(latest: string, current: string): boolean {
|
|||
*/
|
||||
export async function checkForUpdate(current: string): Promise<string | null> {
|
||||
if (process.env["DESIGNPACA_NO_UPDATE_CHECK"] === "1") return null;
|
||||
// CI·파이프 출력에서는 알림을 볼 사람이 없다 — 네트워크도 쓰지 않고 종료도 늦추지 않는다
|
||||
if (process.env["CI"] && process.env["CI"] !== "false" && process.env["CI"] !== "0") return null;
|
||||
if (!process.stdout.isTTY) return null;
|
||||
|
||||
const cachePath = updateCachePath();
|
||||
const raw = await readIfExists(cachePath);
|
||||
|
|
@ -32,7 +39,9 @@ export async function checkForUpdate(current: string): Promise<string | null> {
|
|||
try {
|
||||
const c = JSON.parse(raw) as Cache;
|
||||
if (Date.now() - c.checkedAt < DAY) {
|
||||
return isNewer(c.latest, current) ? c.latest : null;
|
||||
return typeof c.latest === "string" && SEMVER.test(c.latest) && isNewer(c.latest, current)
|
||||
? c.latest
|
||||
: null;
|
||||
}
|
||||
} catch {
|
||||
/* 캐시가 깨졌으면 새로 받는다 */
|
||||
|
|
@ -42,12 +51,12 @@ export async function checkForUpdate(current: string): Promise<string | null> {
|
|||
try {
|
||||
const res = await fetch(REGISTRY, {
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
signal: AbortSignal.timeout(2500),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { version?: string };
|
||||
const latest = body.version;
|
||||
if (!latest) return null;
|
||||
if (typeof latest !== "string" || !SEMVER.test(latest)) return null;
|
||||
await writeAtomic(cachePath, JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache));
|
||||
return isNewer(latest, current) ? latest : null;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
|
|
@ -24,29 +24,137 @@ export async function readIfExists(p: string): Promise<string | null> {
|
|||
}
|
||||
}
|
||||
|
||||
/** 임시 파일에 쓴 뒤 rename — 중간에 죽어도 반쯤 쓰인 파일이 남지 않는다 */
|
||||
export async function writeAtomic(p: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, content, "utf8");
|
||||
await fs.rename(tmp, p);
|
||||
/** 경로 자체가 심볼릭 링크(Windows 정션 포함)인가. 없으면 false */
|
||||
export async function isLink(p: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.lstat(p)).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용자가 고친 파일을 덮기 전에 원본을 남겨둔다 */
|
||||
/** Windows 는 경로 대소문자를 구분하지 않는다 — 같은 위치를 다른 표기로 비교해도 같게 본다 */
|
||||
function norm(p: string): string {
|
||||
const r = path.resolve(p);
|
||||
return process.platform === "win32" ? r.toLowerCase() : r;
|
||||
}
|
||||
|
||||
export function samePath(a: string, b: string): boolean {
|
||||
return norm(a) === norm(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* child 가 base 안쪽(같은 경로 제외)인가.
|
||||
* 문자열 접두사 비교는 `C:\a\b` 와 `C:\a\bc` 를 같은 것으로 본다 — path.relative 로 판정한다.
|
||||
*/
|
||||
export function isInside(base: string, child: string): boolean {
|
||||
const rel = path.relative(norm(base), norm(child));
|
||||
return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
export class UnsafePathError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnsafePathError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* base 아래 target 까지의 모든 경로 구성요소(target 포함)가 링크가 아닌지 확인한다.
|
||||
*
|
||||
* 실측 사고: 저장소에 `.claude` 정션·`AGENTS.md` 심링크를 두자 project 설치가 저장소 밖에
|
||||
* 스킬 46개 파일을 썼고, 링크 대상(비밀 파일) 내용이 AGENTS.md 로 복사됐다.
|
||||
* base 자신과 그 위는 검사하지 않는다 — 사용자가 링크 경로 안에서 작업하는 것은 정상이다.
|
||||
*/
|
||||
export async function assertNoLinksBelow(base: string, target: string): Promise<void> {
|
||||
if (!isInside(base, target)) {
|
||||
throw new UnsafePathError(`설치 경로가 기준 디렉터리 밖이다: ${target} (기준 ${base})`);
|
||||
}
|
||||
const rel = path.relative(path.resolve(base), path.resolve(target));
|
||||
let cur = path.resolve(base);
|
||||
for (const part of rel.split(path.sep)) {
|
||||
cur = path.join(cur, part);
|
||||
let st;
|
||||
try {
|
||||
st = await fs.lstat(cur);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // 아래는 아직 없다 — 새로 만든다
|
||||
throw err;
|
||||
}
|
||||
if (st.isSymbolicLink()) {
|
||||
throw new UnsafePathError(`링크(심볼릭 링크·정션)를 따라 쓰지 않는다: ${cur}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 디렉터리에 배타적으로 새 파일을 만든다(O_EXCL).
|
||||
* 이미 있는 경로(링크 포함)는 열지 않으므로 미리 심어둔 링크를 따라가 다른 파일을 덮지 않는다.
|
||||
*/
|
||||
async function createExclusive(p: string, content: string): Promise<void> {
|
||||
const fh = await fs.open(p, "wx");
|
||||
try {
|
||||
await fh.writeFile(content, "utf8");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일에 쓴 뒤 rename — 중간에 죽어도 반쯤 쓰인 파일이 남지 않는다.
|
||||
*
|
||||
* 임시 파일명은 예측할 수 없게 만들고 배타 생성한다. 예전 `<p>.<pid>.tmp` 는
|
||||
* 같은 이름의 심링크를 미리 심어두면 writeFile 이 링크를 따라가 임의 파일을 덮었다.
|
||||
*/
|
||||
export async function writeAtomic(p: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = path.join(
|
||||
path.dirname(p),
|
||||
`.${path.basename(p)}.${randomBytes(8).toString("hex")}.tmp`,
|
||||
);
|
||||
await createExclusive(tmp, content);
|
||||
try {
|
||||
await fs.rename(tmp, p);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 고친 파일을 덮기 전에 원본을 남겨둔다.
|
||||
*
|
||||
* 기존 백업은 덮지 않는다 — `.orig` 가 있으면 `.orig.1`, `.orig.2` … 로 비어 있는 이름을 찾는다.
|
||||
* 두 번 --force 하면 첫 번째 사용자 수정본이 사라지던 문제를 막는다.
|
||||
*/
|
||||
export async function backup(p: string): Promise<string | null> {
|
||||
const cur = await readIfExists(p);
|
||||
if (cur === null) return null;
|
||||
const dest = `${p}.orig`;
|
||||
await fs.writeFile(dest, cur, "utf8");
|
||||
return dest;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const dest = i === 0 ? `${p}.orig` : `${p}.orig.${i}`;
|
||||
try {
|
||||
await createExclusive(dest, cur);
|
||||
return dest;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error(`백업 파일 이름이 남아 있지 않다: ${p}.orig.*`);
|
||||
}
|
||||
|
||||
/** 파일을 지우고, 비게 된 상위 디렉터리를 stopAt 까지 정리한다 */
|
||||
/**
|
||||
* 파일을 지우고, 비게 된 상위 디렉터리를 stopAt 까지 정리한다.
|
||||
* stopAt 밖의 파일은 지우지 않는다(변조된 매니페스트가 임의 파일을 가리켜도 거부).
|
||||
*/
|
||||
export async function removeFileAndPrune(p: string, stopAt: string): Promise<void> {
|
||||
await fs.rm(p, { force: true });
|
||||
let dir = path.dirname(p);
|
||||
const stop = path.resolve(stopAt);
|
||||
while (path.resolve(dir).startsWith(stop) && path.resolve(dir) !== stop) {
|
||||
if (!isInside(stop, p)) {
|
||||
throw new UnsafePathError(`정리 기준 밖의 파일은 지우지 않는다: ${p} (기준 ${stop})`);
|
||||
}
|
||||
await fs.rm(p, { force: true });
|
||||
let dir = path.dirname(path.resolve(p));
|
||||
while (isInside(stop, dir)) {
|
||||
try {
|
||||
const rest = await fs.readdir(dir);
|
||||
if (rest.length > 0) break;
|
||||
|
|
@ -56,9 +164,9 @@ export async function removeFileAndPrune(p: string, stopAt: string): Promise<voi
|
|||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
// stopAt 자체도 비었으면 함께 정리한다
|
||||
// stopAt(어댑터가 만든 설치 루트) 자체도 비었으면 함께 정리한다
|
||||
try {
|
||||
if ((await fs.readdir(stop)).length === 0) await fs.rmdir(stop);
|
||||
if (!(await isLink(stop)) && (await fs.readdir(stop)).length === 0) await fs.rmdir(stop);
|
||||
} catch {
|
||||
/* 남아있으면 그대로 둔다 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { backup, readIfExists, removeFileAndPrune, sha256, writeAtomic } from "./fsx.ts";
|
||||
import {
|
||||
assertNoLinksBelow,
|
||||
backup,
|
||||
isInside,
|
||||
isLink,
|
||||
readIfExists,
|
||||
removeFileAndPrune,
|
||||
samePath,
|
||||
sha256,
|
||||
UnsafePathError,
|
||||
writeAtomic,
|
||||
} from "./fsx.ts";
|
||||
import { removeBlock, upsertBlock } from "./marker.ts";
|
||||
import { dropInstall, findInstall, inspectDrift, readManifest, upsertInstall } from "./manifest.ts";
|
||||
import { ADAPTERS, getAdapter } from "./targets/index.ts";
|
||||
import type {
|
||||
FileAction,
|
||||
InstalledFile,
|
||||
InstallPlan,
|
||||
InstallRecord,
|
||||
|
|
@ -28,6 +41,15 @@ function ctxOf(scope: Scope, skill: SkillSource, env: EnvOptions = {}): TargetCo
|
|||
};
|
||||
}
|
||||
|
||||
/** 경로 계산에만 쓰는 빈 스킬 — 어댑터의 root·주입 문서 위치를 알아낼 때 쓴다 */
|
||||
const EMPTY_SKILL: SkillSource = {
|
||||
version: "0.0.0",
|
||||
skillMd: "",
|
||||
files: new Map(),
|
||||
body: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
/** 시스템에 흔적이 있는 타깃을 골라준다 — TUI 의 기본 체크 상태로 쓴다 */
|
||||
export async function detectTargets(skill: SkillSource, env: EnvOptions = {}): Promise<TargetId[]> {
|
||||
const found: TargetId[] = [];
|
||||
|
|
@ -56,7 +78,63 @@ export async function planInstall(
|
|||
blocked: `${adapter.label} 은(는) ${only} 범위만 지원한다`,
|
||||
};
|
||||
}
|
||||
return adapter.plan(ctxOf(scope, skill, env));
|
||||
const ctx = ctxOf(scope, skill, env);
|
||||
const plan = await adapter.plan(ctx);
|
||||
return { ...plan, base: scope === "project" ? ctx.cwd : ctx.home };
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 기록이 만들어진 기준 디렉터리(project 면 프로젝트, user 면 홈)를 기록의 root 에서 역산한다.
|
||||
*
|
||||
* 실측 사고(P0): update 가 기록마다 target·scope 만 넘기고 root 를 버려, project 설치를
|
||||
* **현재 cwd** 에 다시 깔았다 — 무관한 저장소의 AGENTS.md 에 블록이 주입되고 사용자가 만든
|
||||
* 같은 이름의 스킬이 백업 없이 덮였다. 어댑터의 상대 경로는 고정이므로 root 에서 그 꼬리를
|
||||
* 떼면 기준 디렉터리가 나온다. 꼬리가 맞지 않으면 어댑터가 만들 수 없는 기록이다 — 거부한다.
|
||||
*/
|
||||
export async function envForRecord(record: InstallRecord): Promise<EnvOptions> {
|
||||
const adapter = getAdapter(record.target);
|
||||
if (!adapter.scopes.includes(record.scope)) {
|
||||
throw new UnsafePathError(`${record.target} 은(는) ${record.scope} 범위 기록을 가질 수 없다`);
|
||||
}
|
||||
if (typeof record.root !== "string" || !path.isAbsolute(record.root)) {
|
||||
throw new UnsafePathError(`설치 기록의 root 가 절대 경로가 아니다: ${String(record.root)}`);
|
||||
}
|
||||
const probe = path.resolve(os.tmpdir(), "designpaca-probe-base");
|
||||
const probed = await adapter.plan(ctxOf(record.scope, EMPTY_SKILL, { home: probe, cwd: probe }));
|
||||
const rel = path.relative(probe, probed.root);
|
||||
if (rel.length === 0 || rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new UnsafePathError(`${record.target} 어댑터의 설치 루트를 계산하지 못했다`);
|
||||
}
|
||||
const ups = rel.split(path.sep).map(() => "..");
|
||||
const base = path.resolve(record.root, ...ups);
|
||||
if (!samePath(path.join(base, rel), record.root)) {
|
||||
throw new UnsafePathError(
|
||||
`설치 기록의 root 가 ${record.target} 어댑터가 만드는 위치가 아니다: ${record.root}`,
|
||||
);
|
||||
}
|
||||
return record.scope === "project" ? { cwd: base } : { home: base };
|
||||
}
|
||||
|
||||
/** 기록이 있던 바로 그 자리를 다시 계획한다. root 가 달라지면 막힌 계획을 돌려준다 */
|
||||
export async function planForRecord(record: InstallRecord, skill: SkillSource): Promise<InstallPlan> {
|
||||
let env: EnvOptions;
|
||||
try {
|
||||
env = await envForRecord(record);
|
||||
} catch (err) {
|
||||
return {
|
||||
target: record.target,
|
||||
scope: record.scope,
|
||||
root: record.root,
|
||||
actions: [],
|
||||
alreadyInstalled: true,
|
||||
blocked: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const plan = await planInstall(record.target, record.scope, skill, env);
|
||||
if (!plan.blocked && !samePath(plan.root, record.root)) {
|
||||
return { ...plan, actions: [], blocked: `계획한 위치(${plan.root})가 기록(${record.root})과 다르다` };
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
|
|
@ -67,25 +145,87 @@ export interface ApplyResult {
|
|||
skipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 전에 계획 전체의 경로 안전성을 확인한다 — 하나라도 걸리면 아무것도 쓰지 않는다.
|
||||
* - project 범위: 프로젝트 디렉터리 아래의 경로 구성요소가 링크면 거부한다(저장소가 심어둔
|
||||
* `.claude` 정션·`AGENTS.md` 심링크를 따라 저장소 밖에 쓰거나 남의 파일 내용을 끌어오지 않는다).
|
||||
* - 모든 범위: 마커 주입 대상 문서가 링크면 거부한다. 링크 대상 내용을 읽어 일반 파일로 바꿔치기하게 된다.
|
||||
*/
|
||||
async function assertPlanSafe(plan: InstallPlan): Promise<void> {
|
||||
for (const action of plan.actions) {
|
||||
if (plan.scope === "project" && plan.base) {
|
||||
await assertNoLinksBelow(plan.base, action.path);
|
||||
}
|
||||
if (action.kind === "inject" && (await isLink(action.path))) {
|
||||
throw new UnsafePathError(
|
||||
`${action.path} 가 링크다 — 따라가 쓰지 않는다. 블록을 직접 넣거나 링크를 일반 파일로 바꾼 뒤 다시 실행해 주세요`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(p: string): string {
|
||||
const r = path.resolve(p);
|
||||
return process.platform === "win32" ? r.toLowerCase() : r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획을 실제로 디스크에 반영한다.
|
||||
*
|
||||
* 업그레이드 안전장치: 이전 설치 기록이 있으면 각 파일의 해시를 비교해서,
|
||||
* 사용자가 손댄 파일은 기본적으로 건드리지 않는다(force 로만 덮고, 덮을 때도 .orig 로 남긴다).
|
||||
* 이전 기록이 없는 파일이 이미 있고 내용이 다르면(매니페스트 손상·수동 설치·같은 이름의 남의 스킬)
|
||||
* 덮기 전에 .orig 로 남긴다.
|
||||
*/
|
||||
export async function applyPlan(
|
||||
plan: InstallPlan,
|
||||
version: string,
|
||||
opts: { force?: boolean; env?: EnvOptions } = {},
|
||||
opts: { force?: boolean; env?: EnvOptions; expectRoot?: string } = {},
|
||||
): Promise<ApplyResult> {
|
||||
if (plan.blocked) throw new Error(plan.blocked);
|
||||
if (opts.expectRoot !== undefined && !samePath(plan.root, opts.expectRoot)) {
|
||||
throw new UnsafePathError(`계획한 위치(${plan.root})가 기록(${opts.expectRoot})과 다르다 — 쓰지 않는다`);
|
||||
}
|
||||
const home = opts.env?.home;
|
||||
|
||||
const prev = findInstall(await readManifest(home), plan.target, plan.scope, plan.root);
|
||||
const modified = new Set<string>();
|
||||
const recorded = new Set<string>();
|
||||
if (prev) {
|
||||
const drift = await inspectDrift(prev);
|
||||
for (const p of drift.modified) modified.add(p);
|
||||
for (const p of drift.modified) modified.add(keyOf(p));
|
||||
for (const f of prev.files) recorded.add(keyOf(f.path));
|
||||
}
|
||||
|
||||
await assertPlanSafe(plan);
|
||||
|
||||
// 1차: 무엇을 쓸지 전부 정한다. 마커가 꼬인 문서 등은 여기서 실패해 반쯤 쓰인 설치를 남기지 않는다.
|
||||
type Step =
|
||||
| { action: FileAction; skip: true }
|
||||
| { action: FileAction; skip: false; next: string; backupFirst: boolean };
|
||||
const steps: Step[] = [];
|
||||
for (const action of plan.actions) {
|
||||
const isDirty = modified.has(keyOf(action.path));
|
||||
if (isDirty && !opts.force) {
|
||||
steps.push({ action, skip: true });
|
||||
continue;
|
||||
}
|
||||
if (action.kind === "write") {
|
||||
let backupFirst = isDirty;
|
||||
if (!isDirty && !recorded.has(keyOf(action.path))) {
|
||||
const cur = await readIfExists(action.path);
|
||||
backupFirst = cur !== null && sha256(cur) !== sha256(action.content);
|
||||
}
|
||||
steps.push({ action, skip: false, next: action.content, backupFirst });
|
||||
} else {
|
||||
const cur = (await readIfExists(action.path)) ?? "";
|
||||
steps.push({
|
||||
action,
|
||||
skip: false,
|
||||
next: upsertBlock(cur, action.marker, action.content),
|
||||
backupFirst: isDirty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files: InstalledFile[] = [];
|
||||
|
|
@ -93,13 +233,13 @@ export async function applyPlan(
|
|||
const backedUp: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const action of plan.actions) {
|
||||
const isDirty = modified.has(action.path);
|
||||
if (isDirty && !opts.force) {
|
||||
for (const step of steps) {
|
||||
const { action } = step;
|
||||
if (step.skip) {
|
||||
// 건너뛰더라도 매니페스트에서 빠지면 uninstall 이 이 파일을 놓친다 — 기록은 남긴다.
|
||||
// 이때 "현재" 해시를 쓰면 안 된다. 그러면 다음 update 에서 드리프트가 사라져
|
||||
// 사용자가 고친 파일을 조용히 덮어쓰게 된다. 설치 당시 해시를 그대로 유지한다.
|
||||
const kept = prev?.files.find((f) => f.path === action.path);
|
||||
const kept = prev?.files.find((f) => keyOf(f.path) === keyOf(action.path));
|
||||
files.push(
|
||||
kept ?? {
|
||||
path: action.path,
|
||||
|
|
@ -110,18 +250,14 @@ export async function applyPlan(
|
|||
skipped.push(action.path);
|
||||
continue;
|
||||
}
|
||||
if (isDirty && opts.force) {
|
||||
if (step.backupFirst) {
|
||||
const b = await backup(action.path);
|
||||
if (b) backedUp.push(b);
|
||||
}
|
||||
|
||||
await writeAtomic(action.path, step.next);
|
||||
if (action.kind === "write") {
|
||||
await writeAtomic(action.path, action.content);
|
||||
files.push({ path: action.path, sha256: sha256(action.content) });
|
||||
} else {
|
||||
const cur = (await readIfExists(action.path)) ?? "";
|
||||
const next = upsertBlock(cur, action.marker, action.content);
|
||||
await writeAtomic(action.path, next);
|
||||
// 마커 방식은 블록 안쪽만 해시한다 — 문서의 다른 부분은 사용자 자유다
|
||||
files.push({ path: action.path, sha256: sha256(action.content.trim()), marker: action.marker });
|
||||
}
|
||||
|
|
@ -144,19 +280,67 @@ export async function applyPlan(
|
|||
export interface RemoveResult {
|
||||
removed: string[];
|
||||
keptModified: string[];
|
||||
/** 경계 검사에 걸려 손대지 않은 파일(기록 root 밖·링크·꼬인 마커). 기록에 남긴다 */
|
||||
refused: string[];
|
||||
}
|
||||
|
||||
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */
|
||||
/**
|
||||
* 기록 파일이 그 기록이 지울 수 있는 자리인지 판정한다.
|
||||
* 변조된 매니페스트가 root 밖의 임의 파일을 가리켜도 지우지 않는다(실측: 가짜 기록 하나로
|
||||
* 사용자 파일과 상위 빈 디렉터리까지 지워졌다).
|
||||
*/
|
||||
async function removalGuard(
|
||||
record: InstallRecord,
|
||||
): Promise<(f: InstalledFile) => Promise<string | null>> {
|
||||
let env: EnvOptions;
|
||||
let injectPaths: string[];
|
||||
try {
|
||||
env = await envForRecord(record);
|
||||
const expected = await planInstall(record.target, record.scope, EMPTY_SKILL, env);
|
||||
if (expected.blocked || !samePath(expected.root, record.root)) throw new Error("root 불일치");
|
||||
injectPaths = expected.actions.filter((a) => a.kind === "inject").map((a) => a.path);
|
||||
} catch (err) {
|
||||
const why = err instanceof Error ? err.message : String(err);
|
||||
return async () => `기록을 신뢰할 수 없다 (${why})`;
|
||||
}
|
||||
const base = record.scope === "project" ? env.cwd : undefined;
|
||||
|
||||
return async (f) => {
|
||||
if (typeof f.path !== "string" || !path.isAbsolute(f.path)) return "절대 경로가 아니다";
|
||||
if (f.marker) {
|
||||
if (!injectPaths.some((p) => samePath(p, f.path))) return "어댑터가 주입하는 문서가 아니다";
|
||||
if (await isLink(f.path)) return "링크다";
|
||||
} else if (!isInside(record.root, f.path)) {
|
||||
return "설치 루트 밖이다";
|
||||
}
|
||||
if (base) {
|
||||
try {
|
||||
await assertNoLinksBelow(base, f.path);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일, 기록 root 밖의 파일은 손대지 않는다. */
|
||||
export async function removeInstall(
|
||||
record: InstallRecord,
|
||||
opts: { force?: boolean; dryRun?: boolean; env?: EnvOptions } = {},
|
||||
): Promise<RemoveResult> {
|
||||
const drift = await inspectDrift(record);
|
||||
const dirty = new Set(drift.modified);
|
||||
const guard = await removalGuard(record);
|
||||
const removed: string[] = [];
|
||||
const keptModified: string[] = [];
|
||||
const refused: string[] = [];
|
||||
|
||||
for (const f of record.files) {
|
||||
if (await guard(f)) {
|
||||
refused.push(f.path);
|
||||
continue;
|
||||
}
|
||||
if (dirty.has(f.path) && !opts.force) {
|
||||
keptModified.push(f.path);
|
||||
continue;
|
||||
|
|
@ -164,10 +348,17 @@ export async function removeInstall(
|
|||
if (f.marker) {
|
||||
const cur = await readIfExists(f.path);
|
||||
if (cur === null) continue;
|
||||
let next: string;
|
||||
try {
|
||||
next = removeBlock(cur, f.marker);
|
||||
} catch {
|
||||
// 마커가 꼬여 있으면 어디까지가 우리 블록인지 확신할 수 없다 — 문서를 건드리지 않는다
|
||||
refused.push(f.path);
|
||||
continue;
|
||||
}
|
||||
if (!opts.dryRun) {
|
||||
const next = removeBlock(cur, f.marker);
|
||||
// 블록만 남아 있던 문서라면 파일째 지운다
|
||||
if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path));
|
||||
// 블록만 남아 있던 문서라면 파일째 지운다(사용자 디렉터리는 정리하지 않는다)
|
||||
if (next.trim().length === 0) await fs.rm(f.path, { force: true });
|
||||
else await writeAtomic(f.path, next);
|
||||
}
|
||||
} else if (!opts.dryRun) {
|
||||
|
|
@ -177,13 +368,14 @@ export async function removeInstall(
|
|||
}
|
||||
|
||||
if (!opts.dryRun) {
|
||||
if (keptModified.length === 0) {
|
||||
const keep = new Set([...keptModified, ...refused]);
|
||||
if (keep.size === 0) {
|
||||
await dropInstall(record.target, record.scope, record.root, opts.env?.home);
|
||||
} else {
|
||||
// 일부만 남았으면 기록도 남은 것만 유지한다
|
||||
await upsertInstall({ ...record, files: record.files.filter((f) => dirty.has(f.path)) }, opts.env?.home);
|
||||
await upsertInstall({ ...record, files: record.files.filter((f) => keep.has(f.path)) }, opts.env?.home);
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, keptModified };
|
||||
return { removed, keptModified, refused };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { manifestPath } from "./paths.ts";
|
||||
import { readIfExists, sha256, writeAtomic } from "./fsx.ts";
|
||||
import { readIfExists, samePath, sha256, writeAtomic } from "./fsx.ts";
|
||||
import { extractBlock } from "./marker.ts";
|
||||
import type { DriftReport, InstallRecord, Manifest, Scope, TargetId } from "./types.ts";
|
||||
|
||||
|
|
@ -10,22 +9,34 @@ const EMPTY: Manifest = { schema: 1, installs: [] };
|
|||
export async function readManifest(home?: string): Promise<Manifest> {
|
||||
const raw = await readIfExists(manifestPath(home));
|
||||
if (!raw) return structuredClone(EMPTY);
|
||||
// 손상된 매니페스트로 설치 전체가 막히지 않도록 빈 것으로 되돌린다(원본은 writeManifest 가 보존한다)
|
||||
return parseManifest(raw) ?? structuredClone(EMPTY);
|
||||
}
|
||||
|
||||
function parseManifest(raw: string): Manifest | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Manifest;
|
||||
if (parsed.schema !== 1 || !Array.isArray(parsed.installs)) return structuredClone(EMPTY);
|
||||
if (parsed.schema !== 1 || !Array.isArray(parsed.installs)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
// 손상된 매니페스트로 설치 전체가 막히지 않도록 빈 것으로 되돌린다
|
||||
return structuredClone(EMPTY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeManifest(m: Manifest, home?: string): Promise<void> {
|
||||
await writeAtomic(manifestPath(home), JSON.stringify(m, null, 2) + "\n");
|
||||
const file = manifestPath(home);
|
||||
// 손상된 매니페스트를 빈 것으로 읽은 뒤 그대로 덮으면 다른 설치 기록이 영구 소실된다.
|
||||
// 덮기 전에 원본을 manifest.json.corrupt-<시각> 으로 옮겨 복구 여지를 남긴다.
|
||||
const prev = await readIfExists(file);
|
||||
if (prev !== null && prev.trim().length > 0 && parseManifest(prev) === null) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
await fs.rename(file, `${file}.corrupt-${stamp}`).catch(() => undefined);
|
||||
}
|
||||
await writeAtomic(file, JSON.stringify(m, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function sameInstall(a: InstallRecord, target: TargetId, scope: Scope, root: string): boolean {
|
||||
return a.target === target && a.scope === scope && path.resolve(a.root) === path.resolve(root);
|
||||
return a.target === target && a.scope === scope && samePath(a.root, root);
|
||||
}
|
||||
|
||||
export function findInstall(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
/**
|
||||
* AGENTS.md 같은 사용자 소유 문서에 우리 블록만 안전하게 심고 빼기 위한 마커 처리.
|
||||
* 블록 밖의 내용은 절대 건드리지 않는다.
|
||||
*
|
||||
* 마커는 **한 줄 전체가 마커일 때만** 인식한다. 실측 사고: 사용자 문서 본문이
|
||||
* "designpaca 는 `<!-- designpaca:start -->` 블록을 쓴다" 처럼 마커를 언급하자, 첫 start ~ 첫 end
|
||||
* 매칭이 그 언급부터 실제 블록 끝까지(사용자 규칙 섹션 포함)를 교체·삭제했다.
|
||||
* start/end 줄이 각각 정확히 하나가 아니면 쓰지 않고 오류로 멈춘다.
|
||||
*/
|
||||
|
||||
export function startTag(marker: string): string {
|
||||
|
|
@ -10,42 +15,104 @@ export function endTag(marker: string): string {
|
|||
return `<!-- ${marker}:end -->`;
|
||||
}
|
||||
|
||||
export function hasBlock(doc: string, marker: string): boolean {
|
||||
return doc.includes(startTag(marker)) && doc.includes(endTag(marker));
|
||||
export class MarkerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "MarkerError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 블록이 있으면 내용만 교체, 없으면 문서 끝에 덧붙인다 */
|
||||
export function upsertBlock(doc: string, marker: string, content: string): string {
|
||||
interface BlockSpan {
|
||||
/** start 줄의 시작 오프셋 */
|
||||
from: number;
|
||||
/** end 줄 끝(줄바꿈 포함) 오프셋 */
|
||||
to: number;
|
||||
/** start 줄 다음 ~ end 줄 앞 (블록 내부) */
|
||||
innerFrom: number;
|
||||
innerTo: number;
|
||||
}
|
||||
|
||||
type Located = { kind: "none" } | { kind: "one"; span: BlockSpan } | { kind: "ambiguous"; reason: string };
|
||||
|
||||
/** 문서를 줄 단위로 훑어 마커 줄을 찾는다. 줄 끝 공백·CR 은 무시한다 */
|
||||
function locate(doc: string, marker: string): Located {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const block = `${s}\n${content.trim()}\n${e}`;
|
||||
const starts: { from: number; next: number }[] = [];
|
||||
const ends: { from: number; next: number }[] = [];
|
||||
let pos = 0;
|
||||
while (pos <= doc.length) {
|
||||
const nl = doc.indexOf("\n", pos);
|
||||
const lineEnd = nl === -1 ? doc.length : nl;
|
||||
const next = nl === -1 ? doc.length : nl + 1;
|
||||
const line = doc.slice(pos, lineEnd).trim();
|
||||
if (line === s) starts.push({ from: pos, next });
|
||||
else if (line === e) ends.push({ from: pos, next });
|
||||
if (nl === -1) break;
|
||||
pos = next;
|
||||
}
|
||||
if (starts.length === 0 && ends.length === 0) return { kind: "none" };
|
||||
if (starts.length !== 1 || ends.length !== 1) {
|
||||
return {
|
||||
kind: "ambiguous",
|
||||
reason: `${marker} 마커 줄이 start ${starts.length}개 · end ${ends.length}개다 (각각 하나여야 한다)`,
|
||||
};
|
||||
}
|
||||
const st = starts[0]!;
|
||||
const en = ends[0]!;
|
||||
if (en.from < st.next) {
|
||||
return { kind: "ambiguous", reason: `${marker} end 마커가 start 보다 앞에 있다` };
|
||||
}
|
||||
return {
|
||||
kind: "one",
|
||||
span: { from: st.from, to: en.next, innerFrom: st.next, innerTo: en.from },
|
||||
};
|
||||
}
|
||||
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si !== -1 && ei !== -1 && ei > si) {
|
||||
return doc.slice(0, si) + block + doc.slice(ei + e.length);
|
||||
export function hasBlock(doc: string, marker: string): boolean {
|
||||
return locate(doc, marker).kind === "one";
|
||||
}
|
||||
|
||||
/** 블록이 있으면 내용만 교체, 없으면 문서 끝에 덧붙인다. 마커가 꼬여 있으면 MarkerError */
|
||||
export function upsertBlock(doc: string, marker: string, content: string): string {
|
||||
const block = `${startTag(marker)}\n${content.trim()}\n${endTag(marker)}`;
|
||||
const loc = locate(doc, marker);
|
||||
if (loc.kind === "ambiguous") throw new MarkerError(loc.reason);
|
||||
if (loc.kind === "one") {
|
||||
const { from, to } = loc.span;
|
||||
// end 줄의 줄바꿈은 원문 그대로 이어 붙인다(마지막 줄이었다면 줄바꿈 없이)
|
||||
const tail = doc.slice(to);
|
||||
const eol = to > 0 && doc[to - 1] === "\n" ? (doc[to - 2] === "\r" ? "\r\n" : "\n") : "";
|
||||
return doc.slice(0, from) + block + eol + tail;
|
||||
}
|
||||
const base = doc.trimEnd();
|
||||
return base.length > 0 ? `${base}\n\n${block}\n` : `${block}\n`;
|
||||
}
|
||||
|
||||
/** 블록만 제거하고 나머지 문서는 보존한다 */
|
||||
/**
|
||||
* 블록만 제거하고 나머지 문서는 보존한다.
|
||||
* 문서 전체를 정규화하지 않는다 — 블록과, 블록을 붙일 때 생긴 구분 빈 줄 하나만 걷어낸다.
|
||||
* 마커가 꼬여 있으면 MarkerError.
|
||||
*/
|
||||
export function removeBlock(doc: string, marker: string): string {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si === -1 || ei === -1 || ei < si) return doc;
|
||||
const out = doc.slice(0, si) + doc.slice(ei + e.length);
|
||||
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
||||
const loc = locate(doc, marker);
|
||||
if (loc.kind === "none") return doc;
|
||||
if (loc.kind === "ambiguous") throw new MarkerError(loc.reason);
|
||||
let before = doc.slice(0, loc.span.from);
|
||||
let after = doc.slice(loc.span.to);
|
||||
if (after.startsWith("\r\n")) after = after.slice(2);
|
||||
else if (after.startsWith("\n")) after = after.slice(1);
|
||||
else if (after.length === 0) {
|
||||
// 문서 끝 블록 — upsertBlock 이 넣은 앞쪽 구분 빈 줄 하나를 걷는다
|
||||
if (before.endsWith("\r\n\r\n")) before = before.slice(0, -2);
|
||||
else if (before.endsWith("\n\n")) before = before.slice(0, -1);
|
||||
}
|
||||
return before + after;
|
||||
}
|
||||
|
||||
/** 블록 내부 내용만 뽑아낸다 (드리프트 판정용) */
|
||||
/** 블록 내부 내용만 뽑아낸다 (드리프트 판정용). 없거나 꼬여 있으면 null */
|
||||
export function extractBlock(doc: string, marker: string): string | null {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si === -1 || ei === -1 || ei < si) return null;
|
||||
return doc.slice(si + s.length, ei).trim();
|
||||
const loc = locate(doc, marker);
|
||||
if (loc.kind !== "one") return null;
|
||||
return doc.slice(loc.span.innerFrom, loc.span.innerTo).trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export async function loadSkillSource(root: string, version: string): Promise<Sk
|
|||
for (const rel of await walk(root)) {
|
||||
if (rel === "SKILL.md") continue;
|
||||
if (rel.endsWith(".orig") || rel === ".designpaca_version") continue;
|
||||
// 개발용 테스트는 사용자 스킬 디렉터리에 설치하지 않는다(번들 허용 목록과 같은 규칙의 방어선)
|
||||
if (/\.test\.[cm]?[jt]s$/.test(rel)) continue;
|
||||
files.set(rel, await fs.readFile(path.join(root, rel), "utf8"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,23 +16,58 @@ import path from "node:path";
|
|||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
export interface OptionalTool {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 이게 없으면 무엇을 못 하는가 — 고르는 사람이 판단할 수 있게 */
|
||||
why: string;
|
||||
/** 설치 크기 어림값. 수백 MB 를 말없이 받게 하지 않는다 */
|
||||
size: string;
|
||||
detect(): Promise<boolean>;
|
||||
install(): Promise<void>;
|
||||
/** 설치 후 사람이 직접 해야 하는 것이 있으면 여기에 */
|
||||
followUp?: string;
|
||||
/**
|
||||
* 검증한 버전으로 고정한다 — 상위 패키지가 탈취되거나 깨진 새 버전이 나와도 그대로 받지 않는다.
|
||||
* CLI 릴리스마다 확인해서 올린다. playwright 는 apps/site 가 실제로 돌리는 버전과 맞춘다.
|
||||
*/
|
||||
export const TOOL_VERSIONS = {
|
||||
playwright: "1.62.1",
|
||||
playwrightMcp: "0.0.83",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* npm/npx 를 셸 없이 실행한다.
|
||||
*
|
||||
* 실측 사고: `exec("npm", …, { shell: true })` 는 Windows 에서 `cmd.exe /c npm` 이 되고,
|
||||
* cmd 는 PATH 보다 **현재 디렉터리를 먼저** 찾는다. 클론한 저장소에 `npm.cmd` 하나만 두면
|
||||
* `designpaca tools`(상태 조회만 해도)와 온보딩에서 임의 코드가 실행됐다.
|
||||
* 그래서 지금 돌고 있는 node 옆의 npm-cli.js·npx-cli.js 를 절대 경로로 찾아 `node` 로 직접 돌린다.
|
||||
* cwd 는 임시 디렉터리로 고정한다 — npx 가 cwd 의 node_modules/.bin 을 먼저 쓰는 것도 막는다.
|
||||
*/
|
||||
async function npmCliPath(which: "npm" | "npx"): Promise<string | null> {
|
||||
const dir = path.dirname(process.execPath);
|
||||
const file = which === "npm" ? "npm-cli.js" : "npx-cli.js";
|
||||
const candidates = [
|
||||
// Windows 공식 설치·nvm-windows: node.exe 옆 node_modules
|
||||
path.join(dir, "node_modules", "npm", "bin", file),
|
||||
// macOS·Linux: <prefix>/bin/node → <prefix>/lib/node_modules
|
||||
path.join(dir, "..", "lib", "node_modules", "npm", "bin", file),
|
||||
];
|
||||
for (const c of candidates) if (await exists(c)) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runNpm(which: "npm" | "npx", args: string[]): Promise<{ stdout: string }> {
|
||||
const cli = await npmCliPath(which);
|
||||
const opts = {
|
||||
cwd: os.tmpdir(),
|
||||
maxBuffer: 1 << 24,
|
||||
env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" },
|
||||
windowsHide: true,
|
||||
};
|
||||
if (cli) return exec(process.execPath, [cli, ...args], opts);
|
||||
if (process.platform === "win32") {
|
||||
// .cmd 는 셸 없이 실행할 수 없다. 셸로 돌리면 위의 바꿔치기가 다시 열리므로 포기한다.
|
||||
throw new Error(`${which} 를 찾지 못했습니다 — node 와 함께 설치된 npm 이 필요합니다`);
|
||||
}
|
||||
// POSIX 의 PATH 검색은 cwd 를 보지 않는다(PATH 에 . 이 없으면). 셸은 쓰지 않는다.
|
||||
return exec(which, args, opts);
|
||||
}
|
||||
|
||||
/** npm 전역 루트. Windows 경로에 공백이 있어도 안전하게 얻는다 */
|
||||
async function npmRoot(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await exec("npm", ["root", "-g"], { shell: true });
|
||||
const { stdout } = await runNpm("npm", ["root", "-g"]);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -48,6 +83,19 @@ async function exists(p: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
export interface OptionalTool {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 이게 없으면 무엇을 못 하는가 — 고르는 사람이 판단할 수 있게 */
|
||||
why: string;
|
||||
/** 설치 크기 어림값. 수백 MB 를 말없이 받게 하지 않는다 */
|
||||
size: string;
|
||||
detect(): Promise<boolean>;
|
||||
install(): Promise<void>;
|
||||
/** 설치 후 사람이 직접 해야 하는 것이 있으면 여기에 */
|
||||
followUp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright — 레퍼런스 조사와 프리플라이트 실렌더 검사에 쓴다.
|
||||
*
|
||||
|
|
@ -79,11 +127,9 @@ const playwright: OptionalTool = {
|
|||
},
|
||||
|
||||
async install() {
|
||||
await exec("npm", ["install", "-g", "playwright"], { shell: true, maxBuffer: 1 << 24 });
|
||||
await exec("npx", ["--yes", "playwright", "install", "chromium"], {
|
||||
shell: true,
|
||||
maxBuffer: 1 << 24,
|
||||
});
|
||||
const pkg = `playwright@${TOOL_VERSIONS.playwright}`;
|
||||
await runNpm("npm", ["install", "-g", pkg]);
|
||||
await runNpm("npx", ["--yes", pkg, "install", "chromium"]);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -98,7 +144,7 @@ const playwrightMcp: OptionalTool = {
|
|||
label: "Playwright MCP 서버",
|
||||
why: "에이전트가 브라우저를 직접 조작한다. 스크립트를 매번 쓰지 않아도 된다",
|
||||
size: "약 2MB",
|
||||
followUp: "claude mcp add playwright -- npx @playwright/mcp@latest",
|
||||
followUp: `claude mcp add playwright -- npx @playwright/mcp@${TOOL_VERSIONS.playwrightMcp}`,
|
||||
|
||||
async detect() {
|
||||
const root = await npmRoot();
|
||||
|
|
@ -107,7 +153,7 @@ const playwrightMcp: OptionalTool = {
|
|||
},
|
||||
|
||||
async install() {
|
||||
await exec("npm", ["install", "-g", "@playwright/mcp"], { shell: true, maxBuffer: 1 << 24 });
|
||||
await runNpm("npm", ["install", "-g", `@playwright/mcp@${TOOL_VERSIONS.playwrightMcp}`]);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ export interface InstallPlan {
|
|||
scope: Scope;
|
||||
/** 이 타깃이 파일을 쓰는 루트 (표시용) */
|
||||
root: string;
|
||||
/**
|
||||
* 계획의 기준 디렉터리 — project 범위면 프로젝트 디렉터리, user 범위면 홈.
|
||||
* applyPlan 이 이 아래의 링크(심링크·정션)를 거부하는 경계로 쓴다. planInstall 이 채운다.
|
||||
*/
|
||||
base?: string;
|
||||
actions: FileAction[];
|
||||
/** 이미 설치돼 있는가 */
|
||||
alreadyInstalled: boolean;
|
||||
|
|
|
|||
|
|
@ -48,3 +48,38 @@ test("블록이 없는 문서를 제거해도 그대로다", () => {
|
|||
const doc = "# 문서\n";
|
||||
assert.equal(removeBlock(doc, M), doc);
|
||||
});
|
||||
|
||||
test("본문이 마커 문자열을 언급해도 줄 전체가 마커인 블록만 인식한다", () => {
|
||||
const doc =
|
||||
"# 규칙\n\ndesignpaca 는 `<!-- designpaca:start -->` 블록을 쓴다.\n\n## 내 규칙\n\n지우지 마라.\n";
|
||||
const once = upsertBlock(doc, M, "v1");
|
||||
assert.ok(once.includes("## 내 규칙"));
|
||||
const twice = upsertBlock(once, M, "v2");
|
||||
assert.ok(twice.includes("## 내 규칙") && twice.includes("지우지 마라."));
|
||||
assert.equal(extractBlock(twice, M), "v2");
|
||||
const removed = removeBlock(twice, M);
|
||||
assert.equal(removed, doc);
|
||||
});
|
||||
|
||||
test("start/end 줄이 여러 개거나 짝이 맞지 않으면 쓰지 않고 오류로 멈춘다", () => {
|
||||
const twoBlocks = upsertBlock("앞\n", M, "a") + "\n" + upsertBlock("", M, "b");
|
||||
assert.throws(() => upsertBlock(twoBlocks, M, "c"), /start 2개/);
|
||||
assert.throws(() => removeBlock(twoBlocks, M));
|
||||
assert.equal(extractBlock(twoBlocks, M), null);
|
||||
|
||||
const endOnly = "<!-- designpaca:end -->\n본문\n";
|
||||
assert.throws(() => upsertBlock(endOnly, M, "x"));
|
||||
const reversed = "<!-- designpaca:end -->\n<!-- designpaca:start -->\n";
|
||||
assert.throws(() => upsertBlock(reversed, M, "x"));
|
||||
});
|
||||
|
||||
test("제거는 블록 밖의 연속 빈 줄을 정규화하지 않는다", () => {
|
||||
const doc = "```\na\n\n\n\nb\n```\n";
|
||||
const out = removeBlock(upsertBlock(doc, M, "블록"), M);
|
||||
assert.equal(out, doc);
|
||||
});
|
||||
|
||||
test("문서 중간 블록을 빼면 블록 앞뒤 구분 빈 줄 하나만 걷힌다", () => {
|
||||
const doc = "A\n\n<!-- designpaca:start -->\nx\n<!-- designpaca:end -->\n\nB\n";
|
||||
assert.equal(removeBlock(doc, M), "A\n\nB\n");
|
||||
});
|
||||
|
|
|
|||
207
packages/core/test/safety.test.ts
Normal file
207
packages/core/test/safety.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { assertNoLinksBelow, backup, removeFileAndPrune, sha256, writeAtomic } from "../src/fsx.ts";
|
||||
import { applyPlan, envForRecord, planForRecord, planInstall, removeInstall } from "../src/installer.ts";
|
||||
import { readManifest, writeManifest } from "../src/manifest.ts";
|
||||
import type { InstallRecord, SkillSource } from "../src/types.ts";
|
||||
|
||||
// 보안 감사(2026-10-01) 재현 시나리오를 회귀 테스트로 고정한다. 모든 쓰기는 임시 디렉터리 안에서만 한다.
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-safety-"));
|
||||
after(async () => {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const skill: SkillSource = {
|
||||
version: "9.9.9",
|
||||
skillMd: ["---", "name: designpaca", 'description: "테스트용"', "---", "", "# 본문", ""].join("\n"),
|
||||
files: new Map([["references/tokens.md", "# 토큰\n"]]),
|
||||
body: "# 본문",
|
||||
description: "테스트용",
|
||||
};
|
||||
|
||||
async function fresh(name: string): Promise<{ home: string; cwd: string; outside: string }> {
|
||||
const dir = path.join(tmp, name);
|
||||
const home = path.join(dir, "home");
|
||||
const cwd = path.join(dir, "proj");
|
||||
const outside = path.join(dir, "outside");
|
||||
for (const d of [home, cwd, outside]) await fs.mkdir(d, { recursive: true });
|
||||
return { home, cwd, outside };
|
||||
}
|
||||
|
||||
async function listAll(dir: string): Promise<string[]> {
|
||||
try {
|
||||
return (await fs.readdir(dir, { recursive: true })).map(String).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 디렉터리 링크 — Windows 는 관리자 권한 없이 만들 수 있는 정션을 쓴다 */
|
||||
async function linkDir(target: string, link: string): Promise<void> {
|
||||
await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
|
||||
}
|
||||
|
||||
/** 파일 심링크는 Windows 에서 권한이 없으면 만들 수 없다 — 그때는 false (해당 검증만 생략) */
|
||||
async function tryLinkFile(target: string, link: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.symlink(target, link, "file");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test("writeAtomic: 임시 파일을 남기지 않고, 예측 가능한 <p>.<pid>.tmp 를 쓰지 않는다", async () => {
|
||||
const { outside, cwd } = await fresh("atomic");
|
||||
const victim = path.join(outside, "bashrc");
|
||||
await fs.writeFile(victim, "원래 내용\n", "utf8");
|
||||
const p = path.join(cwd, "AGENTS.md");
|
||||
// 예전 구현이 쓰던 이름에 링크를 심어둬도 따라가지 않는다
|
||||
await tryLinkFile(victim, `${p}.${process.pid}.tmp`);
|
||||
|
||||
await writeAtomic(p, "새 내용\n");
|
||||
assert.equal(await fs.readFile(p, "utf8"), "새 내용\n");
|
||||
assert.equal(await fs.readFile(victim, "utf8"), "원래 내용\n");
|
||||
const leftovers = (await fs.readdir(cwd)).filter((n) => n.endsWith(".tmp") && !n.includes(String(process.pid)));
|
||||
assert.deepEqual(leftovers, []);
|
||||
});
|
||||
|
||||
test("backup: 기존 .orig 를 덮지 않고 .orig.1 로 회전한다", async () => {
|
||||
const { cwd } = await fresh("backup");
|
||||
const p = path.join(cwd, "SKILL.md");
|
||||
await fs.writeFile(p, "첫 수정\n", "utf8");
|
||||
assert.equal(await backup(p), `${p}.orig`);
|
||||
await fs.writeFile(p, "두 번째 수정\n", "utf8");
|
||||
assert.equal(await backup(p), `${p}.orig.1`);
|
||||
assert.equal(await fs.readFile(`${p}.orig`, "utf8"), "첫 수정\n");
|
||||
assert.equal(await fs.readFile(`${p}.orig.1`, "utf8"), "두 번째 수정\n");
|
||||
});
|
||||
|
||||
test("removeFileAndPrune: 기준 밖 파일은 지우지 않는다 (접두사가 같은 형제 디렉터리 포함)", async () => {
|
||||
const { cwd } = await fresh("prune");
|
||||
const root = path.join(cwd, "a", "b");
|
||||
const sibling = path.join(cwd, "a", "bc");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.mkdir(sibling, { recursive: true });
|
||||
const victim = path.join(sibling, "important.txt");
|
||||
await fs.writeFile(victim, "x", "utf8");
|
||||
await assert.rejects(() => removeFileAndPrune(victim, root));
|
||||
await fs.access(victim);
|
||||
});
|
||||
|
||||
test("assertNoLinksBelow: 기준 아래의 정션·심링크를 거부한다", async () => {
|
||||
const { cwd, outside } = await fresh("links");
|
||||
await linkDir(outside, path.join(cwd, ".claude"));
|
||||
await assert.rejects(() => assertNoLinksBelow(cwd, path.join(cwd, ".claude", "skills", "x", "SKILL.md")));
|
||||
await assert.rejects(() => assertNoLinksBelow(cwd, path.join(cwd, "..", "escape.md")));
|
||||
await assertNoLinksBelow(cwd, path.join(cwd, ".cursor", "skills", "x", "SKILL.md"));
|
||||
});
|
||||
|
||||
test("project 설치는 정션으로 연결된 .claude 를 따라 저장소 밖에 쓰지 않는다", async () => {
|
||||
const { home, cwd, outside } = await fresh("junction-install");
|
||||
await linkDir(outside, path.join(cwd, ".claude"));
|
||||
const plan = await planInstall("claude-code", "project", skill, { home, cwd });
|
||||
await assert.rejects(() => applyPlan(plan, skill.version, { env: { home } }), /링크/);
|
||||
assert.deepEqual(await listAll(outside), []);
|
||||
assert.equal((await readManifest(home)).installs.length, 0);
|
||||
});
|
||||
|
||||
test("심링크인 AGENTS.md 에는 주입하지 않는다 (링크 대상 내용을 저장소로 끌어오지 않는다)", async () => {
|
||||
const { home, cwd, outside } = await fresh("symlink-agents");
|
||||
const secret = path.join(outside, "secret.env");
|
||||
await fs.writeFile(secret, "SECRET=abc\n", "utf8");
|
||||
if (!(await tryLinkFile(secret, path.join(cwd, "AGENTS.md")))) return; // 파일 심링크 권한 없음
|
||||
const plan = await planInstall("agents-md", "project", skill, { home, cwd });
|
||||
await assert.rejects(() => applyPlan(plan, skill.version, { env: { home } }), /링크/);
|
||||
assert.equal(await fs.readFile(secret, "utf8"), "SECRET=abc\n");
|
||||
assert.ok((await fs.lstat(path.join(cwd, "AGENTS.md"))).isSymbolicLink());
|
||||
// 계획 전체를 먼저 검사하므로 .designpaca/ 도 만들어지지 않는다
|
||||
await assert.rejects(() => fs.access(path.join(cwd, ".designpaca")));
|
||||
});
|
||||
|
||||
test("설치 기록 없이 이미 있는 다른 내용의 파일은 덮기 전에 .orig 로 남긴다", async () => {
|
||||
const { home, cwd } = await fresh("no-record");
|
||||
const root = path.join(cwd, ".claude", "skills", "designpaca");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(path.join(root, "SKILL.md"), "user own skill\n", "utf8");
|
||||
const plan = await planInstall("claude-code", "project", skill, { home, cwd });
|
||||
const res = await applyPlan(plan, skill.version, { env: { home } });
|
||||
assert.deepEqual(res.backedUp, [path.join(root, "SKILL.md.orig")]);
|
||||
assert.equal(await fs.readFile(path.join(root, "SKILL.md.orig"), "utf8"), "user own skill\n");
|
||||
await removeInstall(res.record, { env: { home } });
|
||||
});
|
||||
|
||||
test("envForRecord: 기록 root 에서 프로젝트·홈을 역산하고, 어댑터가 만들 수 없는 root 는 거부한다", async () => {
|
||||
const { home, cwd } = await fresh("env-record");
|
||||
const plan = await planInstall("agents-md", "project", skill, { home, cwd });
|
||||
const rec: InstallRecord = {
|
||||
target: "agents-md",
|
||||
scope: "project",
|
||||
root: plan.root,
|
||||
version: "0.0.1",
|
||||
installedAt: "",
|
||||
files: [],
|
||||
};
|
||||
assert.deepEqual(await envForRecord(rec), { cwd });
|
||||
const userPlan = await planInstall("claude-code", "user", skill, { home, cwd });
|
||||
assert.deepEqual(await envForRecord({ ...rec, target: "claude-code", scope: "user", root: userPlan.root }), {
|
||||
home,
|
||||
});
|
||||
await assert.rejects(() => envForRecord({ ...rec, root: path.join(cwd, "somewhere") }));
|
||||
const blocked = await planForRecord({ ...rec, root: path.join(cwd, "somewhere") }, skill);
|
||||
assert.ok(blocked.blocked);
|
||||
// 기록 root 그대로 다시 계획된다 — cwd 가 어디든 상관없다
|
||||
const again = await planForRecord(rec, skill);
|
||||
assert.equal(again.root, plan.root);
|
||||
});
|
||||
|
||||
test("변조된 매니페스트로 uninstall 이 root 밖 파일을 지우지 않는다", async () => {
|
||||
const { home, cwd, outside } = await fresh("tampered");
|
||||
const victim = path.join(outside, "sub", "important.txt");
|
||||
await fs.mkdir(path.dirname(victim), { recursive: true });
|
||||
await fs.writeFile(victim, "중요\n", "utf8");
|
||||
|
||||
// (1) 어댑터가 만들 수 없는 root
|
||||
const bogus: InstallRecord = {
|
||||
target: "claude-code",
|
||||
scope: "project",
|
||||
root: outside,
|
||||
version: "0.0.1",
|
||||
installedAt: "",
|
||||
files: [{ path: victim, sha256: sha256("중요\n") }],
|
||||
};
|
||||
// (2) 정상 root 지만 파일 경로가 root 밖 / 주입 문서가 아닌 마커 파일
|
||||
const plan = await planInstall("claude-code", "project", skill, { home, cwd });
|
||||
const sneaky: InstallRecord = {
|
||||
...bogus,
|
||||
root: plan.root,
|
||||
files: [
|
||||
{ path: victim, sha256: sha256("중요\n") },
|
||||
{ path: path.join(outside, "x.md"), sha256: "", marker: "designpaca" },
|
||||
],
|
||||
};
|
||||
await writeManifest({ schema: 1, installs: [bogus, sneaky] }, home);
|
||||
|
||||
for (const rec of [bogus, sneaky]) {
|
||||
const res = await removeInstall(rec, { env: { home }, force: true });
|
||||
assert.equal(res.removed.length, 0);
|
||||
assert.equal(res.refused.length, rec.files.length);
|
||||
}
|
||||
await fs.access(victim);
|
||||
// 거부한 파일은 기록에 남긴다(조용히 사라지지 않는다)
|
||||
assert.equal((await readManifest(home)).installs.length, 2);
|
||||
});
|
||||
|
||||
test("손상된 매니페스트는 덮기 전에 .corrupt-* 로 보존한다", async () => {
|
||||
const { home } = await fresh("corrupt");
|
||||
const file = path.join(home, ".designpaca", "manifest.json");
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, "{ 깨진 json", "utf8");
|
||||
assert.equal((await readManifest(home)).installs.length, 0);
|
||||
await writeManifest({ schema: 1, installs: [] }, home);
|
||||
const kept = (await fs.readdir(path.dirname(file))).filter((n) => n.startsWith("manifest.json.corrupt-"));
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue