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:
Yun Chan 2026-08-20 10:48:00 +09:00
commit 8808c672dc
135 changed files with 38838 additions and 0 deletions

View file

@ -0,0 +1,55 @@
import { inspectDrift, pruneDeadInstalls, readManifest, tildify } from "@designpaca/core";
import { getVersion } from "../skill.ts";
import { bad, dim, heading, info, ok, table, warn } from "../ui.ts";
export async function runDoctor(): Promise<number> {
const version = await getVersion();
const pruned = await pruneDeadInstalls();
const manifest = await readManifest();
console.log(heading("designpaca 진단"));
console.log(
table([
["CLI 버전", version],
["Node", process.version],
["설치 기록", `${manifest.installs.length}건`],
]),
);
if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`));
if (manifest.installs.length === 0) {
console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라."));
return 0;
}
let problems = 0;
for (const rec of manifest.installs) {
const drift = await inspectDrift(rec);
const missing = drift.files.filter((f) => f.status === "missing");
const modified = drift.files.filter((f) => f.status === "modified");
console.log(heading(`${rec.target} (${rec.scope})`));
console.log(
table([
["위치", tildify(rec.root)],
["버전", rec.version === version ? rec.version : `${rec.version} → ${version} 업데이트 가능`],
["파일", `${rec.files.length}개`],
]),
);
if (rec.version !== version) problems++;
if (missing.length > 0) {
problems++;
console.log(bad(`파일 ${missing.length}개가 사라졌다 — \`designpaca update --force\` 로 복구해라`));
for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
}
if (modified.length > 0) {
console.log(warn(`직접 수정한 파일 ${modified.length}개 — 업데이트가 이 파일들을 건너뛴다`));
for (const m of modified.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
}
if (missing.length === 0 && modified.length === 0 && rec.version === version) {
console.log(ok("정상"));
}
}
return problems > 0 ? 1 : 0;
}

View file

@ -0,0 +1,90 @@
import {
applyPlan,
planInstall,
tildify,
type Scope,
type TargetId,
} from "@designpaca/core";
import { getSkill } from "../skill.ts";
import { bad, dim, fold, heading, info, ok, warn } from "../ui.ts";
export interface InstallOptions {
targets: TargetId[];
scope: Scope;
force?: boolean;
/** 실제로 쓰지 않고 계획만 출력 */
dryRun?: boolean;
}
export interface InstallOutcome {
target: TargetId;
root: string;
written: number;
skipped: string[];
blocked?: string;
}
/** install/update/TUI 가 공유하는 실제 설치 실행부 */
export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]> {
const skill = await getSkill();
const outcomes: InstallOutcome[] = [];
for (const target of opts.targets) {
const plan = await planInstall(target, opts.scope, skill);
if (plan.blocked) {
outcomes.push({ target, root: "", written: 0, skipped: [], blocked: plan.blocked });
continue;
}
if (opts.dryRun) {
outcomes.push({ target, root: plan.root, written: plan.actions.length, skipped: [] });
continue;
}
const res = await applyPlan(plan, skill.version, { force: opts.force });
outcomes.push({
target,
root: plan.root,
written: res.written.length,
skipped: res.skipped,
});
}
return outcomes;
}
export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void {
console.log(heading(dryRun ? "설치 계획" : "설치 결과"));
for (const o of outcomes) {
if (o.blocked) {
console.log(bad(`${o.target}: ${o.blocked}`));
continue;
}
const verb = dryRun ? "쓸 파일" : "설치됨";
console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`));
if (o.skipped.length > 0) {
console.log(warn(` 직접 수정한 파일이라 건드리지 않았다 (--force 로 덮어쓴다):`));
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
}
}
}
/** 설치 후 안내 — 여기서 끝내지 말고 다음 행동을 알려준다 */
export function printNextSteps(targets: TargetId[]): void {
console.log(heading("다음 단계"));
if (targets.includes("claude-code")) {
console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`));
}
if (targets.includes("codex")) {
console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`));
}
if (targets.includes("cursor")) {
console.log(info(`Cursor 는 ${dim(".cursor/rules/designpaca.mdc")} 를 프로젝트 열 때 읽는다`));
}
if (targets.includes("windsurf")) {
console.log(info(`Windsurf 는 ${dim(".windsurf/rules/designpaca.md")} 를 읽는다`));
}
if (targets.includes("agents-md")) {
console.log(
info(`AGENTS.md 에는 포인터만 넣었다 — 본문은 ${dim(".designpaca/SKILL.md")} 에 있다`),
);
}
console.log(info(`문서: ${dim("https://designpaca.chanpaca.net")}`));
}

View file

@ -0,0 +1,30 @@
import { ADAPTERS, readManifest, tildify } from "@designpaca/core";
import { getVersion } from "../skill.ts";
import { dim, heading, info, ok, padDisplay, table } from "../ui.ts";
export async function runList(): Promise<number> {
const version = await getVersion();
const manifest = await readManifest();
console.log(heading("설치 가능한 대상"));
for (const a of ADAPTERS) {
console.log(` ${padDisplay(a.label, 18)} ${dim(a.hint)} ${dim(`[${a.scopes.join("|")}]`)}`);
}
console.log(heading(`현재 설치 (CLI v${version})`));
if (manifest.installs.length === 0) {
console.log(info("없음"));
return 0;
}
for (const rec of manifest.installs) {
console.log(ok(`${rec.target} (${rec.scope})`));
console.log(
table([
["위치", tildify(rec.root)],
["버전", rec.version],
["설치 시각", new Date(rec.installedAt).toLocaleString("ko-KR")],
]),
);
}
return 0;
}

View file

@ -0,0 +1,30 @@
import { readManifest, removeInstall, tildify, type TargetId } from "@designpaca/core";
import { dim, heading, ok, warn } from "../ui.ts";
export async function runUninstall(opts: {
targets?: TargetId[];
force?: boolean;
}): Promise<number> {
const manifest = await readManifest();
const targets = manifest.installs.filter(
(i) => !opts.targets || opts.targets.length === 0 || opts.targets.includes(i.target),
);
if (targets.length === 0) {
console.log(warn("제거할 설치 기록이 없다"));
return 0;
}
console.log(heading("제거"));
for (const rec of targets) {
const res = await removeInstall(rec, { force: opts.force });
console.log(ok(`${rec.target} (${rec.scope}) — ${res.removed.length}개 제거 ${dim(tildify(rec.root))}`));
if (res.keptModified.length > 0) {
console.log(
warn(` 직접 수정한 파일 ${res.keptModified.length}개는 남겼다 (--force 로 함께 지운다)`),
);
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
}
}
return 0;
}

View file

@ -0,0 +1,55 @@
import { 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";
/**
* 설치 기록을 그대로 따라가며 재설치한다.
* 사용자가 고친 파일은 기본적으로 보존된다(applyPlan 이 판단) — 강제로 맞추려면 --force.
*/
export async function runUpdate(opts: { force?: boolean } = {}): Promise<number> {
const version = await getVersion();
const manifest = await readManifest();
if (manifest.installs.length === 0) {
console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해라."));
return 1;
}
console.log(heading(`업데이트 → v${version}`));
const stale = manifest.installs.filter((i) => i.version !== version);
if (stale.length === 0) {
console.log(ok("모든 설치가 이미 최신이다"));
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
let broken = 0;
for (const rec of manifest.installs) {
const drift = await inspectDrift(rec);
const missing = drift.files.filter((f) => f.status === "missing");
if (missing.length > 0) {
broken += missing.length;
console.log(warn(`${rec.target}: 파일 ${missing.length}개가 사라졌다 ${dim(tildify(rec.root))}`));
}
if (drift.modified.length > 0) {
console.log(info(`${rec.target}: 직접 수정한 파일 ${drift.modified.length}개는 그대로 둔다`));
}
}
if (!opts.force) {
console.log(dim(broken > 0 ? " 사라진 파일을 복구하려면 --force" : " 강제로 다시 쓰려면 --force"));
return broken > 0 ? 1 : 0;
}
}
for (const rec of manifest.installs) {
console.log(info(`${rec.target} (${rec.scope}) ${dim(tildify(rec.root))} — ${rec.version} → ${version}`));
const outcomes = await runInstall({
targets: [rec.target],
scope: rec.scope,
force: opts.force,
});
printOutcomes(outcomes);
}
return 0;
}

177
packages/cli/src/index.ts Normal file
View file

@ -0,0 +1,177 @@
import { ADAPTERS, type Scope, type TargetId } from "@designpaca/core";
import { getVersion } from "./skill.ts";
import { checkForUpdate } from "./update-check.ts";
import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts";
import { runDoctor } from "./commands/doctor.ts";
import { runUpdate } from "./commands/update.ts";
import { runUninstall } from "./commands/uninstall.ts";
import { runList } from "./commands/list.ts";
import { onboard } from "./tui/onboard.ts";
import { accent, bad, bold, dim, warn } from "./ui.ts";
const VALID_TARGETS = ADAPTERS.map((a) => a.id) as string[];
interface Args {
command: string;
targets: TargetId[];
scope: Scope;
yes: boolean;
force: boolean;
dryRun: boolean;
help: boolean;
version: boolean;
}
function parse(argv: string[]): Args {
const out: Args = {
command: "",
targets: [],
scope: "user",
yes: false,
force: false,
dryRun: false,
help: false,
version: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i] as string;
if (a === "--help" || a === "-h") out.help = true;
else if (a === "--version" || a === "-v") out.version = true;
else if (a === "--yes" || a === "-y") out.yes = true;
else if (a === "--force" || a === "-f") out.force = true;
else if (a === "--dry-run") out.dryRun = true;
else if (a === "--no-update-check") process.env["DESIGNPACA_NO_UPDATE_CHECK"] = "1";
else if (a === "--target" || a === "-t") {
const v = argv[++i];
if (v) out.targets.push(...(v.split(",") as TargetId[]));
} else if (a.startsWith("--target=")) {
out.targets.push(...(a.slice(9).split(",") as TargetId[]));
} else if (a === "--scope" || a === "-s") {
const v = argv[++i];
if (v) out.scope = v as Scope;
} else if (a.startsWith("--scope=")) {
out.scope = a.slice(8) as Scope;
} else if (!a.startsWith("-") && !out.command) {
out.command = a;
}
}
return out;
}
function usage(version: string): string {
return `
${accent("designpaca")} ${dim(`v${version}`)} — 웹 디자인 파이프라인 스킬 설치기
${bold("사용법")}
npx designpaca 대화형 온보딩 (권장)
npx designpaca install 설치
npx designpaca update 최신 스킬로 갱신
npx designpaca uninstall 제거
npx designpaca doctor 설치 상태 진단
npx designpaca list 설치 가능 대상과 현재 설치 목록
${bold("옵션")}
-t, --target <id[,id]> 설치 대상: ${VALID_TARGETS.join(", ")}
-s, --scope <범위> user | project ${dim("(기본: user)")}
-y, --yes 확인 없이 진행
-f, --force 직접 수정한 파일도 덮어쓴다 ${dim("(.orig 로 백업)")}
--dry-run 쓰지 않고 계획만 출력
--no-update-check 새 버전 확인을 건너뛴다
-h, --help 이 도움말
-v, --version 버전
${bold("예시")}
${dim("npx designpaca install -t claude-code,codex -s user -y")}
${dim("npx designpaca install -t cursor -s project")}
${dim("npx designpaca update --force")}
`.trimStart();
}
async function main(): Promise<number> {
const args = parse(process.argv.slice(2));
const version = await getVersion();
if (args.version) {
console.log(version);
return 0;
}
if (args.help) {
console.log(usage(version));
return 0;
}
const bad_ = args.targets.filter((t) => !VALID_TARGETS.includes(t));
if (bad_.length > 0) {
console.error(bad(`알 수 없는 대상: ${bad_.join(", ")}`));
console.error(dim(` 가능한 값: ${VALID_TARGETS.join(", ")}`));
return 2;
}
if (args.scope !== "user" && args.scope !== "project") {
console.error(bad(`알 수 없는 범위: ${args.scope} (user | project)`));
return 2;
}
// 대화형으로 쓸 수 있는 환경이면 인자 없이 실행했을 때 온보딩으로 보낸다
const interactive = process.stdout.isTTY && !args.yes;
if (!args.command || args.command === "onboard") {
if (interactive) return onboard();
console.log(usage(version));
return 0;
}
let code = 0;
switch (args.command) {
case "install": {
const targets = args.targets.length > 0 ? args.targets : (["claude-code"] as TargetId[]);
if (interactive && args.targets.length === 0) return onboard();
const outcomes = await runInstall({
targets,
scope: args.scope,
force: args.force,
dryRun: args.dryRun,
});
printOutcomes(outcomes, args.dryRun);
if (!args.dryRun) printNextSteps(targets);
code = outcomes.some((o) => o.blocked) ? 1 : 0;
break;
}
case "update":
code = await runUpdate({ force: args.force });
break;
case "uninstall":
case "remove":
code = await runUninstall({ targets: args.targets, force: args.force });
break;
case "doctor":
code = await runDoctor();
break;
case "list":
case "ls":
code = await runList();
break;
default:
console.error(bad(`알 수 없는 명령: ${args.command}`));
console.log(usage(version));
return 2;
}
// 명령을 마친 뒤에만 알린다 — 시작을 지연시키지 않는다
const latest = await checkForUpdate(version);
if (latest) {
console.log(
"\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`),
);
}
return code;
}
main()
.then((code) => {
process.exitCode = code;
})
.catch((err: unknown) => {
console.error(bad(err instanceof Error ? err.message : String(err)));
if (process.env["DESIGNPACA_DEBUG"] === "1" && err instanceof Error) console.error(err.stack);
process.exitCode = 1;
});

28
packages/cli/src/skill.ts Normal file
View file

@ -0,0 +1,28 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadSkillSource, type SkillSource } from "@designpaca/core";
/** 배포물 안에 함께 실린 스킬 원본 위치 (dist/skill) */
export function bundledSkillRoot(): string {
return fileURLToPath(new URL("./skill", import.meta.url));
}
let cached: SkillSource | null = null;
export async function getSkill(): Promise<SkillSource> {
if (cached) return cached;
const root = bundledSkillRoot();
let version = "0.0.0";
try {
version = (await fs.readFile(path.join(root, ".designpaca_version"), "utf8")).trim();
} catch {
/* 스탬프가 없으면 0.0.0 으로 두고 진행한다 — 설치 자체를 막을 이유는 없다 */
}
cached = await loadSkillSource(root, version);
return cached;
}
export async function getVersion(): Promise<string> {
return (await getSkill()).version;
}

View file

@ -0,0 +1,125 @@
import * as p from "@clack/prompts";
import path from "node:path";
import {
ADAPTERS,
detectTargets,
getAdapter,
planInstall,
tildify,
type Scope,
type TargetId,
} from "@designpaca/core";
import { getSkill } from "../skill.ts";
import { runInstall, printNextSteps, printOutcomes } from "../commands/install.ts";
import { accent, bold, dim } from "../ui.ts";
/** 선택한 범위를 어댑터가 지원하지 않으면 지원 가능한 범위로 낮춘다 */
function effectiveScope(target: TargetId, wanted: Scope): Scope {
const a = getAdapter(target);
return a.scopes.includes(wanted) ? wanted : (a.scopes[0] as Scope);
}
export async function onboard(): Promise<number> {
const skill = await getSkill();
console.clear();
p.intro(`${accent("◆ designpaca")} ${dim(`v${skill.version}`)}`);
p.note(
[
"브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·",
"구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.",
"",
`${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`,
].join("\n"),
"무엇을 설치하나",
);
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
const detected = await detectTargets(skill);
const targets = await p.multiselect<TargetId>({
message: "어디에 설치할까?",
options: ADAPTERS.map((a) => ({
value: a.id,
label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""),
hint: a.hint,
})),
initialValues: detected.length > 0 ? detected : ["claude-code"],
required: true,
});
if (p.isCancel(targets)) return cancel();
const wanted = await p.select<Scope>({
message: "설치 범위",
options: [
{ value: "user", label: "전역", hint: "홈 디렉터리 — 모든 프로젝트에서 쓴다" },
{ value: "project", label: "이 프로젝트만", hint: tildify(process.cwd()) },
],
initialValue: "user",
});
if (p.isCancel(wanted)) return cancel();
// 범위가 낮춰진 타깃이 있으면 미리 알린다 — 설치 후에 "왜 여기 깔렸지"가 되지 않게
const downgraded = targets.filter((t) => effectiveScope(t, wanted) !== wanted);
if (downgraded.length > 0) {
p.log.warn(
downgraded
.map((t) => {
const a = getAdapter(t);
return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원한다 → ${effectiveScope(t, wanted)} 로 설치한다`;
})
.join("\n"),
);
}
// 무엇이 어디에 쓰이는지 먼저 보여준다
const previews: string[] = [];
for (const t of targets) {
const scope = effectiveScope(t, wanted);
const plan = await planInstall(t, scope, skill);
if (plan.blocked) {
previews.push(`${bold(getAdapter(t).label)}\n ${dim(plan.blocked)}`);
continue;
}
const dirs = new Set(plan.actions.map((a) => path.dirname(a.path)));
previews.push(
[
`${bold(getAdapter(t).label)} ${plan.alreadyInstalled ? dim("(재설치)") : ""}`,
` ${dim(tildify(plan.root))}`,
` ${dim(`파일 ${plan.actions.length}개 · 디렉터리 ${dirs.size}개`)}`,
].join("\n"),
);
}
p.note(previews.join("\n\n"), "설치 계획");
const go = await p.confirm({ message: "이대로 설치할까?", initialValue: true });
if (p.isCancel(go) || !go) return cancel();
const s = p.spinner();
s.start("설치 중");
const outcomes = [];
try {
for (const t of targets) {
const scope = effectiveScope(t, wanted);
s.message(`설치 중 — ${getAdapter(t).label}`);
outcomes.push(...(await runInstall({ targets: [t], scope })));
}
s.stop("설치 완료");
} catch (err) {
s.stop("설치 실패", 1);
p.log.error(err instanceof Error ? err.message : String(err));
return 1;
}
printOutcomes(outcomes);
printNextSteps(targets);
p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`);
return 0;
}
function cancel(): number {
p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다.");
return 130;
}

55
packages/cli/src/ui.ts Normal file
View file

@ -0,0 +1,55 @@
import pc from "picocolors";
/** designpaca 의 강조색 — 한 가지만 쓴다 */
export const accent = (s: string) => pc.magenta(s);
export const dim = (s: string) => pc.dim(s);
export const bold = (s: string) => pc.bold(s);
export const ok = (s: string) => `${pc.green("✓")} ${s}`;
export const warn = (s: string) => `${pc.yellow("!")} ${s}`;
export const bad = (s: string) => `${pc.red("✗")} ${s}`;
export const info = (s: string) => `${pc.cyan("·")} ${s}`;
/**
* 터미널에서 차지하는 칸 수. 한글·한자·가나는 2칸을 먹는다.
* String.length 로 padEnd 하면 한글이 섞인 표가 어긋난다.
*/
export function displayWidth(s: string): number {
// ANSI 이스케이프는 폭을 차지하지 않는다
const plain = s.replace(/\[[0-9;]*m/g, "");
let w = 0;
for (const ch of plain) {
const cp = ch.codePointAt(0) ?? 0;
const wide =
(cp >= 0x1100 && cp <= 0x115f) || // 한글 자모
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK 부수 ~ 이
(cp >= 0xac00 && cp <= 0xd7a3) || // 한글 음절
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 호환
(cp >= 0xfe30 && cp <= 0xfe6f) ||
(cp >= 0xff00 && cp <= 0xff60) || // 전각
(cp >= 0xffe0 && cp <= 0xffe6);
w += wide ? 2 : 1;
}
return w;
}
/** displayWidth 기준으로 오른쪽을 채운다 */
export function padDisplay(s: string, width: number): string {
const pad = width - displayWidth(s);
return pad > 0 ? s + " ".repeat(pad) : s;
}
export function heading(s: string): string {
return `\n${bold(s)}\n${dim("─".repeat(Math.min(s.length + 8, 56)))}`;
}
/** 긴 경로 목록을 접어서 보여준다 — 설치 계획이 화면을 삼키지 않게 */
export function fold(items: string[], max = 6): string[] {
if (items.length <= max) return items;
return [...items.slice(0, max), dim(`… 그 외 ${items.length - max}개`)];
}
export function table(rows: [string, string][]): string {
const w = Math.max(...rows.map(([k]) => k.length));
return rows.map(([k, v]) => ` ${k.padEnd(w)} ${dim(v)}`).join("\n");
}

View file

@ -0,0 +1,56 @@
import { readIfExists, updateCachePath, writeAtomic } from "@designpaca/core";
const DAY = 24 * 60 * 60 * 1000;
const REGISTRY = "https://registry.npmjs.org/designpaca/latest";
interface Cache {
checkedAt: number;
latest: string;
}
function isNewer(latest: string, current: string): boolean {
const norm = (v: string) => v.split("-")[0]!.split(".").map((n) => Number.parseInt(n, 10) || 0);
const [a, b] = [norm(latest), norm(current)];
for (let i = 0; i < 3; i++) {
const l = a[i] ?? 0;
const c = b[i] ?? 0;
if (l !== c) return l > c;
}
return false;
}
/**
* 하루 한 번만 레지스트리를 본다. 네트워크가 없거나 느리면 조용히 포기한다 —
* 업데이트 확인 때문에 CLI 가 멈추는 일은 없어야 한다.
*/
export async function checkForUpdate(current: string): Promise<string | null> {
if (process.env["DESIGNPACA_NO_UPDATE_CHECK"] === "1") return null;
const cachePath = updateCachePath();
const raw = await readIfExists(cachePath);
if (raw) {
try {
const c = JSON.parse(raw) as Cache;
if (Date.now() - c.checkedAt < DAY) {
return isNewer(c.latest, current) ? c.latest : null;
}
} catch {
/* 캐시가 깨졌으면 새로 받는다 */
}
}
try {
const res = await fetch(REGISTRY, {
headers: { accept: "application/vnd.npm.install-v1+json" },
signal: AbortSignal.timeout(2500),
});
if (!res.ok) return null;
const body = (await res.json()) as { version?: string };
const latest = body.version;
if (!latest) return null;
await writeAtomic(cachePath, JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache));
return isNewer(latest, current) ? latest : null;
} catch {
return null;
}
}