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

@ -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(", ")}`);

View 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;
}

View file

@ -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})`);

View file

@ -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)}`));
}
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 {

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");
});

View file

@ -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 {
/* 남아있으면 그대로 둔다 */
}

View file

@ -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 };
}

View file

@ -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(

View file

@ -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();
}

View file

@ -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"));
}

View file

@ -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}`]);
},
};

View file

@ -26,6 +26,11 @@ export interface InstallPlan {
scope: Scope;
/** 이 타깃이 파일을 쓰는 루트 (표시용) */
root: string;
/**
* 계획의 기준 디렉터리 — project 범위면 프로젝트 디렉터리, user 범위면 홈.
* applyPlan 이 이 아래의 링크(심링크·정션)를 거부하는 경계로 쓴다. planInstall 이 채운다.
*/
base?: string;
actions: FileAction[];
/** 이미 설치돼 있는가 */
alreadyInstalled: boolean;

View file

@ -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");
});

View 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);
});