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
19
packages/core/package.json
Normal file
19
packages/core/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@designpaca/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo \"(core 는 cli 번들에 포함된다)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types --disable-warning=ExperimentalWarning \"test/**/*.test.ts\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
65
packages/core/src/fsx.ts
Normal file
65
packages/core/src/fsx.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function sha256(content: string): string {
|
||||
// 개행 정규화 — Windows 체크아웃(autocrlf)에서 해시가 흔들리는 것을 막는다
|
||||
return createHash("sha256").update(content.replace(/\r\n/g, "\n"), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readIfExists(p: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(p, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 임시 파일에 쓴 뒤 rename — 중간에 죽어도 반쯤 쓰인 파일이 남지 않는다 */
|
||||
export async function writeAtomic(p: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, content, "utf8");
|
||||
await fs.rename(tmp, p);
|
||||
}
|
||||
|
||||
/** 사용자가 고친 파일을 덮기 전에 원본을 남겨둔다 */
|
||||
export async function backup(p: string): Promise<string | null> {
|
||||
const cur = await readIfExists(p);
|
||||
if (cur === null) return null;
|
||||
const dest = `${p}.orig`;
|
||||
await fs.writeFile(dest, cur, "utf8");
|
||||
return dest;
|
||||
}
|
||||
|
||||
/** 파일을 지우고, 비게 된 상위 디렉터리를 stopAt 까지 정리한다 */
|
||||
export async function removeFileAndPrune(p: string, stopAt: string): Promise<void> {
|
||||
await fs.rm(p, { force: true });
|
||||
let dir = path.dirname(p);
|
||||
const stop = path.resolve(stopAt);
|
||||
while (path.resolve(dir).startsWith(stop) && path.resolve(dir) !== stop) {
|
||||
try {
|
||||
const rest = await fs.readdir(dir);
|
||||
if (rest.length > 0) break;
|
||||
await fs.rmdir(dir);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
// stopAt 자체도 비었으면 함께 정리한다
|
||||
try {
|
||||
if ((await fs.readdir(stop)).length === 0) await fs.rmdir(stop);
|
||||
} catch {
|
||||
/* 남아있으면 그대로 둔다 */
|
||||
}
|
||||
}
|
||||
8
packages/core/src/index.ts
Normal file
8
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./types.ts";
|
||||
export * from "./paths.ts";
|
||||
export * from "./fsx.ts";
|
||||
export * from "./marker.ts";
|
||||
export * from "./manifest.ts";
|
||||
export * from "./skill-source.ts";
|
||||
export * from "./installer.ts";
|
||||
export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts";
|
||||
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 };
|
||||
}
|
||||
105
packages/core/src/manifest.ts
Normal file
105
packages/core/src/manifest.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { manifestPath } from "./paths.ts";
|
||||
import { readIfExists, sha256, writeAtomic } from "./fsx.ts";
|
||||
import { extractBlock } from "./marker.ts";
|
||||
import type { DriftReport, InstallRecord, Manifest, Scope, TargetId } from "./types.ts";
|
||||
|
||||
const EMPTY: Manifest = { schema: 1, installs: [] };
|
||||
|
||||
export async function readManifest(home?: string): Promise<Manifest> {
|
||||
const raw = await readIfExists(manifestPath(home));
|
||||
if (!raw) return structuredClone(EMPTY);
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Manifest;
|
||||
if (parsed.schema !== 1 || !Array.isArray(parsed.installs)) return structuredClone(EMPTY);
|
||||
return parsed;
|
||||
} catch {
|
||||
// 손상된 매니페스트로 설치 전체가 막히지 않도록 빈 것으로 되돌린다
|
||||
return structuredClone(EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeManifest(m: Manifest, home?: string): Promise<void> {
|
||||
await writeAtomic(manifestPath(home), JSON.stringify(m, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function sameInstall(a: InstallRecord, target: TargetId, scope: Scope, root: string): boolean {
|
||||
return a.target === target && a.scope === scope && path.resolve(a.root) === path.resolve(root);
|
||||
}
|
||||
|
||||
export function findInstall(
|
||||
m: Manifest,
|
||||
target: TargetId,
|
||||
scope: Scope,
|
||||
root: string,
|
||||
): InstallRecord | undefined {
|
||||
return m.installs.find((i) => sameInstall(i, target, scope, root));
|
||||
}
|
||||
|
||||
export async function upsertInstall(record: InstallRecord, home?: string): Promise<void> {
|
||||
const m = await readManifest(home);
|
||||
const idx = m.installs.findIndex((i) => sameInstall(i, record.target, record.scope, record.root));
|
||||
if (idx >= 0) m.installs[idx] = record;
|
||||
else m.installs.push(record);
|
||||
await writeManifest(m, home);
|
||||
}
|
||||
|
||||
export async function dropInstall(
|
||||
target: TargetId,
|
||||
scope: Scope,
|
||||
root: string,
|
||||
home?: string,
|
||||
): Promise<void> {
|
||||
const m = await readManifest(home);
|
||||
m.installs = m.installs.filter((i) => !sameInstall(i, target, scope, root));
|
||||
await writeManifest(m, home);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 당시 해시와 현재 내용을 비교한다.
|
||||
* 마커 방식 파일은 문서 전체가 아니라 블록 안쪽만 비교한다 — 사용자가 문서의 다른 부분을
|
||||
* 고치는 것은 정상이고, 그걸 드리프트로 잡으면 update 가 영영 못 돈다.
|
||||
*/
|
||||
export async function inspectDrift(record: InstallRecord): Promise<DriftReport> {
|
||||
const files: DriftReport["files"] = [];
|
||||
for (const f of record.files) {
|
||||
const cur = await readIfExists(f.path);
|
||||
if (cur === null) {
|
||||
files.push({ path: f.path, status: "missing" });
|
||||
continue;
|
||||
}
|
||||
const target = f.marker ? (extractBlock(cur, f.marker) ?? "") : cur;
|
||||
files.push({ path: f.path, status: sha256(target) === f.sha256 ? "ok" : "modified" });
|
||||
}
|
||||
return {
|
||||
record,
|
||||
files,
|
||||
modified: files.filter((f) => f.status === "modified").map((f) => f.path),
|
||||
};
|
||||
}
|
||||
|
||||
/** 매니페스트가 가리키는 경로가 실제로 살아있는지 확인한다 */
|
||||
export async function pruneDeadInstalls(home?: string): Promise<number> {
|
||||
const m = await readManifest(home);
|
||||
const before = m.installs.length;
|
||||
const alive: InstallRecord[] = [];
|
||||
for (const rec of m.installs) {
|
||||
const anyAlive = await Promise.all(
|
||||
rec.files.map(async (f) => {
|
||||
try {
|
||||
await fs.access(f.path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (anyAlive.some(Boolean)) alive.push(rec);
|
||||
}
|
||||
if (alive.length !== before) {
|
||||
m.installs = alive;
|
||||
await writeManifest(m, home);
|
||||
}
|
||||
return before - alive.length;
|
||||
}
|
||||
51
packages/core/src/marker.ts
Normal file
51
packages/core/src/marker.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* AGENTS.md 같은 사용자 소유 문서에 우리 블록만 안전하게 심고 빼기 위한 마커 처리.
|
||||
* 블록 밖의 내용은 절대 건드리지 않는다.
|
||||
*/
|
||||
|
||||
export function startTag(marker: string): string {
|
||||
return `<!-- ${marker}:start -->`;
|
||||
}
|
||||
export function endTag(marker: string): string {
|
||||
return `<!-- ${marker}:end -->`;
|
||||
}
|
||||
|
||||
export function hasBlock(doc: string, marker: string): boolean {
|
||||
return doc.includes(startTag(marker)) && doc.includes(endTag(marker));
|
||||
}
|
||||
|
||||
/** 블록이 있으면 내용만 교체, 없으면 문서 끝에 덧붙인다 */
|
||||
export function upsertBlock(doc: string, marker: string, content: string): string {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const block = `${s}\n${content.trim()}\n${e}`;
|
||||
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si !== -1 && ei !== -1 && ei > si) {
|
||||
return doc.slice(0, si) + block + doc.slice(ei + e.length);
|
||||
}
|
||||
const base = doc.trimEnd();
|
||||
return base.length > 0 ? `${base}\n\n${block}\n` : `${block}\n`;
|
||||
}
|
||||
|
||||
/** 블록만 제거하고 나머지 문서는 보존한다 */
|
||||
export function removeBlock(doc: string, marker: string): string {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si === -1 || ei === -1 || ei < si) return doc;
|
||||
const out = doc.slice(0, si) + doc.slice(ei + e.length);
|
||||
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
/** 블록 내부 내용만 뽑아낸다 (드리프트 판정용) */
|
||||
export function extractBlock(doc: string, marker: string): string | null {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si === -1 || ei === -1 || ei < si) return null;
|
||||
return doc.slice(si + s.length, ei).trim();
|
||||
}
|
||||
25
packages/core/src/paths.ts
Normal file
25
packages/core/src/paths.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/** designpaca 자체 상태 디렉터리 (~/.designpaca) */
|
||||
export function stateDir(home = os.homedir()): string {
|
||||
return path.join(home, ".designpaca");
|
||||
}
|
||||
|
||||
export function manifestPath(home = os.homedir()): string {
|
||||
return path.join(stateDir(home), "manifest.json");
|
||||
}
|
||||
|
||||
/** 업데이트 확인 캐시 */
|
||||
export function updateCachePath(home = os.homedir()): string {
|
||||
return path.join(stateDir(home), "update-check.json");
|
||||
}
|
||||
|
||||
/** 홈 경로를 ~ 로 줄여 표시한다 */
|
||||
export function tildify(p: string, home = os.homedir()): string {
|
||||
const rel = path.relative(home, p);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
|
||||
return path.posix.join("~", rel.split(path.sep).join("/"));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
62
packages/core/src/skill-source.ts
Normal file
62
packages/core/src/skill-source.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { SkillSource } from "./types.ts";
|
||||
|
||||
/** 프론트매터를 본문과 분리한다. yaml 파서를 끌어오지 않으려고 필요한 필드만 얕게 읽는다. */
|
||||
export function splitFrontmatter(md: string): { fm: Record<string, string>; body: string } {
|
||||
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(md);
|
||||
if (!m) return { fm: {}, body: md };
|
||||
const fm: Record<string, string> = {};
|
||||
for (const line of (m[1] ?? "").split(/\r?\n/)) {
|
||||
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
||||
if (!kv) continue;
|
||||
let v = (kv[2] ?? "").trim();
|
||||
// 따옴표로 감싼 값의 따옴표만 벗긴다
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
fm[kv[1] as string] = v;
|
||||
}
|
||||
return { fm, body: md.slice(m[0].length) };
|
||||
}
|
||||
|
||||
async function walk(dir: string, base = dir): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join("/"));
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 스킬 원본 디렉터리를 읽어 메모리에 올린다.
|
||||
* SKILL.md 는 필수, 나머지(references/ 등)는 전부 그대로 따라간다.
|
||||
*/
|
||||
export async function loadSkillSource(root: string, version: string): Promise<SkillSource> {
|
||||
const skillMd = await fs.readFile(path.join(root, "SKILL.md"), "utf8");
|
||||
const { fm, body } = splitFrontmatter(skillMd);
|
||||
|
||||
const files = new Map<string, string>();
|
||||
for (const rel of await walk(root)) {
|
||||
if (rel === "SKILL.md") continue;
|
||||
if (rel.endsWith(".orig") || rel === ".designpaca_version") continue;
|
||||
files.set(rel, await fs.readFile(path.join(root, rel), "utf8"));
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
skillMd,
|
||||
files,
|
||||
body: body.trim(),
|
||||
description: fm["description"] ?? "웹 디자인 파이프라인 스킬",
|
||||
};
|
||||
}
|
||||
71
packages/core/src/targets/agents-md.ts
Normal file
71
packages/core/src/targets/agents-md.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { MARKER, rewriteRefPaths, skillDirActions } from "./common.ts";
|
||||
|
||||
/**
|
||||
* 범용 AGENTS.md — 어떤 에이전트든 읽는 프로젝트 루트 문서에 우리 블록만 심는다.
|
||||
* 문서 전체를 덮어쓰지 않는다. 블록 밖은 사용자 것이다.
|
||||
*
|
||||
* **본문 전체를 넣지 않는다.** AGENTS.md 는 스킬과 달리 조건 없이 항상 로드되고,
|
||||
* Codex 는 병합 결과를 기본 32KiB 에서 자른다. 14KB 짜리 본문을 넣으면
|
||||
* 디자인과 무관한 모든 대화에서 그 비용을 내고, 프로젝트 자신의 지침이 잘려나갈 수 있다.
|
||||
* 그래서 여기에는 포인터만 두고 본문은 .designpaca/ 에 풀어둔다.
|
||||
*/
|
||||
export const agentsMd: TargetAdapter = {
|
||||
id: "agents-md",
|
||||
label: "범용 AGENTS.md",
|
||||
hint: "AGENTS.md 에 포인터 주입 — 에이전트 무관",
|
||||
scopes: ["project", "user"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
// 항상 로드되는 자리라 기본 선택은 보수적으로 — AGENTS.md 가 실제로 있을 때만.
|
||||
return (
|
||||
(await exists(path.join(ctx.cwd, "AGENTS.md"))) ||
|
||||
(await exists(path.join(ctx.home, ".codex", "AGENTS.md")))
|
||||
);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const isUser = ctx.scope === "user";
|
||||
const doc = isUser
|
||||
? path.join(ctx.home, ".codex", "AGENTS.md")
|
||||
: path.join(ctx.cwd, "AGENTS.md");
|
||||
// user/project 가 같은 구조를 갖도록 둘 다 .designpaca 를 루트로 쓴다
|
||||
const refRoot = isUser
|
||||
? path.join(ctx.home, ".designpaca", "skill")
|
||||
: path.join(ctx.cwd, ".designpaca");
|
||||
const refPrefix = isUser ? "~/.designpaca/skill" : ".designpaca";
|
||||
|
||||
const block = [
|
||||
"## designpaca — 웹 디자인 파이프라인",
|
||||
"",
|
||||
"이 리포에서 UI 를 새로 만들거나 다시 디자인할 때는, 마크업·스타일을 쓰기 전에",
|
||||
`\`${refPrefix}/SKILL.md\` 를 읽고 그 파이프라인(0~6단계)을 따른다.`,
|
||||
"",
|
||||
"- 프로젝트 루트에 `design.md` 가 있으면 그것이 최상위다. 스킬 기본값을 덮는다.",
|
||||
"- 참조 문서는 각 단계에서 지시하는 것만 그때 연다. 처음부터 전부 읽지 마라.",
|
||||
"",
|
||||
`<!-- designpaca v${ctx.skill.version} — \`npx designpaca update\` 가 관리한다. 직접 고치면 업데이트가 멈춘다. -->`,
|
||||
].join("\n");
|
||||
|
||||
// 본문은 별도 디렉터리에 통째로 푼다. 경로 재작성이 필요하다.
|
||||
const skillActions = skillDirActions(refRoot, {
|
||||
...ctx.skill,
|
||||
skillMd: rewriteRefPaths(ctx.skill.skillMd, refPrefix),
|
||||
});
|
||||
|
||||
const actions: FileAction[] = [
|
||||
{ kind: "inject", path: doc, marker: MARKER, content: block },
|
||||
...skillActions,
|
||||
];
|
||||
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root: refRoot,
|
||||
actions,
|
||||
alreadyInstalled: await exists(path.join(refRoot, "SKILL.md")),
|
||||
};
|
||||
},
|
||||
};
|
||||
34
packages/core/src/targets/claude-code.ts
Normal file
34
packages/core/src/targets/claude-code.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
|
||||
|
||||
/**
|
||||
* Claude Code — ~/.claude/skills/designpaca/ (전역) 또는 .claude/skills/designpaca/ (프로젝트).
|
||||
* SKILL.md 포맷을 그대로 쓰므로 변환이 없다.
|
||||
*/
|
||||
export const claudeCode: TargetAdapter = {
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
hint: "~/.claude/skills/designpaca — SKILL.md 그대로",
|
||||
scopes: ["user", "project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([
|
||||
path.join(ctx.home, ".claude"),
|
||||
path.join(ctx.cwd, ".claude"),
|
||||
path.join(ctx.home, ".claude.json"),
|
||||
]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const root = scopeRoot(ctx, [".claude", "skills", "designpaca"], [".claude", "skills", "designpaca"]);
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root,
|
||||
actions: skillDirActions(root, ctx.skill),
|
||||
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
|
||||
};
|
||||
},
|
||||
};
|
||||
34
packages/core/src/targets/codex.ts
Normal file
34
packages/core/src/targets/codex.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
|
||||
|
||||
/**
|
||||
* Codex CLI — ~/.codex/skills/designpaca/.
|
||||
* Codex 도 Claude Code 와 같은 SKILL.md 규약을 쓴다(실측: ~/.codex/skills/frontend-design 등).
|
||||
*/
|
||||
export const codex: TargetAdapter = {
|
||||
id: "codex",
|
||||
label: "Codex CLI",
|
||||
hint: "~/.codex/skills/designpaca — SKILL.md 그대로",
|
||||
scopes: ["user", "project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([
|
||||
path.join(ctx.home, ".codex"),
|
||||
path.join(ctx.home, ".codex", "AGENTS.md"),
|
||||
path.join(ctx.cwd, ".codex"),
|
||||
]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const root = scopeRoot(ctx, [".codex", "skills", "designpaca"], [".codex", "skills", "designpaca"]);
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root,
|
||||
actions: skillDirActions(root, ctx.skill),
|
||||
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
|
||||
};
|
||||
},
|
||||
};
|
||||
60
packages/core/src/targets/common.ts
Normal file
60
packages/core/src/targets/common.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, SkillSource, TargetContext } from "../types.ts";
|
||||
|
||||
/** 매니페스트·마커에 쓰는 고정 식별자 */
|
||||
export const MARKER = "designpaca";
|
||||
|
||||
/** 스킬 디렉터리를 통째로 쓰는 타깃(Claude Code·Codex)이 공유하는 파일 목록 */
|
||||
export function skillDirActions(root: string, skill: SkillSource): FileAction[] {
|
||||
const actions: FileAction[] = [
|
||||
{ kind: "write", path: path.join(root, "SKILL.md"), content: skill.skillMd },
|
||||
];
|
||||
for (const [rel, content] of skill.files) {
|
||||
actions.push({ kind: "write", path: path.join(root, ...rel.split("/")), content });
|
||||
}
|
||||
// 업그레이드 판정에 쓰는 버전 스탬프
|
||||
actions.push({
|
||||
kind: "write",
|
||||
path: path.join(root, ".designpaca_version"),
|
||||
content: `${skill.version}\n`,
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** references 를 별도 디렉터리로 내보내는 타깃(Cursor·AGENTS.md)용 */
|
||||
export function referenceActions(root: string, skill: SkillSource): FileAction[] {
|
||||
const actions: FileAction[] = [];
|
||||
for (const [rel, content] of skill.files) {
|
||||
actions.push({ kind: "write", path: path.join(root, ...rel.split("/")), content });
|
||||
}
|
||||
actions.push({
|
||||
kind: "write",
|
||||
path: path.join(root, ".designpaca_version"),
|
||||
content: `${skill.version}\n`,
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문의 `references/...` 상대 경로를 실제 설치 위치로 바꾼다.
|
||||
*
|
||||
* Claude Code·Codex 는 스킬 디렉터리를 통째로 복사하므로 상대 경로가 그대로 맞다.
|
||||
* Cursor·Windsurf·AGENTS.md 는 본문만 다른 자리로 옮기고 references 는 별도 디렉터리에 풀기
|
||||
* 때문에, 재작성하지 않으면 본문이 가리키는 경로가 전부 존재하지 않는 곳을 가리킨다.
|
||||
*/
|
||||
export function rewriteRefPaths(body: string, prefix: string): string {
|
||||
return body.replaceAll("references/", `${prefix}/references/`);
|
||||
}
|
||||
|
||||
/** 어느 한 경로라도 있으면 그 도구가 설치돼 있다고 본다 */
|
||||
export async function anyExists(paths: string[]): Promise<boolean> {
|
||||
for (const p of paths) if (await exists(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function scopeRoot(ctx: TargetContext, userRel: string[], projectRel: string[]): string {
|
||||
return ctx.scope === "user"
|
||||
? path.join(ctx.home, ...userRel)
|
||||
: path.join(ctx.cwd, ...projectRel);
|
||||
}
|
||||
57
packages/core/src/targets/cursor.ts
Normal file
57
packages/core/src/targets/cursor.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
|
||||
|
||||
/** 본문과 references 가 함께 놓이는 루트 (본문 경로 재작성 기준) */
|
||||
const REF_PREFIX = ".cursor/rules/designpaca";
|
||||
|
||||
/**
|
||||
* Cursor — .cursor/rules/designpaca.mdc.
|
||||
* .mdc 프론트매터는 SKILL.md 와 필드가 다르다(description/globs/alwaysApply)므로 변환한다.
|
||||
*
|
||||
* Windsurf 는 이 파일을 읽지 않는다(.windsurf/rules/*.md 를 쓴다) — 별도 어댑터로 분리했다.
|
||||
*/
|
||||
export const cursor: TargetAdapter = {
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
hint: ".cursor/rules/designpaca.mdc — 프로젝트 단위",
|
||||
// Cursor 의 전역 규칙은 파일이 아니라 앱 설정(User Rules)이라 프로젝트 범위만 지원한다
|
||||
scopes: ["project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([path.join(ctx.cwd, ".cursor"), path.join(ctx.home, ".cursor")]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const rulesDir = path.join(ctx.cwd, ".cursor", "rules");
|
||||
const mdc = path.join(rulesDir, "designpaca.mdc");
|
||||
const refRoot = path.join(rulesDir, "designpaca");
|
||||
|
||||
const header = [
|
||||
"---",
|
||||
`description: ${ctx.skill.description}`,
|
||||
"globs:",
|
||||
"alwaysApply: false",
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const actions: FileAction[] = [
|
||||
{
|
||||
kind: "write",
|
||||
path: mdc,
|
||||
content: header + rewriteRefPaths(ctx.skill.body, REF_PREFIX),
|
||||
},
|
||||
...referenceActions(refRoot, ctx.skill),
|
||||
];
|
||||
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root: rulesDir,
|
||||
actions,
|
||||
alreadyInstalled: await exists(mdc),
|
||||
};
|
||||
},
|
||||
};
|
||||
17
packages/core/src/targets/index.ts
Normal file
17
packages/core/src/targets/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { TargetAdapter, TargetId } from "../types.ts";
|
||||
import { claudeCode } from "./claude-code.ts";
|
||||
import { codex } from "./codex.ts";
|
||||
import { cursor } from "./cursor.ts";
|
||||
import { windsurf } from "./windsurf.ts";
|
||||
import { agentsMd } from "./agents-md.ts";
|
||||
|
||||
export const ADAPTERS: TargetAdapter[] = [claudeCode, codex, cursor, windsurf, agentsMd];
|
||||
|
||||
export function getAdapter(id: TargetId): TargetAdapter {
|
||||
const a = ADAPTERS.find((x) => x.id === id);
|
||||
if (!a) throw new Error(`알 수 없는 설치 대상: ${id}`);
|
||||
return a;
|
||||
}
|
||||
|
||||
export { MARKER, rewriteRefPaths } from "./common.ts";
|
||||
export { claudeCode, codex, cursor, windsurf, agentsMd };
|
||||
66
packages/core/src/targets/windsurf.ts
Normal file
66
packages/core/src/targets/windsurf.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
|
||||
|
||||
const REF_PREFIX = ".windsurf/rules/designpaca";
|
||||
|
||||
/** Windsurf 규칙 파일의 하드 상한. 넘으면 잘려서 조용히 망가진다. */
|
||||
const WINDSURF_CHAR_LIMIT = 12_000;
|
||||
|
||||
/**
|
||||
* Windsurf — .windsurf/rules/designpaca.md.
|
||||
* Cursor 와 경로·프론트매터가 모두 다르다(trigger/globs, .mdc 아님).
|
||||
*
|
||||
* trigger 는 model_decision 을 쓴다. always_on 으로 두면 디자인 스킬이 모든 메시지의
|
||||
* 시스템 프롬프트에 상주한다.
|
||||
*/
|
||||
export const windsurf: TargetAdapter = {
|
||||
id: "windsurf",
|
||||
label: "Windsurf",
|
||||
hint: ".windsurf/rules/designpaca.md — 프로젝트 단위",
|
||||
scopes: ["project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([
|
||||
path.join(ctx.cwd, ".windsurf"),
|
||||
path.join(ctx.cwd, ".windsurfrules"),
|
||||
path.join(ctx.home, ".windsurf"),
|
||||
]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const rulesDir = path.join(ctx.cwd, ".windsurf", "rules");
|
||||
const rule = path.join(rulesDir, "designpaca.md");
|
||||
const refRoot = path.join(rulesDir, "designpaca");
|
||||
|
||||
const header = [
|
||||
"---",
|
||||
"trigger: model_decision",
|
||||
`description: ${ctx.skill.description}`,
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const body = header + rewriteRefPaths(ctx.skill.body, REF_PREFIX);
|
||||
|
||||
const actions: FileAction[] = [
|
||||
{ kind: "write", path: rule, content: body },
|
||||
...referenceActions(refRoot, ctx.skill),
|
||||
];
|
||||
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root: rulesDir,
|
||||
actions,
|
||||
alreadyInstalled: await exists(rule),
|
||||
// 상한을 넘으면 설치는 되지만 Windsurf 가 뒷부분을 버린다. 조용히 깨지느니 막는다.
|
||||
...(body.length > WINDSURF_CHAR_LIMIT
|
||||
? {
|
||||
blocked: `규칙 본문이 ${body.length}자로 Windsurf 상한(${WINDSURF_CHAR_LIMIT}자)을 넘는다. SKILL.md 를 줄여야 한다.`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
96
packages/core/src/types.ts
Normal file
96
packages/core/src/types.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/** 설치 대상 식별자 */
|
||||
export type TargetId = "claude-code" | "codex" | "cursor" | "windsurf" | "agents-md";
|
||||
|
||||
/** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */
|
||||
export type Scope = "user" | "project";
|
||||
|
||||
/** 한 파일에 대한 설치 동작 */
|
||||
export type FileAction =
|
||||
| { kind: "write"; path: string; content: string }
|
||||
/** 마커 블록으로 감싼 영역만 교체(사용자 문서 보존) */
|
||||
| { kind: "inject"; path: string; marker: string; content: string };
|
||||
|
||||
/** 설치 전 사용자에게 보여줄 계획 */
|
||||
export interface InstallPlan {
|
||||
target: TargetId;
|
||||
scope: Scope;
|
||||
/** 이 타깃이 파일을 쓰는 루트 (표시용) */
|
||||
root: string;
|
||||
actions: FileAction[];
|
||||
/** 이미 설치돼 있는가 */
|
||||
alreadyInstalled: boolean;
|
||||
/** 설치를 막는 사유. 있으면 apply 하지 않는다 */
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
/** 매니페스트에 기록되는 개별 파일 */
|
||||
export interface InstalledFile {
|
||||
path: string;
|
||||
/** 설치 시점 내용의 sha256 — 사용자 수정(드리프트) 감지에 쓴다 */
|
||||
sha256: string;
|
||||
/** 마커 주입 방식으로 설치된 파일인가 */
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
export interface InstallRecord {
|
||||
target: TargetId;
|
||||
scope: Scope;
|
||||
root: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
files: InstalledFile[];
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
/** 매니페스트 스키마 버전 — 향후 마이그레이션 판단용 */
|
||||
schema: 1;
|
||||
installs: InstallRecord[];
|
||||
}
|
||||
|
||||
/** doctor/update 가 쓰는 드리프트 판정 */
|
||||
export type DriftStatus = "ok" | "modified" | "missing";
|
||||
|
||||
export interface DriftReport {
|
||||
record: InstallRecord;
|
||||
files: { path: string; status: DriftStatus }[];
|
||||
/** 사용자가 수정한 파일 경로 */
|
||||
modified: string[];
|
||||
}
|
||||
|
||||
/** 타깃 어댑터가 구현해야 하는 인터페이스 */
|
||||
export interface TargetAdapter {
|
||||
id: TargetId;
|
||||
/** TUI 에 표시할 이름 */
|
||||
label: string;
|
||||
/** 한 줄 설명 */
|
||||
hint: string;
|
||||
/** 이 타깃이 지원하는 범위 */
|
||||
scopes: Scope[];
|
||||
/** 시스템에 이 도구가 설치돼 있는 흔적이 있는가 (기본 선택 여부 판단) */
|
||||
detect(ctx: TargetContext): Promise<boolean>;
|
||||
/** 설치 계획 수립 — 파일을 쓰지 않는다 */
|
||||
plan(ctx: TargetContext): Promise<InstallPlan>;
|
||||
}
|
||||
|
||||
export interface TargetContext {
|
||||
scope: Scope;
|
||||
/** 홈 디렉터리 */
|
||||
home: string;
|
||||
/** 현재 작업 디렉터리(프로젝트 범위일 때 기준) */
|
||||
cwd: string;
|
||||
/** 번들된 스킬 소스 */
|
||||
skill: SkillSource;
|
||||
}
|
||||
|
||||
/** 번들에 포함된 스킬 원본 */
|
||||
export interface SkillSource {
|
||||
version: string;
|
||||
/** SKILL.md 본문 (프론트매터 포함) */
|
||||
skillMd: string;
|
||||
/** references/ 이하 상대경로 → 내용 */
|
||||
files: Map<string, string>;
|
||||
/** 프론트매터를 제외한 본문 — 프론트매터를 쓰지 않는 포맷용 */
|
||||
body: string;
|
||||
/** 프론트매터에서 뽑은 description */
|
||||
description: string;
|
||||
}
|
||||
163
packages/core/test/installer.test.ts
Normal file
163
packages/core/test/installer.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { applyPlan, planInstall, removeInstall } from "../src/installer.ts";
|
||||
import { inspectDrift, readManifest } from "../src/manifest.ts";
|
||||
import { extractBlock } from "../src/marker.ts";
|
||||
import type { SkillSource } from "../src/types.ts";
|
||||
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-test-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const cwd = path.join(tmp, "proj");
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** 본문에 references 경로를 넣어 타깃별 경로 재작성을 검증할 수 있게 한다 */
|
||||
const BODY = "# 본문\n\n토큰은 `references/tokens.md` 를 봐라.";
|
||||
|
||||
const skill: SkillSource = {
|
||||
version: "9.9.9",
|
||||
skillMd: ['---', 'name: designpaca', 'description: "테스트용"', '---', '', BODY, ''].join("\n"),
|
||||
files: new Map([["references/tokens.md", "# 토큰\n"]]),
|
||||
body: BODY,
|
||||
description: "테스트용",
|
||||
};
|
||||
|
||||
const env = { home, cwd };
|
||||
|
||||
test("claude-code: 설치 → 드리프트 없음 → 제거", async () => {
|
||||
const plan = await planInstall("claude-code", "user", skill, env);
|
||||
assert.equal(plan.alreadyInstalled, false);
|
||||
assert.equal(plan.actions.length, 3); // SKILL.md + references 1개 + 버전 스탬프
|
||||
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
assert.equal(res.written.length, 3);
|
||||
assert.equal(res.skipped.length, 0);
|
||||
|
||||
const skillMd = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8");
|
||||
assert.ok(skillMd.includes("name: designpaca"));
|
||||
// 디렉터리를 통째로 복사하는 타깃은 상대 경로가 그대로 맞다. 재작성하면 안 된다.
|
||||
assert.ok(skillMd.includes("`references/tokens.md`"));
|
||||
assert.ok(!skillMd.includes(".claude/skills"));
|
||||
|
||||
const drift = await inspectDrift(res.record);
|
||||
assert.deepEqual(drift.modified, []);
|
||||
|
||||
const removed = await removeInstall(res.record, { env });
|
||||
assert.equal(removed.removed.length, 3);
|
||||
assert.equal(removed.keptModified.length, 0);
|
||||
assert.equal((await readManifest(home)).installs.length, 0);
|
||||
await assert.rejects(() => fs.access(plan.root));
|
||||
});
|
||||
|
||||
test("사용자가 고친 파일은 update 가 건너뛴다", async () => {
|
||||
const plan = await planInstall("claude-code", "user", skill, env);
|
||||
const first = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const target = path.join(plan.root, "SKILL.md");
|
||||
await fs.writeFile(target, "내가 고친 내용\n", "utf8");
|
||||
|
||||
const drift = await inspectDrift(first.record);
|
||||
assert.deepEqual(drift.modified, [target]);
|
||||
|
||||
const again = await applyPlan(await planInstall("claude-code", "user", skill, env), skill.version, {
|
||||
env,
|
||||
});
|
||||
assert.deepEqual(again.skipped, [target]);
|
||||
assert.equal(await fs.readFile(target, "utf8"), "내가 고친 내용\n");
|
||||
|
||||
// force 면 덮되 .orig 로 남긴다
|
||||
const forced = await applyPlan(await planInstall("claude-code", "user", skill, env), skill.version, {
|
||||
env,
|
||||
force: true,
|
||||
});
|
||||
assert.equal(forced.skipped.length, 0);
|
||||
assert.equal(forced.backedUp.length, 1);
|
||||
assert.equal(await fs.readFile(`${target}.orig`, "utf8"), "내가 고친 내용\n");
|
||||
assert.ok((await fs.readFile(target, "utf8")).includes("name: designpaca"));
|
||||
|
||||
await removeInstall(forced.record, { env });
|
||||
await fs.rm(`${target}.orig`, { force: true });
|
||||
});
|
||||
|
||||
test("agents-md: 포인터만 주입하고 본문은 별도 디렉터리에 푼다", async () => {
|
||||
const doc = path.join(cwd, "AGENTS.md");
|
||||
await fs.writeFile(doc, "# 내 프로젝트\n\n내 지침.\n", "utf8");
|
||||
|
||||
const plan = await planInstall("agents-md", "project", skill, env);
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const after1 = await fs.readFile(doc, "utf8");
|
||||
assert.ok(after1.includes("# 내 프로젝트"));
|
||||
assert.ok(after1.includes("내 지침."));
|
||||
|
||||
const block = extractBlock(after1, "designpaca") ?? "";
|
||||
// AGENTS.md 는 항상 로드된다. 본문 전체가 아니라 포인터만 들어가야 한다.
|
||||
assert.ok(block.includes(".designpaca/SKILL.md"), "포인터가 없다");
|
||||
assert.ok(!block.includes("토큰은"), "본문이 통째로 들어갔다");
|
||||
assert.ok(block.length < 1000, `블록이 ${block.length}자로 너무 크다`);
|
||||
|
||||
// 본문은 .designpaca/ 에 있고, 그 안의 참조 경로가 재작성돼 있어야 한다
|
||||
const skillMd = await fs.readFile(path.join(cwd, ".designpaca", "SKILL.md"), "utf8");
|
||||
assert.ok(skillMd.includes(".designpaca/references/tokens.md"), "참조 경로가 재작성되지 않았다");
|
||||
await fs.access(path.join(cwd, ".designpaca", "references", "tokens.md"));
|
||||
|
||||
// 문서의 다른 곳을 고쳐도 드리프트로 잡히면 안 된다 (블록 안쪽만 본다)
|
||||
await fs.writeFile(doc, after1.replace("내 지침.", "내 지침을 고쳤다."), "utf8");
|
||||
assert.deepEqual((await inspectDrift(res.record)).modified, []);
|
||||
|
||||
await removeInstall(res.record, { env });
|
||||
const after2 = await fs.readFile(doc, "utf8");
|
||||
assert.ok(after2.includes("내 지침을 고쳤다."));
|
||||
assert.ok(!after2.includes("designpaca:start"));
|
||||
});
|
||||
|
||||
test("cursor 는 user 범위를 거부한다", async () => {
|
||||
const plan = await planInstall("cursor", "user", skill, env);
|
||||
assert.ok(plan.blocked);
|
||||
await assert.rejects(() => applyPlan(plan, skill.version, { env }));
|
||||
});
|
||||
|
||||
test("cursor: .mdc 프론트매터로 변환하고 참조 경로를 재작성한다", async () => {
|
||||
const plan = await planInstall("cursor", "project", skill, env);
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const mdc = await fs.readFile(path.join(cwd, ".cursor", "rules", "designpaca.mdc"), "utf8");
|
||||
assert.ok(mdc.startsWith("---\ndescription: 테스트용"));
|
||||
assert.ok(mdc.includes("alwaysApply: false"));
|
||||
assert.ok(mdc.includes("# 본문"));
|
||||
// 본문과 references 가 다른 디렉터리에 놓이므로 경로가 재작성돼야 한다
|
||||
assert.ok(
|
||||
mdc.includes(".cursor/rules/designpaca/references/tokens.md"),
|
||||
"참조 경로가 재작성되지 않았다",
|
||||
);
|
||||
await fs.access(path.join(cwd, ".cursor", "rules", "designpaca", "references", "tokens.md"));
|
||||
|
||||
await removeInstall(res.record, { env });
|
||||
});
|
||||
|
||||
test("windsurf: Cursor 와 다른 경로·프론트매터를 쓴다", async () => {
|
||||
const plan = await planInstall("windsurf", "project", skill, env);
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const rule = await fs.readFile(path.join(cwd, ".windsurf", "rules", "designpaca.md"), "utf8");
|
||||
// always_on 이면 디자인 스킬이 모든 메시지의 시스템 프롬프트에 상주한다
|
||||
assert.ok(rule.includes("trigger: model_decision"));
|
||||
assert.ok(!rule.includes("alwaysApply"));
|
||||
assert.ok(rule.includes(".windsurf/rules/designpaca/references/tokens.md"));
|
||||
|
||||
await removeInstall(res.record, { env });
|
||||
});
|
||||
|
||||
test("windsurf: 12,000자 상한을 넘으면 설치를 막는다", async () => {
|
||||
const huge: SkillSource = { ...skill, body: "가".repeat(13_000) };
|
||||
const plan = await planInstall("windsurf", "project", huge, env);
|
||||
assert.ok(plan.blocked, "상한 초과인데 막지 않았다");
|
||||
assert.match(plan.blocked, /상한/);
|
||||
});
|
||||
50
packages/core/test/marker.test.ts
Normal file
50
packages/core/test/marker.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { extractBlock, hasBlock, removeBlock, upsertBlock } from "../src/marker.ts";
|
||||
|
||||
const M = "designpaca";
|
||||
|
||||
test("빈 문서에 블록을 넣는다", () => {
|
||||
const out = upsertBlock("", M, "본문");
|
||||
assert.ok(hasBlock(out, M));
|
||||
assert.equal(extractBlock(out, M), "본문");
|
||||
});
|
||||
|
||||
test("기존 문서 뒤에 붙이고 원문을 보존한다", () => {
|
||||
const doc = "# 내 지침\n\n건드리지 마라.\n";
|
||||
const out = upsertBlock(doc, M, "우리 블록");
|
||||
assert.ok(out.startsWith("# 내 지침"));
|
||||
assert.ok(out.includes("건드리지 마라."));
|
||||
assert.equal(extractBlock(out, M), "우리 블록");
|
||||
});
|
||||
|
||||
test("두 번째 호출은 블록 내용만 교체한다", () => {
|
||||
const first = upsertBlock("# 문서\n\n앞부분\n", M, "v1");
|
||||
const second = upsertBlock(first, M, "v2");
|
||||
assert.equal(extractBlock(second, M), "v2");
|
||||
assert.ok(!second.includes("v1"));
|
||||
assert.ok(second.includes("앞부분"));
|
||||
// 블록이 두 개로 늘어나면 안 된다
|
||||
assert.equal(second.split("designpaca:start").length - 1, 1);
|
||||
});
|
||||
|
||||
test("블록 뒤에 사용자가 쓴 내용도 보존한다", () => {
|
||||
const doc = upsertBlock("앞\n", M, "블록") + "\n뒤에 쓴 내용\n";
|
||||
const out = upsertBlock(doc, M, "새 블록");
|
||||
assert.ok(out.includes("앞"));
|
||||
assert.ok(out.includes("뒤에 쓴 내용"));
|
||||
assert.equal(extractBlock(out, M), "새 블록");
|
||||
});
|
||||
|
||||
test("제거하면 우리 블록만 사라진다", () => {
|
||||
const doc = upsertBlock("# 문서\n\n내용\n", M, "블록");
|
||||
const out = removeBlock(doc, M);
|
||||
assert.ok(!hasBlock(out, M));
|
||||
assert.ok(out.includes("# 문서"));
|
||||
assert.ok(out.includes("내용"));
|
||||
});
|
||||
|
||||
test("블록이 없는 문서를 제거해도 그대로다", () => {
|
||||
const doc = "# 문서\n";
|
||||
assert.equal(removeBlock(doc, M), doc);
|
||||
});
|
||||
23
packages/core/test/skill-source.test.ts
Normal file
23
packages/core/test/skill-source.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { splitFrontmatter } from "../src/skill-source.ts";
|
||||
|
||||
test("프론트매터와 본문을 나눈다", () => {
|
||||
const { fm, body } = splitFrontmatter(
|
||||
['---', 'name: designpaca', 'description: "따옴표 있는 값"', '---', '', '# 제목', '내용'].join("\n"),
|
||||
);
|
||||
assert.equal(fm["name"], "designpaca");
|
||||
assert.equal(fm["description"], "따옴표 있는 값");
|
||||
assert.ok(body.trim().startsWith("# 제목"));
|
||||
});
|
||||
|
||||
test("프론트매터가 없으면 전체가 본문이다", () => {
|
||||
const { fm, body } = splitFrontmatter("# 제목만 있다");
|
||||
assert.deepEqual(fm, {});
|
||||
assert.equal(body, "# 제목만 있다");
|
||||
});
|
||||
|
||||
test("CRLF 문서도 처리한다", () => {
|
||||
const { fm } = splitFrontmatter("---\r\nname: designpaca\r\n---\r\n본문");
|
||||
assert.equal(fm["name"], "designpaca");
|
||||
});
|
||||
5
packages/core/tsconfig.json
Normal file
5
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["node"], "noEmit": true },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue