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