feat(core,cli): harden install engine and extend skill lint and interview eval
- update only touches the recorded project root; project installs do not follow symlinks or junctions; repeated --force rotates .orig backups; uninstall never deletes outside the recorded root - run npm/npx without a shell on Windows and pin optional tool versions - skip the update check in CI and non-interactive output - lint: cross-document section pointers, preflight row ids, gate rule numbers, per-document size budgets, gate copy equality - eval: scenarios S19-S21, route and read-cost rejudging, interpretation card judging, temp-dir previews and table-form cards no longer fail
This commit is contained in:
parent
ee3bef2ce5
commit
9bcb45cbd3
122 changed files with 5836 additions and 831 deletions
|
|
@ -3,6 +3,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isAllowedSkillFile } from "./skill-allowlist.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const src = path.resolve(here, "../../skill");
|
||||
|
|
@ -14,15 +15,25 @@ const dest = path.resolve(here, "../dist/skill");
|
|||
// 배포물은 어느 OS 에서 만들었는지와 무관하게 같아야 한다.
|
||||
const TEXT = new Set([".md", ".txt", ".json", ".yml", ".yaml"]);
|
||||
|
||||
async function copyDir(from, to) {
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
const excluded = [];
|
||||
|
||||
async function copyDir(from, to, rel = "") {
|
||||
for (const e of await fs.readdir(from, { withFileTypes: true })) {
|
||||
if (e.name === "node_modules" || e.name === "package.json") continue;
|
||||
if (e.name === "node_modules") continue;
|
||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||
const s = path.join(from, e.name);
|
||||
const d = path.join(to, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await copyDir(s, d);
|
||||
} else if (TEXT.has(path.extname(e.name))) {
|
||||
await copyDir(s, d, r);
|
||||
continue;
|
||||
}
|
||||
// 허용 목록 밖(테스트·스크래치·dotfile·package.json 등)은 사용자 설치물에 싣지 않는다
|
||||
if (!e.isFile() || !isAllowedSkillFile(r)) {
|
||||
if (e.name !== "package.json") excluded.push(r);
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
if (TEXT.has(path.extname(e.name))) {
|
||||
const text = await fs.readFile(s, "utf8");
|
||||
await fs.writeFile(d, text.replace(/\r\n/g, "\n"), "utf8");
|
||||
} else {
|
||||
|
|
@ -40,3 +51,4 @@ await fs.writeFile(path.join(dest, ".designpaca_version"), `${pkg.version}\n`, "
|
|||
|
||||
const count = (await fs.readdir(dest, { recursive: true })).length;
|
||||
console.log(`스킬 번들 완료: ${count}개 항목 → dist/skill`);
|
||||
if (excluded.length > 0) console.log(` 설치물에서 제외: ${excluded.join(", ")}`);
|
||||
|
|
|
|||
21
packages/cli/scripts/skill-allowlist.mjs
Normal file
21
packages/cli/scripts/skill-allowlist.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// 사용자 설치물(dist/skill → 각 하네스의 스킬 디렉터리)에 실을 파일의 허용 목록.
|
||||
// bundle-skill.mjs(복사 시)와 verify-package.mjs(배포 직전)가 같은 규칙을 쓴다.
|
||||
//
|
||||
// 이유: 예전에는 packages/skill 아래를 통째로 복사해서 추적되지 않은 스크래치·.env·로컬 메모가
|
||||
// 로컬 publish 때 npm 에 실릴 수 있었고, 개발용 계약 테스트(design-gate-contract.test.mjs)가
|
||||
// 모든 사용자 스킬 디렉터리에 설치됐다. 새 종류의 파일을 실으려면 여기서 명시적으로 연다.
|
||||
|
||||
/** posix 상대 경로(rel) 가 설치물에 들어가도 되는가 */
|
||||
export function isAllowedSkillFile(rel) {
|
||||
const parts = rel.split("/");
|
||||
if (parts.some((p) => p.startsWith(".") && p !== ".designpaca_version")) return false;
|
||||
if (/\.test\.[cm]?[jt]s$/.test(rel)) return false;
|
||||
if (parts.length === 1) {
|
||||
return ["SKILL.md", "CHANGELOG.md", "THIRD_PARTY_NOTICES.md", ".designpaca_version"].includes(rel);
|
||||
}
|
||||
const [top] = parts;
|
||||
if (top === "references") return rel.endsWith(".md");
|
||||
if (top === "agents") return /\.ya?ml$/.test(rel);
|
||||
if (top === "tools") return parts.length === 2 && rel.endsWith(".mjs");
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isAllowedSkillFile } from "./skill-allowlist.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const must = ["dist/index.js", "dist/skill/SKILL.md", "dist/skill/.designpaca_version"];
|
||||
|
|
@ -26,4 +27,17 @@ if (stamp !== pkg.version) {
|
|||
console.error(`버전 불일치: package.json=${pkg.version} / 스킬 스탬프=${stamp}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// 허용 목록 밖 파일(테스트·스크래치·.env 등)이 설치물에 섞였으면 배포하지 않는다
|
||||
const skillDir = path.join(root, "dist/skill");
|
||||
const unexpected = [];
|
||||
for (const e of await fs.readdir(skillDir, { recursive: true, withFileTypes: true })) {
|
||||
if (!e.isFile() && !e.isSymbolicLink()) continue;
|
||||
const rel = path.relative(skillDir, path.join(e.parentPath ?? e.path, e.name)).split(path.sep).join("/");
|
||||
if (e.isSymbolicLink() || !isAllowedSkillFile(rel)) unexpected.push(rel);
|
||||
}
|
||||
if (unexpected.length) {
|
||||
console.error(`스킬 설치물에 허용 목록 밖 파일:\n${unexpected.map((m) => ` - ${m}`).join("\n")}`);
|
||||
console.error("scripts/skill-allowlist.mjs 를 확인하고 pnpm build 를 다시 실행해라.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`배포 점검 통과 (v${pkg.version})`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
applyPlan,
|
||||
planInstall,
|
||||
type EnvOptions,
|
||||
samePath,
|
||||
tildify,
|
||||
type Scope,
|
||||
type TargetId,
|
||||
|
|
@ -14,6 +16,13 @@ export interface InstallOptions {
|
|||
force?: boolean;
|
||||
/** 실제로 쓰지 않고 계획만 출력 */
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* 계획 기준 디렉터리. update 가 기록의 프로젝트·홈을 그대로 넘긴다 — 없으면 cwd·홈.
|
||||
* 실측 사고: 이걸 넘기지 않아 project 설치가 update 를 실행한 cwd 에 다시 깔렸다.
|
||||
*/
|
||||
env?: EnvOptions;
|
||||
/** 계획한 root 가 이 경로와 다르면 쓰지 않는다(update 의 방어선) */
|
||||
expectRoot?: string;
|
||||
}
|
||||
|
||||
export interface InstallOutcome {
|
||||
|
|
@ -21,6 +30,8 @@ export interface InstallOutcome {
|
|||
root: string;
|
||||
written: number;
|
||||
skipped: string[];
|
||||
/** 덮기 전에 남긴 백업(.orig) */
|
||||
backedUp?: string[];
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +41,10 @@ export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]
|
|||
const outcomes: InstallOutcome[] = [];
|
||||
|
||||
for (const target of opts.targets) {
|
||||
const plan = await planInstall(target, opts.scope, skill);
|
||||
const plan = await planInstall(target, opts.scope, skill, opts.env);
|
||||
if (!plan.blocked && opts.expectRoot !== undefined && !samePath(plan.root, opts.expectRoot)) {
|
||||
plan.blocked = `계획한 위치(${tildify(plan.root)})가 설치 기록(${tildify(opts.expectRoot)})과 다릅니다`;
|
||||
}
|
||||
if (plan.blocked) {
|
||||
outcomes.push({ target, root: "", written: 0, skipped: [], blocked: plan.blocked });
|
||||
continue;
|
||||
|
|
@ -39,12 +53,21 @@ export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]
|
|||
outcomes.push({ target, root: plan.root, written: plan.actions.length, skipped: [] });
|
||||
continue;
|
||||
}
|
||||
const res = await applyPlan(plan, skill.version, { force: opts.force });
|
||||
let res;
|
||||
try {
|
||||
res = await applyPlan(plan, skill.version, { force: opts.force, expectRoot: opts.expectRoot });
|
||||
} catch (err) {
|
||||
// 링크 거부·꼬인 마커 등은 이 대상만 막고 다른 대상은 계속 진행한다
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
outcomes.push({ target, root: plan.root, written: 0, skipped: [], blocked: msg });
|
||||
continue;
|
||||
}
|
||||
outcomes.push({
|
||||
target,
|
||||
root: plan.root,
|
||||
written: res.written.length,
|
||||
skipped: res.skipped,
|
||||
backedUp: res.backedUp,
|
||||
});
|
||||
}
|
||||
return outcomes;
|
||||
|
|
@ -63,6 +86,10 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void
|
|||
console.log(warn(` 직접 수정하신 파일이라 그대로 두었습니다 (--force 로 덮어쓸 수 있습니다):`));
|
||||
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
|
||||
}
|
||||
if (o.backedUp && o.backedUp.length > 0) {
|
||||
console.log(warn(` 기존 파일을 덮기 전에 백업해 두었습니다:`));
|
||||
for (const b of fold(o.backedUp)) console.log(dim(` ${tildify(b)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export async function runUninstall(opts: {
|
|||
}
|
||||
|
||||
console.log(heading(opts.dryRun ? "제거 계획" : "제거"));
|
||||
let code = 0;
|
||||
for (const rec of targets) {
|
||||
const res = await removeInstall(rec, { force: opts.force, dryRun: opts.dryRun });
|
||||
const verb = opts.dryRun ? "제거할 파일" : "제거";
|
||||
|
|
@ -28,6 +29,13 @@ export async function runUninstall(opts: {
|
|||
);
|
||||
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
||||
}
|
||||
if (res.refused.length > 0) {
|
||||
code = 1;
|
||||
console.log(
|
||||
warn(` 설치 기록과 맞지 않아 손대지 않은 파일 ${res.refused.length}개 (기록 밖 경로·링크·꼬인 마커)`),
|
||||
);
|
||||
for (const p of res.refused.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
return code;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { inspectDrift, readManifest, tildify } from "@designpaca/core";
|
||||
import { envForRecord, inspectDrift, readManifest, tildify } from "@designpaca/core";
|
||||
import { getVersion } from "../skill.ts";
|
||||
import { runInstall, printOutcomes } from "./install.ts";
|
||||
import { dim, heading, info, ok, warn } from "../ui.ts";
|
||||
import { bad, dim, heading, info, ok, warn } from "../ui.ts";
|
||||
|
||||
/**
|
||||
* 설치 기록을 그대로 따라가며 재설치한다.
|
||||
|
|
@ -42,15 +42,28 @@ export async function runUpdate(opts: { force?: boolean; dryRun?: boolean } = {}
|
|||
}
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const rec of manifest.installs) {
|
||||
console.log(info(`${rec.target} (${rec.scope}) ${dim(tildify(rec.root))} — ${rec.version} → ${version}`));
|
||||
// 기록이 있던 바로 그 자리를 다시 계획한다. cwd 를 쓰면 project 설치가 지금 디렉터리에 깔린다.
|
||||
let env;
|
||||
try {
|
||||
env = await envForRecord(rec);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(bad(`${rec.target}: ${err instanceof Error ? err.message : String(err)} — 건너뜁니다`));
|
||||
continue;
|
||||
}
|
||||
const outcomes = await runInstall({
|
||||
targets: [rec.target],
|
||||
scope: rec.scope,
|
||||
force: opts.force,
|
||||
dryRun: opts.dryRun,
|
||||
env,
|
||||
expectRoot: rec.root,
|
||||
});
|
||||
if (outcomes.some((o) => o.blocked)) failed++;
|
||||
printOutcomes(outcomes, opts.dryRun);
|
||||
}
|
||||
return 0;
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { readIfExists, updateCachePath, writeAtomic } from "@designpaca/core";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
/** 명령을 마친 뒤 종료를 붙잡는 최대 시간. 느린 네트워크에서 체감 지연을 줄이려고 짧게 둔다 */
|
||||
const TIMEOUT_MS = 1200;
|
||||
/** 레지스트리·캐시 값은 터미널에 그대로 찍히므로 semver 모양만 받는다(이스케이프 주입 차단) */
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const REGISTRY = "https://registry.npmjs.org/designpaca/latest";
|
||||
|
||||
interface Cache {
|
||||
|
|
@ -25,6 +29,9 @@ function isNewer(latest: string, current: string): boolean {
|
|||
*/
|
||||
export async function checkForUpdate(current: string): Promise<string | null> {
|
||||
if (process.env["DESIGNPACA_NO_UPDATE_CHECK"] === "1") return null;
|
||||
// CI·파이프 출력에서는 알림을 볼 사람이 없다 — 네트워크도 쓰지 않고 종료도 늦추지 않는다
|
||||
if (process.env["CI"] && process.env["CI"] !== "false" && process.env["CI"] !== "0") return null;
|
||||
if (!process.stdout.isTTY) return null;
|
||||
|
||||
const cachePath = updateCachePath();
|
||||
const raw = await readIfExists(cachePath);
|
||||
|
|
@ -32,7 +39,9 @@ export async function checkForUpdate(current: string): Promise<string | null> {
|
|||
try {
|
||||
const c = JSON.parse(raw) as Cache;
|
||||
if (Date.now() - c.checkedAt < DAY) {
|
||||
return isNewer(c.latest, current) ? c.latest : null;
|
||||
return typeof c.latest === "string" && SEMVER.test(c.latest) && isNewer(c.latest, current)
|
||||
? c.latest
|
||||
: null;
|
||||
}
|
||||
} catch {
|
||||
/* 캐시가 깨졌으면 새로 받는다 */
|
||||
|
|
@ -42,12 +51,12 @@ export async function checkForUpdate(current: string): Promise<string | null> {
|
|||
try {
|
||||
const res = await fetch(REGISTRY, {
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
signal: AbortSignal.timeout(2500),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { version?: string };
|
||||
const latest = body.version;
|
||||
if (!latest) return null;
|
||||
if (typeof latest !== "string" || !SEMVER.test(latest)) return null;
|
||||
await writeAtomic(cachePath, JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache));
|
||||
return isNewer(latest, current) ? latest : null;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ after(async () => {
|
|||
});
|
||||
|
||||
/** 빌드 산출물을 사용자가 실행하는 방식 그대로 돌린다 */
|
||||
function run(args: string[]): string {
|
||||
function run(args: string[], cwd?: string): string {
|
||||
return execFileSync(process.execPath, [CLI, ...args, "--no-update-check"], {
|
||||
encoding: "utf8",
|
||||
...(cwd ? { cwd } : {}),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
|
|
@ -120,3 +121,45 @@ test("설치 → doctor → 제거 왕복", { skip: !built }, async () => {
|
|||
run(["uninstall", "-y"]);
|
||||
await assert.rejects(() => fs.access(skillMd));
|
||||
});
|
||||
|
||||
test("update 는 project 설치를 기록된 root 에서 갱신하고 cwd 에는 아무것도 쓰지 않는다", { skip: !built }, async () => {
|
||||
// 보안 감사 P0 재현: repoA 에 project 설치 → 버전을 낮춤 → 무관한 repoB 에서 update.
|
||||
// 예전에는 repoB 의 AGENTS.md 에 블록이 주입되고 사용자가 만든 스킬이 백업 없이 덮였다.
|
||||
const repoA = path.join(home, "repoA");
|
||||
const repoB = path.join(home, "repoB");
|
||||
await fs.mkdir(repoA, { recursive: true });
|
||||
await fs.mkdir(path.join(repoB, ".claude", "skills", "designpaca"), { recursive: true });
|
||||
await fs.writeFile(path.join(repoB, "AGENTS.md"), "# repoB\n", "utf8");
|
||||
const userSkill = path.join(repoB, ".claude", "skills", "designpaca", "SKILL.md");
|
||||
await fs.writeFile(userSkill, "user own skill\n", "utf8");
|
||||
const listB = async () => (await fs.readdir(repoB, { recursive: true })).map(String).sort();
|
||||
const beforeB = await listB();
|
||||
|
||||
run(["install", "-t", "agents-md,claude-code", "-s", "project", "-y"], repoA);
|
||||
const manifestFile = path.join(home, ".designpaca", "manifest.json");
|
||||
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8"));
|
||||
assert.equal(manifest.installs.length, 2);
|
||||
for (const rec of manifest.installs) rec.version = "0.0.1";
|
||||
await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||
|
||||
run(["update"], repoB);
|
||||
|
||||
// repoB 는 그대로다
|
||||
assert.deepEqual(await listB(), beforeB);
|
||||
assert.equal(await fs.readFile(path.join(repoB, "AGENTS.md"), "utf8"), "# repoB\n");
|
||||
assert.equal(await fs.readFile(userSkill, "utf8"), "user own skill\n");
|
||||
|
||||
// repoA 기록만 새 버전으로 갱신됐고, 새 기록이 생기지 않았다
|
||||
const after = JSON.parse(await fs.readFile(manifestFile, "utf8"));
|
||||
const version = run(["--version"]).trim();
|
||||
assert.equal(after.installs.length, 2);
|
||||
for (const rec of after.installs) {
|
||||
assert.equal(rec.version, version);
|
||||
assert.ok(path.resolve(rec.root).startsWith(path.resolve(repoA)), `기록 root 가 repoA 밖: ${rec.root}`);
|
||||
}
|
||||
assert.ok((await fs.readFile(path.join(repoA, "AGENTS.md"), "utf8")).includes("designpaca:start"));
|
||||
|
||||
run(["uninstall", "-y"], repoB);
|
||||
await assert.rejects(() => fs.access(path.join(repoA, ".claude", "skills", "designpaca", "SKILL.md")));
|
||||
assert.equal(await fs.readFile(userSkill, "utf8"), "user own skill\n");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue