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,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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue