sh-ui-cli 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SangHyeon Kim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # sh-ui-cli
2
+
3
+ sh-ui 디자인 시스템의 컴포넌트를 프로젝트로 복사하는 CLI. shadcn 방식 — 프로젝트가 소스를 소유한다.
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ # 프로젝트 dev 의존성으로
9
+ npm i -D sh-ui-cli
10
+
11
+ # 또는 ad-hoc 실행
12
+ npx sh-ui-cli <command>
13
+ ```
14
+
15
+ ## 사용법
16
+
17
+ ### init — 설정 파일 생성
18
+
19
+ ```bash
20
+ npx sh-ui init
21
+ # 대화형 프롬프트:
22
+ # platform: react | flutter
23
+ # base: neutral | zinc | slate
24
+ # radius: none | sm | md | lg | xl | full
25
+ # mode: light-dark | light | dark
26
+ ```
27
+
28
+ 비대화형 예:
29
+
30
+ ```bash
31
+ npx sh-ui init --platform react --base neutral --radius md --mode light-dark --yes
32
+ ```
33
+
34
+ ### add — 컴포넌트 추가
35
+
36
+ ```bash
37
+ npx sh-ui add button
38
+ npx sh-ui add card input
39
+ npx sh-ui add button --diff # 파일 변경 미리보기(실제 쓰지 않음)
40
+ ```
41
+
42
+ ### list — 설치된 컴포넌트 목록
43
+
44
+ ```bash
45
+ npx sh-ui list
46
+ ```
47
+
48
+ ### remove — 컴포넌트 제거
49
+
50
+ ```bash
51
+ npx sh-ui remove button
52
+ ```
53
+
54
+ ## 지원 플랫폼
55
+
56
+ - **React (Next.js)** — `src/shared/ui/` 또는 `sh-ui.config.json` 에 지정된 경로로 복사
57
+ - **Flutter** — `lib/sh_ui/widgets/` 또는 지정 경로로 복사
58
+
59
+ ## 설정 파일 (`sh-ui.config.json`)
60
+
61
+ ```json
62
+ {
63
+ "platform": "react",
64
+ "style": "default",
65
+ "theme": { "base": "neutral", "radius": "md", "mode": "light-dark" },
66
+ "paths": {
67
+ "tokens": "src/shared/styles/tokens.css",
68
+ "components": "src/shared/ui",
69
+ "utils": "src/shared/lib/utils.ts"
70
+ }
71
+ }
72
+ ```
73
+
74
+ ## 더 알아보기
75
+
76
+ - sh-ui 디자인 시스템: https://github.com/sanghyeonKim0201/sh-ui
77
+ - `sh-ui-create` (프로젝트 스캐폴드): https://www.npmjs.com/package/sh-ui-create
78
+
79
+ ## 라이선스
80
+
81
+ MIT
package/bin/sh-ui.mjs ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { init } from "../src/init.mjs";
3
+ import { add } from "../src/add.mjs";
4
+ import { list } from "../src/list.mjs";
5
+ import { remove } from "../src/remove.mjs";
6
+
7
+ const [, , cmd, ...rest] = process.argv;
8
+
9
+ const usage = `사용법:
10
+ sh-ui init 설정 파일(sh-ui.config.json) 생성
11
+ sh-ui add <component...> 컴포넌트 소스를 프로젝트로 복사하고
12
+ 필요한 외부 패키지를 자동 설치
13
+ 특수값: tokens → 설정 기반 토큰 파일 생성
14
+ sh-ui list 현재 설치된 컴포넌트 목록 표시
15
+ sh-ui remove <component...> 설치된 컴포넌트 파일 삭제
16
+ 옵션:
17
+ --skip-install (add) 외부 패키지 자동 설치 생략
18
+ --diff (add) 파일을 쓰지 않고 변경 내역만 출력
19
+ --all (list) 설치되지 않은 컴포넌트까지 표시
20
+ --force (remove) 사용자가 수정한 파일도 삭제
21
+ --dry-run (remove) 삭제 대상만 출력하고 실행 안 함
22
+ `;
23
+
24
+ try {
25
+ switch (cmd) {
26
+ case "init":
27
+ await init({ cwd: process.cwd(), args: rest });
28
+ break;
29
+ case "add": {
30
+ const skipInstall = rest.includes("--skip-install");
31
+ const diffMode = rest.includes("--diff");
32
+ const names = rest.filter((a) => !a.startsWith("--"));
33
+ if (names.length === 0) {
34
+ console.error("에러: 추가할 컴포넌트 이름이 필요합니다.\n");
35
+ console.error(usage);
36
+ process.exit(1);
37
+ }
38
+ await add({ cwd: process.cwd(), names, skipInstall, diffMode });
39
+ break;
40
+ }
41
+ case "list": {
42
+ const all = rest.includes("--all");
43
+ await list({ cwd: process.cwd(), all });
44
+ break;
45
+ }
46
+ case "remove":
47
+ case "rm": {
48
+ const force = rest.includes("--force");
49
+ const dryRun = rest.includes("--dry-run");
50
+ const names = rest.filter((a) => !a.startsWith("--"));
51
+ if (names.length === 0) {
52
+ console.error("에러: 삭제할 컴포넌트 이름이 필요합니다.\n");
53
+ console.error(usage);
54
+ process.exit(1);
55
+ }
56
+ await remove({ cwd: process.cwd(), names, force, dryRun });
57
+ break;
58
+ }
59
+ case undefined:
60
+ case "-h":
61
+ case "--help":
62
+ console.log(usage);
63
+ break;
64
+ default:
65
+ console.error(`알 수 없는 명령: ${cmd}\n`);
66
+ console.error(usage);
67
+ process.exit(1);
68
+ }
69
+ } catch (err) {
70
+ console.error(`✗ ${err.message}`);
71
+ process.exit(1);
72
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "sh-ui-cli",
3
+ "version": "0.14.0",
4
+ "description": "sh-ui CLI — 디자인 시스템 컴포넌트를 프로젝트로 복사하는 CLI (sh-ui init / add / list / remove)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/sanghyeonKim0201/sh-ui.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/sanghyeonKim0201/sh-ui#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/issues"
14
+ },
15
+ "keywords": [
16
+ "sh-ui",
17
+ "cli",
18
+ "design-system",
19
+ "registry",
20
+ "components"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "bin": {
27
+ "sh-ui": "./bin/sh-ui.mjs"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "LICENSE",
33
+ "README.md"
34
+ ],
35
+ "scripts": {}
36
+ }
package/src/add.mjs ADDED
@@ -0,0 +1,280 @@
1
+ import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, resolve, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+ import { buildTokensCss, buildTokensDart } from "../../tokens/build.mjs";
7
+ import { formatUnifiedDiff } from "./diff.mjs";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const REPO_ROOT = resolve(__dirname, "../../..");
11
+
12
+ /** 컬러 출력 가능 여부: TTY + NO_COLOR 미설정. */
13
+ function canUseColor() {
14
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
15
+ }
16
+
17
+ /** `{components}/button.tsx` 처럼 config.paths 값으로 치환 */
18
+ function resolveDest(template, config) {
19
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (m, key) => {
20
+ const v = config.paths?.[key];
21
+ if (!v) throw new Error(`paths.${key} 가 sh-ui.config.json에 없습니다.`);
22
+ return v;
23
+ });
24
+ }
25
+
26
+ async function ensureDir(filePath) {
27
+ await mkdir(dirname(filePath), { recursive: true });
28
+ }
29
+
30
+ /**
31
+ * 대상 파일 쓰기 래퍼. diff 모드면 기존 파일과 비교해 diff만 출력하고 skip.
32
+ * @returns "new" | "unchanged" | "modified" | "previewed"
33
+ */
34
+ async function writeOrDiff({ dest, content, cwd, diffMode, summary, isBinary = false }) {
35
+ const rel = relative(cwd, dest);
36
+ const exists = existsSync(dest);
37
+
38
+ if (!exists) {
39
+ if (diffMode) {
40
+ summary.push({ kind: "new", rel });
41
+ return "previewed";
42
+ }
43
+ await ensureDir(dest);
44
+ await writeFile(dest, content, "utf8");
45
+ return "new";
46
+ }
47
+
48
+ if (isBinary) {
49
+ // 바이너리는 diff 의미가 없으므로 size만 비교
50
+ if (diffMode) {
51
+ summary.push({ kind: "binary", rel });
52
+ return "previewed";
53
+ }
54
+ await writeFile(dest, content, "utf8");
55
+ return "modified";
56
+ }
57
+
58
+ const existing = await readFile(dest, "utf8");
59
+ if (existing === content) {
60
+ if (diffMode) {
61
+ summary.push({ kind: "same", rel });
62
+ }
63
+ return "unchanged";
64
+ }
65
+
66
+ if (diffMode) {
67
+ const { text, addCount, delCount } = formatUnifiedDiff(existing, content, {
68
+ useColor: canUseColor(),
69
+ });
70
+ summary.push({ kind: "modified", rel, addCount, delCount, diff: text });
71
+ return "previewed";
72
+ }
73
+
74
+ await writeFile(dest, content, "utf8");
75
+ return "modified";
76
+ }
77
+
78
+ /** 특수 컴포넌트: 설정으로 토큰 파일 생성 */
79
+ async function addTokens(config, cwd, diffMode, summary) {
80
+ const destRel = config.paths?.tokens;
81
+ if (!destRel) throw new Error("paths.tokens 가 설정에 없습니다.");
82
+ const dest = resolve(cwd, destRel);
83
+
84
+ const content =
85
+ config.platform === "react"
86
+ ? await buildTokensCss(config)
87
+ : await buildTokensDart(config);
88
+
89
+ const result = await writeOrDiff({ dest, content, cwd, diffMode, summary });
90
+ if (!diffMode && result !== "unchanged") {
91
+ console.log(`✓ tokens → ${relative(cwd, dest)}`);
92
+ }
93
+ }
94
+
95
+ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary) {
96
+ const registryPath = resolve(
97
+ REPO_ROOT,
98
+ "packages/registry",
99
+ config.platform,
100
+ "registry.json",
101
+ );
102
+ const registry = JSON.parse(await readFile(registryPath, "utf8"));
103
+ const entry = registry.components?.[name];
104
+ if (!entry) {
105
+ throw new Error(
106
+ `'${name}' 컴포넌트를 ${config.platform} 레지스트리에서 찾을 수 없습니다.`,
107
+ );
108
+ }
109
+
110
+ for (const dep of entry.registryDependencies ?? []) {
111
+ await addOne(dep, config, cwd, installed, pendingDeps, diffMode, summary);
112
+ }
113
+
114
+ for (const file of entry.files) {
115
+ const src = resolve(REPO_ROOT, "packages/registry", config.platform, file.src);
116
+ const dest = resolve(cwd, resolveDest(file.dest, config));
117
+ const content = await readFile(src, "utf8");
118
+ const result = await writeOrDiff({ dest, content, cwd, diffMode, summary });
119
+ if (!diffMode && result !== "unchanged") {
120
+ console.log(`✓ ${name} → ${relative(cwd, dest)}`);
121
+ }
122
+ }
123
+
124
+ for (const dep of entry.dependencies ?? []) {
125
+ pendingDeps.add(dep);
126
+ }
127
+ }
128
+
129
+ /** lockfile 존재로 패키지 매니저 감지. 없으면 npm. */
130
+ function detectPackageManager(cwd) {
131
+ if (existsSync(resolve(cwd, "pnpm-lock.yaml"))) return "pnpm";
132
+ if (
133
+ existsSync(resolve(cwd, "bun.lockb")) ||
134
+ existsSync(resolve(cwd, "bun.lock"))
135
+ ) {
136
+ return "bun";
137
+ }
138
+ if (existsSync(resolve(cwd, "yarn.lock"))) return "yarn";
139
+ return "npm";
140
+ }
141
+
142
+ /** 이미 package.json에 있는 의존성은 제외. */
143
+ async function filterMissingDeps(deps, cwd) {
144
+ try {
145
+ const pkg = JSON.parse(
146
+ await readFile(resolve(cwd, "package.json"), "utf8"),
147
+ );
148
+ const have = {
149
+ ...(pkg.dependencies ?? {}),
150
+ ...(pkg.devDependencies ?? {}),
151
+ ...(pkg.peerDependencies ?? {}),
152
+ ...(pkg.optionalDependencies ?? {}),
153
+ };
154
+ return deps.filter((d) => !(d in have));
155
+ } catch {
156
+ return deps;
157
+ }
158
+ }
159
+
160
+ function runInstall(pm, deps, cwd) {
161
+ const addCmd = pm === "npm" ? "install" : "add";
162
+ const args = [addCmd, ...deps];
163
+ console.log(`\n외부 패키지 설치: ${pm} ${args.join(" ")}`);
164
+ // Windows는 .cmd/.bat 파일을 실행하려면 shell이 필요하지만,
165
+ // Unix에선 args 이스케이프 경고를 피하려고 shell을 끈다.
166
+ const isWin = process.platform === "win32";
167
+ return new Promise((ok, bad) => {
168
+ const child = spawn(pm, args, { cwd, stdio: "inherit", shell: isWin });
169
+ child.on("exit", (code) =>
170
+ code === 0 ? ok() : bad(new Error(`${pm} exited with code ${code}`)),
171
+ );
172
+ child.on("error", bad);
173
+ });
174
+ }
175
+
176
+ export async function add({ cwd, names, skipInstall = false, diffMode = false }) {
177
+ const configPath = resolve(cwd, "sh-ui.config.json");
178
+ let config;
179
+ try {
180
+ config = JSON.parse(await readFile(configPath, "utf8"));
181
+ } catch {
182
+ throw new Error(
183
+ "sh-ui.config.json을 찾을 수 없습니다. 먼저 `sh-ui init`을 실행하세요.",
184
+ );
185
+ }
186
+
187
+ const installed = new Set();
188
+ const pendingDeps = new Set();
189
+ const summary = [];
190
+ for (const name of names) {
191
+ await addOne(name, config, cwd, installed, pendingDeps, diffMode, summary);
192
+ }
193
+
194
+ if (diffMode) {
195
+ renderDiffReport(summary);
196
+ return;
197
+ }
198
+
199
+ if (pendingDeps.size === 0) return;
200
+
201
+ const deps = [...pendingDeps];
202
+ const missing = await filterMissingDeps(deps, cwd);
203
+
204
+ if (missing.length === 0) {
205
+ console.log(
206
+ `\n외부 패키지 모두 이미 설치됨: ${deps.join(", ")}`,
207
+ );
208
+ return;
209
+ }
210
+
211
+ if (skipInstall) {
212
+ const pm = detectPackageManager(cwd);
213
+ const addCmd = pm === "npm" ? "install" : "add";
214
+ console.log(
215
+ `\n ⚠ 외부 패키지 필요. 다음을 실행하세요:\n ${pm} ${addCmd} ${missing.join(" ")}`,
216
+ );
217
+ return;
218
+ }
219
+
220
+ const pm = detectPackageManager(cwd);
221
+ try {
222
+ await runInstall(pm, missing, cwd);
223
+ } catch (err) {
224
+ const addCmd = pm === "npm" ? "install" : "add";
225
+ console.error(
226
+ `\n✗ 자동 설치 실패 (${err.message}). 수동으로 실행하세요:\n ${pm} ${addCmd} ${missing.join(" ")}`,
227
+ );
228
+ throw err;
229
+ }
230
+ }
231
+
232
+ async function addOne(name, config, cwd, installed, pendingDeps, diffMode, summary) {
233
+ if (installed.has(name)) return;
234
+ installed.add(name);
235
+ if (name === "tokens") {
236
+ await addTokens(config, cwd, diffMode, summary);
237
+ } else {
238
+ await addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary);
239
+ }
240
+ }
241
+
242
+ function renderDiffReport(summary) {
243
+ const created = summary.filter((s) => s.kind === "new");
244
+ const modified = summary.filter((s) => s.kind === "modified");
245
+ const same = summary.filter((s) => s.kind === "same");
246
+ const binary = summary.filter((s) => s.kind === "binary");
247
+
248
+ console.log("\n── 변경 미리보기 (diff 모드) ──");
249
+
250
+ if (created.length) {
251
+ console.log(`\n신규 ${created.length}개:`);
252
+ for (const s of created) console.log(` + ${s.rel}`);
253
+ }
254
+
255
+ if (modified.length) {
256
+ console.log(`\n변경 ${modified.length}개:`);
257
+ for (const s of modified) {
258
+ console.log(`\n ~ ${s.rel} (+${s.addCount} -${s.delCount})`);
259
+ console.log(s.diff);
260
+ }
261
+ }
262
+
263
+ if (binary.length) {
264
+ console.log(`\n바이너리(비교 생략) ${binary.length}개:`);
265
+ for (const s of binary) console.log(` ~ ${s.rel}`);
266
+ }
267
+
268
+ if (same.length) {
269
+ console.log(`\n동일(변경 없음) ${same.length}개:`);
270
+ for (const s of same) console.log(` = ${s.rel}`);
271
+ }
272
+
273
+ if (!created.length && !modified.length) {
274
+ console.log("\n모든 파일이 최신 상태입니다.");
275
+ } else {
276
+ console.log(
277
+ "\n※ diff 모드 — 파일이 실제로 쓰이지 않았습니다. 적용하려면 --diff 없이 다시 실행하세요.",
278
+ );
279
+ }
280
+ }
package/src/diff.mjs ADDED
@@ -0,0 +1,116 @@
1
+ // 의존성 없는 라인 단위 unified-diff 생성기.
2
+ // 컴포넌트 파일(~1000줄 이하)에 충분한 성능의 LCS-DP.
3
+
4
+ /** LCS DP로 (=, -, +) 시퀀스를 만든다. */
5
+ function lcsOps(a, b) {
6
+ const m = a.length;
7
+ const n = b.length;
8
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
9
+ for (let i = 1; i <= m; i++) {
10
+ for (let j = 1; j <= n; j++) {
11
+ if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
12
+ else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
13
+ }
14
+ }
15
+ const ops = [];
16
+ let i = m;
17
+ let j = n;
18
+ while (i > 0 && j > 0) {
19
+ if (a[i - 1] === b[j - 1]) {
20
+ ops.unshift({ op: "=", line: a[i - 1] });
21
+ i--;
22
+ j--;
23
+ } else if (dp[i - 1][j] >= dp[i][j - 1]) {
24
+ ops.unshift({ op: "-", line: a[i - 1] });
25
+ i--;
26
+ } else {
27
+ ops.unshift({ op: "+", line: b[j - 1] });
28
+ j--;
29
+ }
30
+ }
31
+ while (i > 0) {
32
+ ops.unshift({ op: "-", line: a[i - 1] });
33
+ i--;
34
+ }
35
+ while (j > 0) {
36
+ ops.unshift({ op: "+", line: b[j - 1] });
37
+ j--;
38
+ }
39
+ return ops;
40
+ }
41
+
42
+ /** 터미널 컬러가 지원되면 ANSI 이스케이프, 아니면 평문. */
43
+ function paint(useColor) {
44
+ if (!useColor) {
45
+ return {
46
+ red: (s) => s,
47
+ green: (s) => s,
48
+ dim: (s) => s,
49
+ };
50
+ }
51
+ return {
52
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
53
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
54
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * 두 텍스트를 비교해 unified diff 문자열을 반환한다.
60
+ * context 줄은 `===`(동일 블록) 3개를 넘어가면 축약.
61
+ */
62
+ export function formatUnifiedDiff(oldText, newText, { useColor = false, context = 3 } = {}) {
63
+ const oldLines = oldText.split("\n");
64
+ const newLines = newText.split("\n");
65
+ const ops = lcsOps(oldLines, newLines);
66
+
67
+ const c = paint(useColor);
68
+ const out = [];
69
+ let addCount = 0;
70
+ let delCount = 0;
71
+
72
+ // 변경이 있는 구간만 context 라인과 함께 출력
73
+ const changeIdx = [];
74
+ ops.forEach((o, idx) => {
75
+ if (o.op !== "=") changeIdx.push(idx);
76
+ });
77
+ if (changeIdx.length === 0) {
78
+ return { text: "", addCount: 0, delCount: 0 };
79
+ }
80
+
81
+ // 인접한 변경들을 그룹으로 묶어 context 범위 표시
82
+ const groups = [];
83
+ let cur = { start: Math.max(0, changeIdx[0] - context), end: changeIdx[0] + context };
84
+ for (let k = 1; k < changeIdx.length; k++) {
85
+ const idx = changeIdx[k];
86
+ if (idx - context <= cur.end) {
87
+ cur.end = Math.max(cur.end, idx + context);
88
+ } else {
89
+ groups.push(cur);
90
+ cur = { start: idx - context, end: idx + context };
91
+ }
92
+ }
93
+ groups.push(cur);
94
+
95
+ for (const g of groups) {
96
+ out.push(c.dim(` @@ …`));
97
+ const from = Math.max(0, g.start);
98
+ const to = Math.min(ops.length - 1, g.end);
99
+ for (let idx = from; idx <= to; idx++) {
100
+ const o = ops[idx];
101
+ if (o.op === "=") out.push(` ${o.line}`);
102
+ else if (o.op === "-") {
103
+ out.push(c.red(`- ${o.line}`));
104
+ delCount++;
105
+ } else {
106
+ out.push(c.green(`+ ${o.line}`));
107
+ addCount++;
108
+ }
109
+ }
110
+ }
111
+
112
+ // groups를 만들 때 delCount/addCount가 context에 걸친 실제 + / - 라인만 세도록 보정:
113
+ // 위 반복에서 동일한 인덱스의 - / +만 센 것이므로 일치한다. (중복 카운트 우려 없음)
114
+
115
+ return { text: out.join("\n"), addCount, delCount };
116
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,156 @@
1
+ import { writeFile, access } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { stdin, stdout } from "node:process";
5
+
6
+ const CHOICES = {
7
+ platform: ["react", "flutter"],
8
+ base: ["neutral", "zinc", "slate"],
9
+ radius: ["none", "sm", "md", "lg", "xl", "full"],
10
+ mode: ["light-dark", "light", "dark"],
11
+ };
12
+
13
+ const DEFAULTS = {
14
+ platform: "react",
15
+ base: "neutral",
16
+ radius: "md",
17
+ mode: "light-dark",
18
+ };
19
+
20
+ const PATHS = {
21
+ react: {
22
+ tokens: "src/styles/tokens.css",
23
+ components: "src/components/ui",
24
+ utils: "src/lib/utils.ts",
25
+ },
26
+ flutter: {
27
+ tokens: "lib/sh_ui/foundation/sh_ui_tokens.dart",
28
+ components: "lib/sh_ui/widgets",
29
+ foundation: "lib/sh_ui/foundation",
30
+ widgets: "lib/sh_ui/widgets",
31
+ },
32
+ };
33
+
34
+ const ALIASES = {
35
+ react: {
36
+ components: "@/components",
37
+ utils: "@/lib/utils",
38
+ ui: "@/components/ui",
39
+ },
40
+ };
41
+
42
+ /** --key=value / --key value / -y / --yes / --force 파싱 */
43
+ function parseFlags(args) {
44
+ const flags = {};
45
+ for (let i = 0; i < args.length; i++) {
46
+ const a = args[i];
47
+ if (a === "-y" || a === "--yes") flags.yes = true;
48
+ else if (a === "--force") flags.force = true;
49
+ else if (a.startsWith("--")) {
50
+ const eq = a.indexOf("=");
51
+ if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
52
+ else flags[a.slice(2)] = args[++i];
53
+ }
54
+ }
55
+ return flags;
56
+ }
57
+
58
+ async function exists(p) {
59
+ try {
60
+ await access(p);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /** TTY 대화형 프롬프트. 잘못된 값이면 재질문. 빈 입력은 기본값. */
68
+ async function prompt(rl, label, choices, def) {
69
+ while (true) {
70
+ const raw = (await rl.question(`${label} [${choices.join("/")}] (${def}): `))
71
+ .trim()
72
+ .toLowerCase();
73
+ const value = raw || def;
74
+ if (choices.includes(value)) return value;
75
+ console.log(` ! '${raw}'는 허용되지 않습니다. 다시 입력하세요.`);
76
+ }
77
+ }
78
+
79
+ function validateOrThrow(key, value) {
80
+ if (!CHOICES[key].includes(value)) {
81
+ throw new Error(
82
+ `--${key}에 '${value}'는 허용되지 않습니다. 허용: ${CHOICES[key].join(", ")}`,
83
+ );
84
+ }
85
+ }
86
+
87
+ /** 플래그/TTY 상태에 따라 4개 축 값을 결정. 필요한 경우에만 프롬프트. */
88
+ async function resolveAnswers(flags) {
89
+ const answers = { ...DEFAULTS };
90
+ for (const key of Object.keys(CHOICES)) {
91
+ if (flags[key] != null) {
92
+ validateOrThrow(key, flags[key]);
93
+ answers[key] = flags[key];
94
+ }
95
+ }
96
+
97
+ const missingKeys = Object.keys(CHOICES).filter((k) => flags[k] == null);
98
+ if (flags.yes || missingKeys.length === 0) return answers;
99
+
100
+ if (!stdin.isTTY) {
101
+ throw new Error(
102
+ `비-TTY 환경에서는 프롬프트를 쓸 수 없습니다. --yes 또는 플래그로 값을 지정하세요.\n` +
103
+ `예: sh-ui init --platform react --base neutral --radius md --mode light-dark`,
104
+ );
105
+ }
106
+
107
+ const rl = createInterface({ input: stdin, output: stdout });
108
+ try {
109
+ console.log("sh-ui Design System 설정을 시작합니다. (Enter = 기본값)\n");
110
+ for (const key of missingKeys) {
111
+ answers[key] = await prompt(rl, labelFor(key), CHOICES[key], answers[key]);
112
+ }
113
+ } finally {
114
+ rl.close();
115
+ }
116
+ return answers;
117
+ }
118
+
119
+ function labelFor(key) {
120
+ return {
121
+ platform: "플랫폼",
122
+ base: "기본 색 스케일",
123
+ radius: "radius",
124
+ mode: "모드",
125
+ }[key];
126
+ }
127
+
128
+ function buildConfig({ platform, base, radius, mode }) {
129
+ return {
130
+ $schema: "https://your-ds.dev/sh-ui.schema.json",
131
+ platform,
132
+ style: "default",
133
+ theme: { base, radius, mode },
134
+ paths: PATHS[platform],
135
+ ...(ALIASES[platform] ? { aliases: ALIASES[platform] } : {}),
136
+ };
137
+ }
138
+
139
+ export async function init({ cwd, args }) {
140
+ const flags = parseFlags(args);
141
+ const configPath = resolve(cwd, "sh-ui.config.json");
142
+
143
+ if ((await exists(configPath)) && !flags.force) {
144
+ throw new Error("sh-ui.config.json이 이미 존재합니다. --force로 덮어쓰기 가능.");
145
+ }
146
+
147
+ const answers = await resolveAnswers(flags);
148
+ const config = buildConfig(answers);
149
+
150
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
151
+
152
+ console.log(`\n✓ sh-ui.config.json 생성 완료`);
153
+ console.log(` platform: ${answers.platform}`);
154
+ console.log(` theme: base=${answers.base}, radius=${answers.radius}, mode=${answers.mode}`);
155
+ console.log(`\n다음 단계: sh-ui add tokens button`);
156
+ }
package/src/list.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, resolve, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const REPO_ROOT = resolve(__dirname, "../../..");
8
+
9
+ function resolveDest(template, config) {
10
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (m, key) => {
11
+ const v = config.paths?.[key];
12
+ if (!v) throw new Error(`paths.${key} 가 sh-ui.config.json에 없습니다.`);
13
+ return v;
14
+ });
15
+ }
16
+
17
+ async function loadConfig(cwd) {
18
+ const configPath = resolve(cwd, "sh-ui.config.json");
19
+ try {
20
+ return JSON.parse(await readFile(configPath, "utf8"));
21
+ } catch {
22
+ throw new Error(
23
+ "sh-ui.config.json을 찾을 수 없습니다. 먼저 `sh-ui init`을 실행하세요.",
24
+ );
25
+ }
26
+ }
27
+
28
+ async function loadRegistry(platform) {
29
+ const registryPath = resolve(
30
+ REPO_ROOT,
31
+ "packages/registry",
32
+ platform,
33
+ "registry.json",
34
+ );
35
+ return JSON.parse(await readFile(registryPath, "utf8"));
36
+ }
37
+
38
+ /** 해당 컴포넌트의 파일 중 하나라도 존재하면 "설치됨"으로 간주. */
39
+ function isInstalled(entry, config, cwd) {
40
+ for (const file of entry.files) {
41
+ const dest = resolve(cwd, resolveDest(file.dest, config));
42
+ if (existsSync(dest)) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export async function list({ cwd, all = false }) {
48
+ const config = await loadConfig(cwd);
49
+ const registry = await loadRegistry(config.platform);
50
+ const entries = Object.entries(registry.components ?? {});
51
+
52
+ const rows = [];
53
+ for (const [name, entry] of entries) {
54
+ try {
55
+ rows.push({
56
+ name,
57
+ installed: isInstalled(entry, config, cwd),
58
+ files: entry.files.map((f) => resolveDest(f.dest, config)),
59
+ });
60
+ } catch {
61
+ // 이 config에서 해석 불가한 paths placeholder가 있는 엔트리는 스킵
62
+ // (예: base.css는 {styles} 경로를 쓰는데 기본 config엔 paths.styles 없음)
63
+ }
64
+ }
65
+
66
+ const installed = rows.filter((r) => r.installed);
67
+ const available = rows.filter((r) => !r.installed);
68
+
69
+ console.log(`플랫폼: ${config.platform}\n`);
70
+
71
+ if (installed.length === 0) {
72
+ console.log("설치된 컴포넌트 없음. `sh-ui add <name>`으로 시작하세요.");
73
+ } else {
74
+ console.log(`설치됨 (${installed.length}개):`);
75
+ for (const r of installed) {
76
+ console.log(` ✓ ${r.name}`);
77
+ for (const f of r.files) console.log(` ${relative(cwd, resolve(cwd, f))}`);
78
+ }
79
+ }
80
+
81
+ if (all) {
82
+ console.log(`\n설치 가능 (${available.length}개):`);
83
+ for (const r of available) console.log(` · ${r.name}`);
84
+ } else if (available.length > 0) {
85
+ console.log(
86
+ `\n설치 가능한 컴포넌트 ${available.length}개 더 있음. 전체 목록은 \`sh-ui list --all\`.`,
87
+ );
88
+ }
89
+ }
package/src/remove.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import { readFile, rm, rmdir, readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, resolve, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const REPO_ROOT = resolve(__dirname, "../../..");
8
+
9
+ function resolveDest(template, config) {
10
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (m, key) => {
11
+ const v = config.paths?.[key];
12
+ if (!v) throw new Error(`paths.${key} 가 sh-ui.config.json에 없습니다.`);
13
+ return v;
14
+ });
15
+ }
16
+
17
+ async function loadConfig(cwd) {
18
+ const configPath = resolve(cwd, "sh-ui.config.json");
19
+ try {
20
+ return JSON.parse(await readFile(configPath, "utf8"));
21
+ } catch {
22
+ throw new Error(
23
+ "sh-ui.config.json을 찾을 수 없습니다. 먼저 `sh-ui init`을 실행하세요.",
24
+ );
25
+ }
26
+ }
27
+
28
+ async function loadRegistry(platform) {
29
+ const registryPath = resolve(
30
+ REPO_ROOT,
31
+ "packages/registry",
32
+ platform,
33
+ "registry.json",
34
+ );
35
+ return JSON.parse(await readFile(registryPath, "utf8"));
36
+ }
37
+
38
+ /** 설치된 파일이 레지스트리 원본과 동일한지(= 사용자가 수정하지 않았는지). */
39
+ async function isUnmodified(srcAbs, destAbs) {
40
+ try {
41
+ const [src, dest] = await Promise.all([
42
+ readFile(srcAbs, "utf8"),
43
+ readFile(destAbs, "utf8"),
44
+ ]);
45
+ return src === dest;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** 디렉터리가 비어 있으면 지운다. 상위로 올라가며 반복. */
52
+ async function pruneEmptyDirs(startDir, stopAt) {
53
+ let dir = startDir;
54
+ while (dir.startsWith(stopAt) && dir !== stopAt) {
55
+ try {
56
+ const entries = await readdir(dir);
57
+ if (entries.length > 0) return;
58
+ await rmdir(dir);
59
+ } catch {
60
+ return;
61
+ }
62
+ dir = dirname(dir);
63
+ }
64
+ }
65
+
66
+ export async function remove({ cwd, names, force = false, dryRun = false }) {
67
+ const config = await loadConfig(cwd);
68
+ const registry = await loadRegistry(config.platform);
69
+
70
+ const plannedDeletes = [];
71
+ const modifiedBlocked = [];
72
+ const missingNames = [];
73
+
74
+ for (const name of names) {
75
+ const entry = registry.components?.[name];
76
+ if (!entry) {
77
+ missingNames.push(name);
78
+ continue;
79
+ }
80
+
81
+ for (const file of entry.files) {
82
+ const srcAbs = resolve(REPO_ROOT, "packages/registry", config.platform, file.src);
83
+ const destAbs = resolve(cwd, resolveDest(file.dest, config));
84
+
85
+ if (!existsSync(destAbs)) continue;
86
+
87
+ const unmodified = await isUnmodified(srcAbs, destAbs);
88
+ if (!unmodified && !force) {
89
+ modifiedBlocked.push({ name, dest: destAbs });
90
+ continue;
91
+ }
92
+ plannedDeletes.push({ name, dest: destAbs, unmodified });
93
+ }
94
+ }
95
+
96
+ if (missingNames.length > 0) {
97
+ for (const n of missingNames) {
98
+ console.error(`✗ '${n}' 은(는) ${config.platform} 레지스트리에 없습니다.`);
99
+ }
100
+ }
101
+
102
+ if (modifiedBlocked.length > 0) {
103
+ console.error("\n⚠ 사용자가 수정한 파일이 있어 삭제를 건너뜁니다:");
104
+ for (const f of modifiedBlocked) {
105
+ console.error(` ${relative(cwd, f.dest)} (${f.name})`);
106
+ }
107
+ console.error(
108
+ "\n --force 를 붙이면 수정된 파일도 삭제합니다. (원본 복구 불가)",
109
+ );
110
+ }
111
+
112
+ if (plannedDeletes.length === 0) {
113
+ if (modifiedBlocked.length > 0 || missingNames.length > 0) {
114
+ process.exit(1);
115
+ }
116
+ console.log("삭제할 파일이 없습니다.");
117
+ return;
118
+ }
119
+
120
+ if (dryRun) {
121
+ console.log("\n── 삭제 미리보기 (dry-run) ──");
122
+ for (const d of plannedDeletes) {
123
+ const tag = d.unmodified ? "" : " ⚠ 수정됨";
124
+ console.log(` - ${relative(cwd, d.dest)}${tag}`);
125
+ }
126
+ console.log("\n실제로 삭제하려면 --dry-run 없이 다시 실행.");
127
+ return;
128
+ }
129
+
130
+ const touchedDirs = new Set();
131
+ for (const d of plannedDeletes) {
132
+ await rm(d.dest);
133
+ console.log(`✓ 삭제: ${relative(cwd, d.dest)}`);
134
+ touchedDirs.add(dirname(d.dest));
135
+ }
136
+
137
+ // 컴포넌트 폴더가 비면 정리 (paths.components 상위까지)
138
+ const componentsRoot = config.paths?.components
139
+ ? resolve(cwd, config.paths.components)
140
+ : cwd;
141
+ for (const dir of touchedDirs) {
142
+ await pruneEmptyDirs(dir, componentsRoot);
143
+ }
144
+
145
+ if (missingNames.length > 0 || modifiedBlocked.length > 0) process.exit(1);
146
+ }