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