designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 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)
This commit is contained in:
commit
8808c672dc
135 changed files with 38838 additions and 0 deletions
185
packages/core/src/installer.ts
Normal file
185
packages/core/src/installer.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue