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

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