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