웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포. 스킬 (packages/skill) - SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로 본문은 절차와 인덱스만, 지식은 references/ 로 분리 - 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기 - 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외 - 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙, SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침 설치 CLI (packages/cli, packages/core) - npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md - 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다 - 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성 - AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입 - Windsurf 는 12,000자 상한 초과 시 설치를 차단 배포 (build/ci, .forgejo/workflows) - 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러 → draft 릴리스 → Cloudflare Pages. 재실행 멱등 근거 (research/) - 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다 테스트 22개 통과 (core 16 · cli 6)
185 lines
5.9 KiB
TypeScript
185 lines
5.9 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { backup, readIfExists, removeFileAndPrune, sha256, 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 {
|
|
InstalledFile,
|
|
InstallPlan,
|
|
InstallRecord,
|
|
Scope,
|
|
SkillSource,
|
|
TargetContext,
|
|
TargetId,
|
|
} from "./types.ts";
|
|
|
|
export interface EnvOptions {
|
|
home?: string;
|
|
cwd?: string;
|
|
}
|
|
|
|
function ctxOf(scope: Scope, skill: SkillSource, env: EnvOptions = {}): TargetContext {
|
|
return {
|
|
scope,
|
|
home: env.home ?? os.homedir(),
|
|
cwd: env.cwd ?? process.cwd(),
|
|
skill,
|
|
};
|
|
}
|
|
|
|
/** 시스템에 흔적이 있는 타깃을 골라준다 — TUI 의 기본 체크 상태로 쓴다 */
|
|
export async function detectTargets(skill: SkillSource, env: EnvOptions = {}): Promise<TargetId[]> {
|
|
const found: TargetId[] = [];
|
|
for (const a of ADAPTERS) {
|
|
const scope: Scope = a.scopes.includes("user") ? "user" : "project";
|
|
if (await a.detect(ctxOf(scope, skill, env))) found.push(a.id);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export async function planInstall(
|
|
target: TargetId,
|
|
scope: Scope,
|
|
skill: SkillSource,
|
|
env: EnvOptions = {},
|
|
): Promise<InstallPlan> {
|
|
const adapter = getAdapter(target);
|
|
if (!adapter.scopes.includes(scope)) {
|
|
const only = adapter.scopes.join("/");
|
|
return {
|
|
target,
|
|
scope,
|
|
root: "",
|
|
actions: [],
|
|
alreadyInstalled: false,
|
|
blocked: `${adapter.label} 은(는) ${only} 범위만 지원한다`,
|
|
};
|
|
}
|
|
return adapter.plan(ctxOf(scope, skill, env));
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
record: InstallRecord;
|
|
written: string[];
|
|
backedUp: string[];
|
|
/** 사용자가 고쳐서 건너뛴 파일 */
|
|
skipped: string[];
|
|
}
|
|
|
|
/**
|
|
* 계획을 실제로 디스크에 반영한다.
|
|
*
|
|
* 업그레이드 안전장치: 이전 설치 기록이 있으면 각 파일의 해시를 비교해서,
|
|
* 사용자가 손댄 파일은 기본적으로 건드리지 않는다(force 로만 덮고, 덮을 때도 .orig 로 남긴다).
|
|
*/
|
|
export async function applyPlan(
|
|
plan: InstallPlan,
|
|
version: string,
|
|
opts: { force?: boolean; env?: EnvOptions } = {},
|
|
): Promise<ApplyResult> {
|
|
if (plan.blocked) throw new Error(plan.blocked);
|
|
const home = opts.env?.home;
|
|
|
|
const prev = findInstall(await readManifest(home), plan.target, plan.scope, plan.root);
|
|
const modified = new Set<string>();
|
|
if (prev) {
|
|
const drift = await inspectDrift(prev);
|
|
for (const p of drift.modified) modified.add(p);
|
|
}
|
|
|
|
const files: InstalledFile[] = [];
|
|
const written: string[] = [];
|
|
const backedUp: string[] = [];
|
|
const skipped: string[] = [];
|
|
|
|
for (const action of plan.actions) {
|
|
const isDirty = modified.has(action.path);
|
|
if (isDirty && !opts.force) {
|
|
// 건너뛰더라도 매니페스트에서 빠지면 uninstall 이 이 파일을 놓친다 — 기록은 남긴다.
|
|
// 이때 "현재" 해시를 쓰면 안 된다. 그러면 다음 update 에서 드리프트가 사라져
|
|
// 사용자가 고친 파일을 조용히 덮어쓰게 된다. 설치 당시 해시를 그대로 유지한다.
|
|
const kept = prev?.files.find((f) => f.path === action.path);
|
|
files.push(
|
|
kept ?? {
|
|
path: action.path,
|
|
sha256: sha256(action.kind === "inject" ? action.content.trim() : action.content),
|
|
...(action.kind === "inject" ? { marker: action.marker } : {}),
|
|
},
|
|
);
|
|
skipped.push(action.path);
|
|
continue;
|
|
}
|
|
if (isDirty && opts.force) {
|
|
const b = await backup(action.path);
|
|
if (b) backedUp.push(b);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
written.push(action.path);
|
|
}
|
|
|
|
const record: InstallRecord = {
|
|
target: plan.target,
|
|
scope: plan.scope,
|
|
root: plan.root,
|
|
version,
|
|
installedAt: new Date().toISOString(),
|
|
files,
|
|
};
|
|
await upsertInstall(record, home);
|
|
|
|
return { record, written, backedUp, skipped };
|
|
}
|
|
|
|
export interface RemoveResult {
|
|
removed: string[];
|
|
keptModified: string[];
|
|
}
|
|
|
|
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */
|
|
export async function removeInstall(
|
|
record: InstallRecord,
|
|
opts: { force?: boolean; env?: EnvOptions } = {},
|
|
): Promise<RemoveResult> {
|
|
const drift = await inspectDrift(record);
|
|
const dirty = new Set(drift.modified);
|
|
const removed: string[] = [];
|
|
const keptModified: string[] = [];
|
|
|
|
for (const f of record.files) {
|
|
if (dirty.has(f.path) && !opts.force) {
|
|
keptModified.push(f.path);
|
|
continue;
|
|
}
|
|
if (f.marker) {
|
|
const cur = await readIfExists(f.path);
|
|
if (cur === null) continue;
|
|
const next = removeBlock(cur, f.marker);
|
|
// 블록만 남아 있던 문서라면 파일째 지운다
|
|
if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path));
|
|
else await writeAtomic(f.path, next);
|
|
} else {
|
|
await removeFileAndPrune(f.path, record.root);
|
|
}
|
|
removed.push(f.path);
|
|
}
|
|
|
|
if (keptModified.length === 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);
|
|
}
|
|
|
|
return { removed, keptModified };
|
|
}
|