sh-ui-cli 0.65.0 → 0.67.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/bin/sh-ui.mjs +47 -2
- package/data/changelog/versions.json +28 -0
- package/package.json +1 -1
- package/src/create/cli-args.js +1 -1
- package/src/create/generator.js +183 -91
- package/src/create/index.mjs +13 -3
- package/src/mcp.mjs +73 -1
- package/src/migrate-v065.mjs +431 -0
- package/src/resolve-context.mjs +56 -0
package/bin/sh-ui.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { init } from "../src/init.mjs";
|
|
|
3
3
|
import { add } from "../src/add.mjs";
|
|
4
4
|
import { list } from "../src/list.mjs";
|
|
5
5
|
import { remove } from "../src/remove.mjs";
|
|
6
|
+
import { findShUiContext } from "../src/resolve-context.mjs";
|
|
6
7
|
|
|
7
8
|
const [, , cmd, ...rest] = process.argv;
|
|
8
9
|
|
|
@@ -18,6 +19,8 @@ const usage = `사용법:
|
|
|
18
19
|
sh-ui rename-app <old> <new> monorepo 의 앱 이름 일괄 변경
|
|
19
20
|
(apps/<old>/, packages/ui/ui-apps/ui-<old>/
|
|
20
21
|
디렉토리 + 모든 import/path 패턴)
|
|
22
|
+
sh-ui migrate-v065 v0.64.x → v0.65 자동 마이그레이션
|
|
23
|
+
(--dry-run 기본, --apply 로 실제 적용)
|
|
21
24
|
sh-ui mcp MCP 서버(stdio) 시작 — IDE-내 AI용
|
|
22
25
|
sh-ui mcp init --client <name> IDE MCP 설정 파일에 sh-ui 엔트리 자동 추가
|
|
23
26
|
(claude-code | cursor | claude-desktop)
|
|
@@ -27,6 +30,8 @@ const usage = `사용법:
|
|
|
27
30
|
--force (add) 기존 파일을 모두 덮어쓰기 (prompt 없음)
|
|
28
31
|
(remove) 사용자가 수정한 파일도 삭제
|
|
29
32
|
--keep (add) 기존 파일을 모두 유지 (prompt 없음)
|
|
33
|
+
--app <name> (add) monorepo 라우팅 시 대상 ui-{name} 명시
|
|
34
|
+
(apps/<name>/ 안에서 실행하면 자동 추론)
|
|
30
35
|
--all (list) 설치되지 않은 컴포넌트까지 표시
|
|
31
36
|
--dry-run (remove, rename-app) 변경 대상만 출력하고 실행 안 함
|
|
32
37
|
--yes (rename-app) 대화형 확인 생략
|
|
@@ -52,13 +57,41 @@ try {
|
|
|
52
57
|
process.exit(1);
|
|
53
58
|
}
|
|
54
59
|
const onConflict = force ? "overwrite" : keepFlag ? "keep" : "prompt";
|
|
55
|
-
|
|
60
|
+
// --app 은 모노레포 라우팅 시 ui-{app} 명시 (tokens 또는 v0.64.x fallback).
|
|
61
|
+
const appIdx = rest.indexOf("--app");
|
|
62
|
+
const app = appIdx !== -1 ? rest[appIdx + 1] : null;
|
|
63
|
+
const names = rest.filter((a, i) => !a.startsWith("--") && rest[i - 1] !== "--app");
|
|
56
64
|
if (names.length === 0) {
|
|
57
65
|
console.error("에러: 추가할 컴포넌트 이름이 필요합니다.\n");
|
|
58
66
|
console.error(usage);
|
|
59
67
|
process.exit(1);
|
|
60
68
|
}
|
|
61
|
-
|
|
69
|
+
|
|
70
|
+
// v0.67+: cwd 부터 walk-up 으로 sh-ui.config.json (standalone/ui-core/ui-app)
|
|
71
|
+
// 또는 pnpm-workspace.yaml (monorepo 루트) 발견. monorepo 면 자동 라우팅 (tokens →
|
|
72
|
+
// ui-app, 그 외 → ui-core), apps/<name>/ 안에서 실행하면 그 앱이 hintApp.
|
|
73
|
+
const ctx = findShUiContext(process.cwd());
|
|
74
|
+
if (!ctx) {
|
|
75
|
+
console.error(
|
|
76
|
+
"✗ sh-ui.config.json 또는 pnpm-workspace.yaml 을 cwd 부터 부모 트리에서 찾지 못했습니다.\n" +
|
|
77
|
+
" 먼저 `sh-ui init` 또는 `sh-ui create` 로 프로젝트를 초기화하세요.",
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
if (ctx.kind === "config") {
|
|
82
|
+
await add({ cwd: ctx.root, names, skipInstall, diffMode, onConflict });
|
|
83
|
+
} else {
|
|
84
|
+
const { addComponent } = await import("../src/create/generator.js");
|
|
85
|
+
await addComponent({
|
|
86
|
+
cwd: ctx.root,
|
|
87
|
+
names,
|
|
88
|
+
app,
|
|
89
|
+
hintApp: ctx.hintApp,
|
|
90
|
+
skipInstall,
|
|
91
|
+
diffMode,
|
|
92
|
+
onConflict,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
62
95
|
break;
|
|
63
96
|
}
|
|
64
97
|
case "list": {
|
|
@@ -93,6 +126,18 @@ try {
|
|
|
93
126
|
await renameApp({ cwd: process.cwd(), oldName, newName, yes, dryRun, skipInstall });
|
|
94
127
|
break;
|
|
95
128
|
}
|
|
129
|
+
case "migrate-v065": {
|
|
130
|
+
const apply = rest.includes("--apply");
|
|
131
|
+
const skipImportRewrite = rest.includes("--skip-import-rewrite");
|
|
132
|
+
const { migrateToV065 } = await import("../src/migrate-v065.mjs");
|
|
133
|
+
const { summary } = await migrateToV065({
|
|
134
|
+
cwd: process.cwd(),
|
|
135
|
+
dryRun: !apply,
|
|
136
|
+
skipImportRewrite,
|
|
137
|
+
});
|
|
138
|
+
console.log(summary);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
96
141
|
case "remove":
|
|
97
142
|
case "rm": {
|
|
98
143
|
const force = rest.includes("--force");
|
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.67.0",
|
|
7
|
+
"date": "2026-05-09",
|
|
8
|
+
"title": "monorepo 어디서든 sh-ui add — walk-up 컨텍스트 라우팅",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`sh-ui add` 가 monorepo 전 디렉토리에서 동작** — `apps/web/`, monorepo 루트, `packages/ui/ui-core/`, `packages/ui/ui-apps/ui-{name}/` 어디서든. cwd 부터 부모 트리 walk-up 으로 `sh-ui.config.json` (standalone) 또는 `pnpm-workspace.yaml` (monorepo 루트) 자동 발견.",
|
|
12
|
+
"**hintApp 자동 추출** — `apps/web/`에서 `sh-ui add tokens` 실행 시 자동으로 ui-web 으로 라우팅 (별도 `--app` 불필요). `apps/admin/` 에선 ui-admin. monorepo 루트면 prompt (TTY) 또는 모든 앱 (비대화형 + tokens).",
|
|
13
|
+
"**npx subprocess 제거** — orchestrator 가 inner `npx sh-ui add` 대신 `add()` 함수를 in-process 호출. sh-ui-cli 가 모노레포에 local 설치 안 돼도 동작.",
|
|
14
|
+
"**다중 컴포넌트 + tokens 혼합** — `sh-ui add button card tokens` 한 줄로 ui-core 와 ui-app 양쪽에 라우팅 (이름별 그룹화).",
|
|
15
|
+
"**`--app <name>` 플래그 신규 (sh-ui add)** — hintApp 자동 추론을 명시적으로 덮어쓸 때.",
|
|
16
|
+
"**호환** — `sh-ui create add-component <name>` 는 alias 로 유지. v0.65/v0.66 의 ui-core SoT 라우팅 그대로."
|
|
17
|
+
],
|
|
18
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.67.0"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.66.0",
|
|
22
|
+
"date": "2026-05-09",
|
|
23
|
+
"title": "monorepo: sh_ui_add_app MCP 툴 + sh_ui_migrate_to_v065 자동 마이그레이션",
|
|
24
|
+
"type": "minor",
|
|
25
|
+
"highlights": [
|
|
26
|
+
"**`sh_ui_add_app` MCP 툴 신규** — 기존 모노레포에 새 Next.js 앱 추가 (`apps/{name}/` + `packages/ui/ui-apps/ui-{name}/` 동시 생성). v0.65 ui-core/ui-app 분리 레이아웃 자동 준수, theme/css 인자로 새 앱 별 톤/프레임워크 지정 가능. CLI 진입점 `sh-ui create add-app [name] [--port/--plugins/--theme/--css]` 도 동시 확장.",
|
|
27
|
+
"**`sh_ui_migrate_to_v065` 자동 마이그레이션** — v0.64.x → v0.65 monorepo 컴포넌트 통합 도구. ui-{app}/src/{components,hooks,lib}/ 의 파일을 ui-core 단일 SoT 로 이동, ui-app 에 `role: \"tokens-only\"` 마커 + paths/aliases/exports 정리, `apps/*` 의 `@workspace/ui-{app}/(components|hooks|lib)/` 임포트를 `@workspace/ui-core/...` 로 일괄 재작성. dryRun 기본, 컨텐츠 충돌 시 abort (자동 병합 안 함).",
|
|
28
|
+
"**`apps/docs/migrations/v0.65.md`** — 자동 도구 사용법 + 충돌 해결 가이드 + 수동 마이그레이션 6 단계 + 호환성 노트.",
|
|
29
|
+
"**addApp 옵션 객체 시그니처** — `addApp({ name, port, plugins, theme, css, cwd })`. 비대화형 (MCP daemon, CI) 지원 + 새 ui-app 의 tokens.css 에 theme 주입까지 createProject 흐름과 통일."
|
|
30
|
+
],
|
|
31
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.66.0"
|
|
32
|
+
},
|
|
5
33
|
{
|
|
6
34
|
"version": "0.65.0",
|
|
7
35
|
"date": "2026-05-09",
|
package/package.json
CHANGED
package/src/create/cli-args.js
CHANGED
|
@@ -12,7 +12,7 @@ const VALID_STRUCTURES = CREATE_STRUCTURES;
|
|
|
12
12
|
const VALID_PLUGINS = allPlugins.map((p) => p.name);
|
|
13
13
|
const VALID_ARCHES = allArchitectures.map((a) => a.name);
|
|
14
14
|
|
|
15
|
-
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch'];
|
|
15
|
+
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port'];
|
|
16
16
|
const BOOL_FLAGS = ['yes', 'help', 'dry-run'];
|
|
17
17
|
|
|
18
18
|
const SUBCOMMANDS = ['add-app', 'add-component'];
|
package/src/create/generator.js
CHANGED
|
@@ -337,47 +337,84 @@ async function listAllFiles(targetDir) {
|
|
|
337
337
|
|
|
338
338
|
// ─── Add app to existing monorepo ───
|
|
339
339
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
340
|
+
/**
|
|
341
|
+
* 기존 monorepo 에 새 Next.js 앱 추가 — apps/{name}/ + packages/ui/ui-apps/ui-{name}/ 동시 생성.
|
|
342
|
+
*
|
|
343
|
+
* 옵션 객체 시그니처 (v0.66+) — MCP `sh_ui_add_app` 와 CLI `sh-ui add-app` 모두 같은 진입점.
|
|
344
|
+
* 미지정 옵션은 TTY 면 prompt, 비대화형(MCP/CI)이면 기본값 또는 에러.
|
|
345
|
+
*
|
|
346
|
+
* 산출물: apps/{name}/ (Next.js + arch overlay) + packages/ui/ui-apps/ui-{name}/ (tokens-only,
|
|
347
|
+
* v0.65 layout) — theme 인자 주면 ui-app 의 tokens.css 에 주입, css 인자는 컴포넌트 변종 결정.
|
|
348
|
+
*/
|
|
349
|
+
export async function addApp(options = {}) {
|
|
350
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
351
|
+
const isMonorepo = await fs.pathExists(path.resolve(cwd, 'pnpm-workspace.yaml'));
|
|
344
352
|
if (!isMonorepo) {
|
|
345
|
-
|
|
353
|
+
const msg = '현재 디렉토리가 모노레포가 아닙니다. pnpm-workspace.yaml 이 없습니다.';
|
|
354
|
+
if (!process.stdin.isTTY) throw new Error(msg);
|
|
355
|
+
console.log(`❌ ${msg}`);
|
|
346
356
|
return;
|
|
347
357
|
}
|
|
348
358
|
|
|
349
|
-
|
|
359
|
+
// 비대화형 (MCP daemon, CI) 가드 — name 만 필수. port/plugins 는 기본값.
|
|
360
|
+
if (!process.stdin.isTTY && !options.name) {
|
|
361
|
+
throw new Error('비대화형 환경(TTY 없음)에서는 name 이 필요합니다.');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const appName = options.name ?? await input({
|
|
350
365
|
message: '앱 이름:',
|
|
351
366
|
default: 'web',
|
|
352
367
|
});
|
|
353
368
|
|
|
354
|
-
const port =
|
|
355
|
-
message: '포트 번호:',
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
const port = options.port ?? (process.stdin.isTTY
|
|
370
|
+
? await input({ message: '포트 번호:', default: '3000' })
|
|
371
|
+
: '3000');
|
|
372
|
+
|
|
373
|
+
let plugins;
|
|
374
|
+
if (options.plugins) {
|
|
375
|
+
plugins = getPluginsByNames(options.plugins);
|
|
376
|
+
} else if (process.stdin.isTTY) {
|
|
377
|
+
const selected = await checkbox({
|
|
378
|
+
message: '추가 기능 선택 (Space로 선택):',
|
|
379
|
+
choices: getPluginChoices(),
|
|
380
|
+
});
|
|
381
|
+
plugins = getPluginsByNames(selected);
|
|
382
|
+
} else {
|
|
383
|
+
plugins = [];
|
|
384
|
+
}
|
|
365
385
|
plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
366
386
|
|
|
367
|
-
|
|
387
|
+
// theme/css resolve — createProject 와 같은 패턴.
|
|
388
|
+
let theme = null;
|
|
389
|
+
let themeBase = null;
|
|
390
|
+
if (options.theme) {
|
|
391
|
+
theme = resolveTheme(options.theme);
|
|
392
|
+
themeBase = THEME_PRESET_NAMES.includes(options.theme) ? options.theme : 'custom';
|
|
393
|
+
}
|
|
394
|
+
const css = options.css ?? CSS_FRAMEWORK_DEFAULT;
|
|
368
395
|
|
|
396
|
+
const appsDir = path.resolve(cwd, 'apps', appName);
|
|
369
397
|
if (await fs.pathExists(appsDir)) {
|
|
370
|
-
|
|
398
|
+
const msg = `apps/${appName} 디렉토리가 이미 존재합니다.`;
|
|
399
|
+
if (!process.stdin.isTTY) throw new Error(msg);
|
|
400
|
+
console.log(`❌ ${msg}`);
|
|
371
401
|
return;
|
|
372
402
|
}
|
|
373
403
|
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
// 여기서 읽어오는 흐름으로 개선 가능.
|
|
404
|
+
// arch 는 모노레포가 처음 만들어질 때 정한 값과 같아야 한다. root config 에 별도
|
|
405
|
+
// 저장 안 해 두므로 일단 DEFAULT_ARCH (fsd) fallback. 향후 root config 에 arch
|
|
406
|
+
// 박아두고 여기서 읽어오는 흐름으로 개선 가능.
|
|
378
407
|
const arch = assertArchPlatformCompat(DEFAULT_ARCH, 'next');
|
|
379
408
|
|
|
380
|
-
await generateApp(appsDir, appName, port, plugins, arch);
|
|
409
|
+
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
410
|
+
|
|
411
|
+
// monorepo 의 새 ui-app 패키지 — tokens-only role + theme 주입 + cssFramework 패치.
|
|
412
|
+
// generateMonorepo 의 흐름과 정확히 동일.
|
|
413
|
+
const uiAppDir = path.resolve(cwd, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
|
|
414
|
+
if (theme) {
|
|
415
|
+
await injectCssTheme(uiAppDir, theme);
|
|
416
|
+
}
|
|
417
|
+
await patchShUiConfig(path.join(uiAppDir, 'sh-ui.config.json'), css, themeBase);
|
|
381
418
|
|
|
382
419
|
console.log(`\n✅ apps/${appName} 이 추가되었습니다!`);
|
|
383
420
|
console.log('\n pnpm install');
|
|
@@ -386,108 +423,163 @@ export async function addApp() {
|
|
|
386
423
|
|
|
387
424
|
// ─── Add component to ui packages ───
|
|
388
425
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
426
|
+
/**
|
|
427
|
+
* 컴포넌트/토큰 추가 오케스트레이터 — monorepo 의 어느 디렉토리에서든 의도대로 라우팅.
|
|
428
|
+
*
|
|
429
|
+
* **v0.67+ 시그니처**: 옵션 객체 받음. `bin/sh-ui.mjs` 가 walk-up 으로 monorepo 루트를 찾아
|
|
430
|
+
* 호출하고, `add()` 를 in-process 로 호출 (npx subprocess 없음 — 외부 sh-ui-cli 설치 의존 X).
|
|
431
|
+
*
|
|
432
|
+
* 라우팅 (monorepo, v0.65+):
|
|
433
|
+
* - `tokens` → `packages/ui/ui-apps/ui-{hintApp 또는 app 또는 prompt 또는 단일}`
|
|
434
|
+
* - 그 외(컴포넌트/훅/lib) → `packages/ui/ui-core` 단일 SoT
|
|
435
|
+
*
|
|
436
|
+
* v0.64.x 호환: ui-core/sh-ui.config.json 부재 시 모든 add 는 ui-apps/ui-{app} 으로 폴백.
|
|
437
|
+
*
|
|
438
|
+
* @param {Object} options
|
|
439
|
+
* @param {string} [options.cwd] monorepo 루트. 기본 process.cwd()
|
|
440
|
+
* @param {string[]} options.names 추가할 이름들 (예: ['button', 'tokens'])
|
|
441
|
+
* @param {string|null} [options.app] 명시적 --app 플래그 (있으면 hintApp 무시)
|
|
442
|
+
* @param {string|null} [options.hintApp] walk-up 결과의 힌트 (apps/web → 'web')
|
|
443
|
+
* @param {boolean} [options.skipInstall]
|
|
444
|
+
* @param {boolean} [options.diffMode]
|
|
445
|
+
* @param {string} [options.onConflict] 'prompt' | 'keep' | 'overwrite'
|
|
446
|
+
*/
|
|
447
|
+
export async function addComponent(options = {}) {
|
|
448
|
+
const { add } = await import('../add.mjs');
|
|
449
|
+
|
|
450
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
451
|
+
const names = (options.names ?? []).filter(Boolean);
|
|
452
|
+
const app = options.app ?? null;
|
|
453
|
+
const hintApp = options.hintApp ?? null;
|
|
454
|
+
const skipInstall = options.skipInstall === true;
|
|
455
|
+
const diffMode = options.diffMode === true;
|
|
456
|
+
const onConflict = options.onConflict ?? 'prompt';
|
|
457
|
+
|
|
458
|
+
if (names.length === 0) {
|
|
459
|
+
if (!process.stdin.isTTY) {
|
|
460
|
+
throw new Error('비대화형 환경(TTY 없음)에서는 추가할 이름이 필요합니다.');
|
|
461
|
+
}
|
|
462
|
+
const single = await input({ message: '컴포넌트 이름:' });
|
|
463
|
+
names.push(single);
|
|
464
|
+
}
|
|
392
465
|
|
|
466
|
+
const isMonorepo = await fs.pathExists(path.join(cwd, 'pnpm-workspace.yaml'));
|
|
393
467
|
if (!isMonorepo) {
|
|
394
|
-
// Standalone:
|
|
395
|
-
|
|
396
|
-
componentName = await input({ message: '컴포넌트 이름:' });
|
|
397
|
-
}
|
|
398
|
-
console.log(`\n📦 sh-ui 컴포넌트 추가: ${componentName}`);
|
|
399
|
-
execSync(`npx sh-ui add ${componentName}`, { cwd, stdio: 'inherit' });
|
|
400
|
-
console.log(`\n✅ ${componentName} 추가 완료!`);
|
|
468
|
+
// Standalone: cwd 자체가 sh-ui.config.json 보유 — 그대로 add 호출.
|
|
469
|
+
await add({ cwd, names, skipInstall, diffMode, onConflict });
|
|
401
470
|
return;
|
|
402
471
|
}
|
|
403
472
|
|
|
404
|
-
// Monorepo (v0.65+): tokens 는 packages/ui/ui-apps/ui-{app} 으로, 그 외 컴포넌트/훅은
|
|
405
|
-
// packages/ui/ui-core 단일 SoT 로 라우팅. 컴포넌트 중복 emit 제거가 v0.65 의 핵심.
|
|
406
|
-
// v0.64.x 호환: ui-core/sh-ui.config.json 미존재 시 (구 모노레포 레이아웃) 기존 ui-apps
|
|
407
|
-
// 직접 라우팅 분기로 내려감.
|
|
408
473
|
const uiCoreDir = path.join(cwd, 'packages', 'ui', 'ui-core');
|
|
409
474
|
const uiAppsDir = path.join(cwd, 'packages', 'ui', 'ui-apps');
|
|
410
475
|
const hasUiCore = await fs.pathExists(path.join(uiCoreDir, 'sh-ui.config.json'));
|
|
411
476
|
const hasUiApps = await fs.pathExists(uiAppsDir);
|
|
412
477
|
|
|
413
478
|
if (!hasUiCore && !hasUiApps) {
|
|
414
|
-
|
|
415
|
-
return;
|
|
479
|
+
throw new Error('packages/ui/ui-core 또는 packages/ui/ui-apps/ 디렉토리가 없습니다.');
|
|
416
480
|
}
|
|
417
481
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
482
|
+
// 이름을 라우팅 그룹별로 분리: tokens → ui-app, 그 외 → ui-core.
|
|
483
|
+
const tokenNames = names.filter((n) => n === 'tokens');
|
|
484
|
+
const componentNames = names.filter((n) => n !== 'tokens');
|
|
421
485
|
|
|
422
|
-
|
|
486
|
+
// ─── 비-tokens → ui-core (또는 v0.64.x fallback 시 ui-apps) ───
|
|
487
|
+
if (componentNames.length > 0) {
|
|
488
|
+
if (hasUiCore) {
|
|
489
|
+
if (app) {
|
|
490
|
+
console.log(
|
|
491
|
+
`ℹ️ v0.65+ 에서 컴포넌트는 packages/ui/ui-core 에 단일 emit (--app ${app} 무시).`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
console.log(`\n📦 packages/ui/ui-core ← ${componentNames.join(', ')}`);
|
|
495
|
+
await add({ cwd: uiCoreDir, names: componentNames, skipInstall, diffMode, onConflict });
|
|
496
|
+
} else {
|
|
497
|
+
// v0.64.x 호환: ui-core 없으니 모든 ui-app 에 컴포넌트 직접 emit (구 동작).
|
|
498
|
+
const targets = await pickUiAppTargets({ uiAppsDir, app, hintApp, kind: 'component' });
|
|
499
|
+
for (const pkg of targets) {
|
|
500
|
+
const pkgDir = path.join(uiAppsDir, pkg);
|
|
501
|
+
console.log(`\n📦 packages/ui/ui-apps/${pkg} ← ${componentNames.join(', ')}`);
|
|
502
|
+
await add({ cwd: pkgDir, names: componentNames, skipInstall, diffMode, onConflict });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
423
506
|
|
|
424
|
-
// ───
|
|
425
|
-
if (
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
`ℹ️ v0.65+ 에서 컴포넌트는 packages/ui/ui-core 에 단일 emit 됩니다 (--app ${appName} 무시).`,
|
|
429
|
-
);
|
|
507
|
+
// ─── tokens → ui-app(s) ───
|
|
508
|
+
if (tokenNames.length > 0) {
|
|
509
|
+
if (!hasUiApps) {
|
|
510
|
+
throw new Error('tokens 추가에는 packages/ui/ui-apps/ 가 필요합니다.');
|
|
430
511
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
console.log(
|
|
435
|
-
|
|
436
|
-
console.log(`❌ packages/ui/ui-core 실패: ${error.message}`);
|
|
512
|
+
const targets = await pickUiAppTargets({ uiAppsDir, app, hintApp, kind: 'tokens' });
|
|
513
|
+
for (const pkg of targets) {
|
|
514
|
+
const pkgDir = path.join(uiAppsDir, pkg);
|
|
515
|
+
console.log(`\n📦 packages/ui/ui-apps/${pkg} ← tokens`);
|
|
516
|
+
await add({ cwd: pkgDir, names: tokenNames, skipInstall, diffMode, onConflict });
|
|
437
517
|
}
|
|
438
|
-
return;
|
|
439
518
|
}
|
|
440
519
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
console.log('❌ packages/ui/ui-apps/ 디렉토리가 없습니다.');
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
520
|
+
console.log('\n✅ 추가 완료');
|
|
521
|
+
}
|
|
446
522
|
|
|
523
|
+
/**
|
|
524
|
+
* monorepo 의 ui-app 패키지 후보를 정해 1+ 개 반환.
|
|
525
|
+
*
|
|
526
|
+
* 우선순위:
|
|
527
|
+
* 1. 명시적 `--app` 플래그
|
|
528
|
+
* 2. walk-up 결과의 hintApp (apps/<name>/ 또는 ui-apps/ui-<name>/ 컨텍스트)
|
|
529
|
+
* 3. 단일 ui-app 만 존재 → 자동 선택
|
|
530
|
+
* 4. TTY → 사용자에게 select prompt
|
|
531
|
+
* 5. 비대화형 + 다중 ui-app → tokens 는 'all', 컴포넌트(v0.64.x fallback)는 throw
|
|
532
|
+
*/
|
|
533
|
+
async function pickUiAppTargets({ uiAppsDir, app, hintApp, kind }) {
|
|
447
534
|
const entries = await fs.readdir(uiAppsDir, { withFileTypes: true });
|
|
448
535
|
const uiPackages = entries
|
|
449
536
|
.filter((e) => e.isDirectory() && e.name.startsWith('ui-') && e.name !== 'ui-app-template')
|
|
450
537
|
.map((e) => e.name);
|
|
451
538
|
|
|
452
539
|
if (uiPackages.length === 0) {
|
|
453
|
-
|
|
454
|
-
return;
|
|
540
|
+
throw new Error('packages/ui/ui-apps/ui-* 패키지가 없습니다.');
|
|
455
541
|
}
|
|
456
542
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const pkgName = `ui-${appName}`;
|
|
543
|
+
if (app) {
|
|
544
|
+
const pkgName = `ui-${app}`;
|
|
460
545
|
if (!uiPackages.includes(pkgName)) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
546
|
+
throw new Error(
|
|
547
|
+
`packages/ui/ui-apps/${pkgName} 이 존재하지 않습니다. 사용 가능: ${uiPackages.join(', ')}`,
|
|
548
|
+
);
|
|
464
549
|
}
|
|
465
|
-
|
|
466
|
-
} else if (uiPackages.length === 1) {
|
|
467
|
-
targets = uiPackages;
|
|
468
|
-
} else {
|
|
469
|
-
const choice = await select({
|
|
470
|
-
message: isTokens ? '어느 ui 패키지에 토큰을 추가할까요?' : '어디에 추가할까요?',
|
|
471
|
-
choices: [
|
|
472
|
-
{ name: '모든 ui 패키지', value: 'all' },
|
|
473
|
-
...uiPackages.map((name) => ({ name: `packages/ui/ui-apps/${name}`, value: name })),
|
|
474
|
-
],
|
|
475
|
-
});
|
|
476
|
-
targets = choice === 'all' ? uiPackages : [choice];
|
|
550
|
+
return [pkgName];
|
|
477
551
|
}
|
|
478
552
|
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
553
|
+
if (hintApp) {
|
|
554
|
+
const pkgName = `ui-${hintApp}`;
|
|
555
|
+
if (uiPackages.includes(pkgName)) {
|
|
556
|
+
return [pkgName];
|
|
557
|
+
}
|
|
558
|
+
// hintApp 이 가리키는 앱의 ui-패키지가 없으면 일반 흐름으로 떨어짐 (드문 케이스).
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (uiPackages.length === 1) {
|
|
562
|
+
return uiPackages;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (!process.stdin.isTTY) {
|
|
566
|
+
if (kind === 'tokens') {
|
|
567
|
+
// 비대화형 + 다중 ui-app + tokens: 모든 앱에 적용 (가장 안전한 기본값).
|
|
568
|
+
return uiPackages;
|
|
487
569
|
}
|
|
570
|
+
throw new Error(
|
|
571
|
+
`여러 ui-app 후보 (${uiPackages.join(', ')}) — 비대화형 환경에서는 --app 으로 명시하세요.`,
|
|
572
|
+
);
|
|
488
573
|
}
|
|
489
574
|
|
|
490
|
-
|
|
575
|
+
const choice = await select({
|
|
576
|
+
message: kind === 'tokens' ? '어느 ui 패키지에 토큰을 추가할까요?' : '어디에 추가할까요?',
|
|
577
|
+
choices: [
|
|
578
|
+
{ name: '모든 ui 패키지', value: 'all' },
|
|
579
|
+
...uiPackages.map((name) => ({ name: `packages/ui/ui-apps/${name}`, value: name })),
|
|
580
|
+
],
|
|
581
|
+
});
|
|
582
|
+
return choice === 'all' ? uiPackages : [choice];
|
|
491
583
|
}
|
|
492
584
|
|
|
493
585
|
// ─── Generators ───
|
package/src/create/index.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
18
18
|
|
|
19
19
|
사용법:
|
|
20
20
|
sh-ui create [name] [options]
|
|
21
|
-
sh-ui create add-app
|
|
21
|
+
sh-ui create add-app [name] [--port <n>] [--plugins ..] [--theme ..] [--css ..]
|
|
22
22
|
sh-ui create add-component <name> [--app <name>]
|
|
23
23
|
|
|
24
24
|
옵션:
|
|
@@ -67,9 +67,19 @@ export async function runCreate(rest) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
if (command === 'add-app') {
|
|
70
|
-
await addApp(
|
|
70
|
+
await addApp({
|
|
71
|
+
name: positional[0],
|
|
72
|
+
port: flags.port,
|
|
73
|
+
plugins: flags.plugins,
|
|
74
|
+
theme: flags.theme,
|
|
75
|
+
css: flags.css,
|
|
76
|
+
});
|
|
71
77
|
} else if (command === 'add-component') {
|
|
72
|
-
|
|
78
|
+
// 호환 별칭 — 신규 진입점은 `sh-ui add <name>` (bin/sh-ui.mjs 가 walk-up 으로 라우팅).
|
|
79
|
+
await addComponent({
|
|
80
|
+
names: positional.filter(Boolean),
|
|
81
|
+
app: flags.app ?? null,
|
|
82
|
+
});
|
|
73
83
|
} else {
|
|
74
84
|
await createProject({
|
|
75
85
|
name: positional[0],
|
package/src/mcp.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// 노출 툴:
|
|
8
8
|
// sh_ui_describe_init - init 4개 축(platform/base/radius/mode) enum + 한글 설명
|
|
9
9
|
// sh_ui_create_project - 빈 폴더에 Next.js/Flutter 프로젝트 스캐폴드
|
|
10
|
+
// sh_ui_add_app - 기존 모노레포에 새 Next.js 앱 추가
|
|
10
11
|
// sh_ui_init - sh-ui.config.json 생성 (비대화형)
|
|
11
12
|
// sh_ui_list_components - 플랫폼 전체 컴포넌트 + 요약
|
|
12
13
|
// sh_ui_get_component - 단일 컴포넌트의 메타·소스·deps
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
// sh_ui_encode_theme - 토큰 객체 → base64 (사용자가 손본 톤을 영구 보관)
|
|
17
18
|
// sh_ui_decode_theme - base64 → 토큰 객체 (기존 테마 일부만 수정 후 재인코딩)
|
|
18
19
|
// sh_ui_rename_app - monorepo 의 앱 이름 일괄 변경 (디렉토리 + import/path)
|
|
20
|
+
// sh_ui_migrate_to_v065 - v0.64.x → v0.65 자동 마이그레이션 (컴포넌트 ui-core 단일화)
|
|
19
21
|
|
|
20
22
|
import { readFile } from "node:fs/promises";
|
|
21
23
|
import { existsSync } from "node:fs";
|
|
@@ -29,7 +31,8 @@ import { add } from "./add.mjs";
|
|
|
29
31
|
import { list } from "./list.mjs";
|
|
30
32
|
import { remove } from "./remove.mjs";
|
|
31
33
|
import { renameApp } from "./rename-app.mjs";
|
|
32
|
-
import {
|
|
34
|
+
import { migrateToV065 } from "./migrate-v065.mjs";
|
|
35
|
+
import { createProject, addApp } from "./create/generator.js";
|
|
33
36
|
import {
|
|
34
37
|
getRegistryRoot,
|
|
35
38
|
getSummariesPath,
|
|
@@ -325,6 +328,44 @@ export async function startMcpServer() {
|
|
|
325
328
|
},
|
|
326
329
|
);
|
|
327
330
|
|
|
331
|
+
server.registerTool(
|
|
332
|
+
"sh_ui_add_app",
|
|
333
|
+
{
|
|
334
|
+
description:
|
|
335
|
+
"기존 모노레포에 새 Next.js 앱 추가 — apps/{name}/ + packages/ui/ui-apps/ui-{name}/ 동시 생성. " +
|
|
336
|
+
"사용자가 '앱 추가' / 'monorepo 에 새 앱' / 'add admin app' 류 요청을 하면 이 툴 사용 (Bash 로 npx " + cliName + " add-app 직접 호출보다 우선). " +
|
|
337
|
+
"v0.65+ 레이아웃 준수 — ui-{name} 은 tokens-only role, 컴포넌트는 sibling ui-core 가 SoT. " +
|
|
338
|
+
"theme/css 는 새 ui-app 에만 적용 (다른 앱 영향 없음). monorepo 가 아니면 (pnpm-workspace.yaml 없음) 에러.",
|
|
339
|
+
inputSchema: {
|
|
340
|
+
name: z.string().min(1)
|
|
341
|
+
.describe("앱 이름 — apps/{name}/ + packages/ui/ui-apps/ui-{name}/ 디렉토리명. 영숫자 + 하이픈."),
|
|
342
|
+
port: z.string().optional()
|
|
343
|
+
.describe("개발 서버 포트. 기본 3000. 다른 앱과 겹치면 사용자가 직접 다르게 지정."),
|
|
344
|
+
plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
|
|
345
|
+
.describe(`Next.js 플러그인 (${PLUGIN_NAMES.join(', ')}). 미지정시 빈 배열`),
|
|
346
|
+
theme: z.string().optional()
|
|
347
|
+
.describe(`프리셋 이름 (${THEME_PRESETS_LIST}) 또는 base64 테마 코드. 새 ui-app 의 tokens.css 에만 주입.`),
|
|
348
|
+
cssFramework: z.enum(CSS_FRAMEWORKS).optional()
|
|
349
|
+
.describe("CSS 프레임워크. 기본 plain. 새 앱의 컴포넌트 변종 결정 — 같은 모노레포 내 다른 앱과 다른 값 가능."),
|
|
350
|
+
cwd: z.string().optional()
|
|
351
|
+
.describe("모노레포 루트 (pnpm-workspace.yaml 있는 곳). 기본 process.cwd()"),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
async (input) => {
|
|
355
|
+
const text = await captureConsole(() =>
|
|
356
|
+
addApp({
|
|
357
|
+
name: input.name,
|
|
358
|
+
port: input.port,
|
|
359
|
+
plugins: input.plugins,
|
|
360
|
+
theme: input.theme,
|
|
361
|
+
css: input.cssFramework,
|
|
362
|
+
cwd: resolveCwd(input),
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
return textResult(text || "✓ 앱 추가 완료");
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
|
|
328
369
|
server.registerTool(
|
|
329
370
|
"sh_ui_init",
|
|
330
371
|
{
|
|
@@ -607,6 +648,37 @@ export async function startMcpServer() {
|
|
|
607
648
|
},
|
|
608
649
|
);
|
|
609
650
|
|
|
651
|
+
// v0.64.x → v0.65 monorepo 자동 마이그레이션.
|
|
652
|
+
server.registerTool(
|
|
653
|
+
"sh_ui_migrate_to_v065",
|
|
654
|
+
{
|
|
655
|
+
description:
|
|
656
|
+
"v0.64.x → v0.65 monorepo 자동 마이그레이션 — 컴포넌트/훅/lib 를 ui-{app} 들에서 ui-core 단일 SoT 로 통합 + ui-app 을 tokens-only role 로 정리 + apps/* 의 import (`@workspace/ui-{app}/components/...` → `@workspace/ui-core/...`) 재작성. " +
|
|
657
|
+
"**dryRun 기본 — 안전 우선**. 컨텐츠 충돌(같은 logical path 의 파일이 ui-app 마다 다른 내용) 검출 시 abort + 충돌 목록 반환 (자동 병합 안 함, 사용자가 손본 컴포넌트 보호). " +
|
|
658
|
+
"monorepo 전용 (pnpm-workspace.yaml + packages/ui/ui-apps 필요). 적용 후 사용자에게 `pnpm install` 안내 필수.",
|
|
659
|
+
inputSchema: {
|
|
660
|
+
cwd: z.string().optional().describe("monorepo 루트. 기본 process.cwd()"),
|
|
661
|
+
dryRun: z.boolean().optional().describe("plan 만 반환, 실제 변경 X. 기본 true (안전)"),
|
|
662
|
+
skipImportRewrite: z.boolean().optional().describe("apps/* import 재작성 생략. 기본 false"),
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
async (input) => {
|
|
666
|
+
try {
|
|
667
|
+
const { summary } = await migrateToV065({
|
|
668
|
+
cwd: resolveCwd(input),
|
|
669
|
+
dryRun: input.dryRun !== false,
|
|
670
|
+
skipImportRewrite: input.skipImportRewrite === true,
|
|
671
|
+
});
|
|
672
|
+
return textResult(summary);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
return {
|
|
675
|
+
isError: true,
|
|
676
|
+
content: [{ type: "text", text: e.message }],
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
);
|
|
681
|
+
|
|
610
682
|
// 변경 내역 조회 — 보너스: 사용자가 "최근 변경 알려줘" 류 요청 시
|
|
611
683
|
server.registerTool(
|
|
612
684
|
"sh_ui_get_changelog",
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// v0.64.x → v0.65 monorepo 마이그레이션 도구.
|
|
2
|
+
//
|
|
3
|
+
// v0.64.x 레이아웃: 컴포넌트가 packages/ui/ui-apps/ui-{app}/src/components/ 에 emit.
|
|
4
|
+
// 같은 모노레포 내 N 개 앱이면 같은 컴포넌트가 N 번 복제됨.
|
|
5
|
+
// v0.65 레이아웃: 컴포넌트는 packages/ui/ui-core/src/components/ 단일 SoT.
|
|
6
|
+
// ui-{app} 은 tokens-only role (토큰/스타일만).
|
|
7
|
+
//
|
|
8
|
+
// 안전 원칙:
|
|
9
|
+
// - dryRun 기본 — 실제 파일 변경은 명시적 dryRun=false 시에만.
|
|
10
|
+
// - 컨텐츠 충돌 시 즉시 abort — 자동 병합 시도하지 않음 (사용자가 손본 컴포넌트 보호).
|
|
11
|
+
// - import 재작성은 정확한 패턴 (`@workspace/ui-{app}/{kind}/`) 만 — false-positive 차단.
|
|
12
|
+
|
|
13
|
+
import * as fsp from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
const COMPONENT_KINDS = ["components", "hooks", "lib"];
|
|
18
|
+
|
|
19
|
+
async function fileExists(p) {
|
|
20
|
+
try {
|
|
21
|
+
await fsp.access(p);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function listFilesRecursive(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
async function walk(d) {
|
|
31
|
+
const entries = await fsp.readdir(d, { withFileTypes: true });
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
const full = path.join(d, e.name);
|
|
34
|
+
if (e.isDirectory()) {
|
|
35
|
+
if (e.name === "node_modules" || e.name === ".next" || e.name === "dist") continue;
|
|
36
|
+
await walk(full);
|
|
37
|
+
} else if (e.isFile()) {
|
|
38
|
+
out.push(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await walk(dir);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hashContent(s) {
|
|
47
|
+
return createHash("sha256").update(s).digest("hex").slice(0, 12);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 마이그레이션 plan 빌드 + (옵션) 적용.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} options
|
|
54
|
+
* @param {string} [options.cwd] — monorepo 루트. 기본 process.cwd().
|
|
55
|
+
* @param {boolean} [options.dryRun=true] — true 면 plan 텍스트 반환, false 면 실제 적용.
|
|
56
|
+
* @param {boolean} [options.skipImportRewrite] — apps/* 의 import 재작성 생략. 기본 false.
|
|
57
|
+
* @returns {Promise<{plan: object, summary: string}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function migrateToV065(options = {}) {
|
|
60
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
61
|
+
const dryRun = options.dryRun !== false; // 기본 true (안전)
|
|
62
|
+
const skipImportRewrite = options.skipImportRewrite === true;
|
|
63
|
+
|
|
64
|
+
// ─── 1. 사전 검사 ───
|
|
65
|
+
if (!(await fileExists(path.join(cwd, "pnpm-workspace.yaml")))) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"pnpm-workspace.yaml 없음 — 모노레포가 아닙니다. v0.65 마이그레이션은 monorepo 변종에만 적용됩니다.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const uiAppsDir = path.join(cwd, "packages", "ui", "ui-apps");
|
|
72
|
+
if (!(await fileExists(uiAppsDir))) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"packages/ui/ui-apps/ 없음 — v0.64.x monorepo 레이아웃이 아니거나 이미 마이그레이션됨.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const uiPackages = (await fsp.readdir(uiAppsDir, { withFileTypes: true }))
|
|
79
|
+
.filter((e) => e.isDirectory() && e.name.startsWith("ui-") && e.name !== "ui-app-template")
|
|
80
|
+
.map((e) => e.name);
|
|
81
|
+
|
|
82
|
+
if (uiPackages.length === 0) {
|
|
83
|
+
throw new Error("packages/ui/ui-apps/ui-* 패키지 없음.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const uiCoreDir = path.join(cwd, "packages", "ui", "ui-core");
|
|
87
|
+
const uiCoreCfgPath = path.join(uiCoreDir, "sh-ui.config.json");
|
|
88
|
+
const uiCoreAlreadyV065 = await fileExists(uiCoreCfgPath);
|
|
89
|
+
|
|
90
|
+
// ─── 2. 파일 수집 + 충돌 검출 ───
|
|
91
|
+
// logicalPath ('src/components/button/index.tsx') → [{app, srcPath, hash, content}]
|
|
92
|
+
const fileMap = new Map();
|
|
93
|
+
for (const pkg of uiPackages) {
|
|
94
|
+
const pkgDir = path.join(uiAppsDir, pkg);
|
|
95
|
+
for (const kind of COMPONENT_KINDS) {
|
|
96
|
+
const subDir = path.join(pkgDir, "src", kind);
|
|
97
|
+
if (!(await fileExists(subDir))) continue;
|
|
98
|
+
const files = await listFilesRecursive(subDir);
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const rel = path.relative(subDir, file);
|
|
101
|
+
if (rel === ".gitkeep" || rel.startsWith(".gitkeep")) continue;
|
|
102
|
+
const content = await fsp.readFile(file, "utf-8");
|
|
103
|
+
const hash = hashContent(content);
|
|
104
|
+
const logicalPath = path.posix.join("src", kind, ...rel.split(path.sep));
|
|
105
|
+
const list = fileMap.get(logicalPath) ?? [];
|
|
106
|
+
list.push({ app: pkg, srcPath: file, hash, content });
|
|
107
|
+
fileMap.set(logicalPath, list);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ui-core 에 같은 logical path 의 기존 파일이 있으면 충돌 검출 대상에 포함.
|
|
113
|
+
for (const [logicalPath, entries] of fileMap) {
|
|
114
|
+
const uiCorePath = path.join(uiCoreDir, logicalPath);
|
|
115
|
+
if (await fileExists(uiCorePath)) {
|
|
116
|
+
const existing = await fsp.readFile(uiCorePath, "utf-8");
|
|
117
|
+
const hash = hashContent(existing);
|
|
118
|
+
entries.push({ app: "ui-core (existing)", srcPath: uiCorePath, hash, content: existing });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const moves = [];
|
|
123
|
+
const conflicts = [];
|
|
124
|
+
for (const [logicalPath, entries] of fileMap) {
|
|
125
|
+
const uniqueHashes = [...new Set(entries.map((e) => e.hash))];
|
|
126
|
+
if (uniqueHashes.length > 1) {
|
|
127
|
+
conflicts.push({
|
|
128
|
+
logicalPath,
|
|
129
|
+
variants: entries.map((e) => ({ app: e.app, hash: e.hash })),
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
moves.push({
|
|
133
|
+
logicalPath,
|
|
134
|
+
sourceApps: entries
|
|
135
|
+
.filter((e) => e.app !== "ui-core (existing)")
|
|
136
|
+
.map((e) => e.app),
|
|
137
|
+
canonicalSrc: entries[0].srcPath,
|
|
138
|
+
dest: path.join(uiCoreDir, logicalPath),
|
|
139
|
+
alreadyInUiCore: entries.some((e) => e.app === "ui-core (existing)"),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── 3. ui-app config / package.json 패치 plan ───
|
|
145
|
+
const appConfigPatches = [];
|
|
146
|
+
const appPackageJsonPatches = [];
|
|
147
|
+
for (const pkg of uiPackages) {
|
|
148
|
+
const cfgPath = path.join(uiAppsDir, pkg, "sh-ui.config.json");
|
|
149
|
+
if (await fileExists(cfgPath)) {
|
|
150
|
+
const cfg = JSON.parse(await fsp.readFile(cfgPath, "utf-8"));
|
|
151
|
+
if (cfg.role !== "tokens-only") {
|
|
152
|
+
appConfigPatches.push({ pkg, cfgPath });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const pkgJsonPath = path.join(uiAppsDir, pkg, "package.json");
|
|
156
|
+
if (await fileExists(pkgJsonPath)) {
|
|
157
|
+
const pkgJson = JSON.parse(await fsp.readFile(pkgJsonPath, "utf-8"));
|
|
158
|
+
const removeExports = ["./components/*", "./hooks/*", "./lib/*"]
|
|
159
|
+
.filter((k) => pkgJson.exports?.[k]);
|
|
160
|
+
if (removeExports.length > 0) {
|
|
161
|
+
appPackageJsonPatches.push({ pkg, pkgJsonPath, removeExports });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── 4. apps/* import 재작성 plan ───
|
|
167
|
+
const importRewrites = [];
|
|
168
|
+
if (!skipImportRewrite) {
|
|
169
|
+
const appsDir = path.join(cwd, "apps");
|
|
170
|
+
if (await fileExists(appsDir)) {
|
|
171
|
+
const apps = (await fsp.readdir(appsDir, { withFileTypes: true }))
|
|
172
|
+
.filter((e) => e.isDirectory())
|
|
173
|
+
.map((e) => e.name);
|
|
174
|
+
const tsExt = /\.(ts|tsx|mjs|js|jsx)$/;
|
|
175
|
+
const re = /@workspace\/ui-([a-zA-Z0-9-]+)\/(components|hooks|lib)\//g;
|
|
176
|
+
for (const app of apps) {
|
|
177
|
+
const appDir = path.join(appsDir, app);
|
|
178
|
+
const files = await listFilesRecursive(appDir);
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
if (!tsExt.test(file)) continue;
|
|
181
|
+
const content = await fsp.readFile(file, "utf-8");
|
|
182
|
+
if (!content.includes("@workspace/ui-")) continue;
|
|
183
|
+
let count = 0;
|
|
184
|
+
const rewritten = content.replace(re, (match, appName) => {
|
|
185
|
+
if (appName === "core") return match; // already core
|
|
186
|
+
count++;
|
|
187
|
+
return match.replace(`@workspace/ui-${appName}/`, "@workspace/ui-core/");
|
|
188
|
+
});
|
|
189
|
+
if (count > 0) {
|
|
190
|
+
importRewrites.push({ file, count, rewritten });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const plan = {
|
|
198
|
+
uiCoreAlreadyV065,
|
|
199
|
+
uiPackages,
|
|
200
|
+
moves,
|
|
201
|
+
conflicts,
|
|
202
|
+
appConfigPatches,
|
|
203
|
+
appPackageJsonPatches,
|
|
204
|
+
importRewrites,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// ─── 5. 충돌 발생 시 abort ───
|
|
208
|
+
if (conflicts.length > 0) {
|
|
209
|
+
const summary = renderConflicts(conflicts);
|
|
210
|
+
const err = new Error(
|
|
211
|
+
`자동 마이그레이션 abort — ${conflicts.length} 개 파일에 컨텐츠 충돌.\n` +
|
|
212
|
+
`같은 logical path 의 파일이 ui-app 마다 다른 내용을 가지고 있습니다 (사용자가 손본 결과 가능성).\n` +
|
|
213
|
+
`수동으로 해결한 뒤 재실행하거나, 충돌 파일은 수동 이전.\n\n${summary}`,
|
|
214
|
+
);
|
|
215
|
+
err.conflicts = conflicts;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── 6. dryRun: plan 텍스트 반환 ───
|
|
220
|
+
if (dryRun) {
|
|
221
|
+
const summary = renderPlan(plan, cwd);
|
|
222
|
+
return { plan, summary };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── 7. apply ───
|
|
226
|
+
await applyPlan(plan, { cwd, uiAppsDir, uiCoreDir });
|
|
227
|
+
return {
|
|
228
|
+
plan,
|
|
229
|
+
summary:
|
|
230
|
+
`✓ v0.65 마이그레이션 완료\n` +
|
|
231
|
+
` ${moves.length} 파일 ui-core 로 이동\n` +
|
|
232
|
+
` ${appConfigPatches.length} ui-app sh-ui.config.json 패치\n` +
|
|
233
|
+
` ${appPackageJsonPatches.length} ui-app package.json 패치\n` +
|
|
234
|
+
` ${importRewrites.length} apps/* 파일 import 재작성`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderPlan(plan, cwd) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
lines.push("[DRY RUN] v0.65 마이그레이션 plan — 실제 적용은 dryRun: false");
|
|
241
|
+
lines.push("");
|
|
242
|
+
|
|
243
|
+
if (!plan.uiCoreAlreadyV065) {
|
|
244
|
+
lines.push("◇ ui-core sh-ui.config.json 신규 생성 + package.json exports 추가");
|
|
245
|
+
} else {
|
|
246
|
+
lines.push("◇ ui-core 는 이미 v0.65 레이아웃 — 컴포넌트만 이동");
|
|
247
|
+
}
|
|
248
|
+
lines.push("");
|
|
249
|
+
|
|
250
|
+
if (plan.moves.length > 0) {
|
|
251
|
+
lines.push(`◇ 파일 이동 (${plan.moves.length}개) — N 개 ui-app 의 동일 컨텐츠 → ui-core 단일 SoT:`);
|
|
252
|
+
for (const m of plan.moves.slice(0, 30)) {
|
|
253
|
+
const sources = m.sourceApps.length > 0 ? m.sourceApps.join(", ") : "(이미 ui-core)";
|
|
254
|
+
lines.push(` ${m.logicalPath} ← ${sources}${m.alreadyInUiCore ? " (ui-core 동일)" : ""}`);
|
|
255
|
+
}
|
|
256
|
+
if (plan.moves.length > 30) lines.push(` ... +${plan.moves.length - 30} more`);
|
|
257
|
+
lines.push("");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (plan.appConfigPatches.length > 0) {
|
|
261
|
+
lines.push(`◇ ui-app sh-ui.config.json 패치 (${plan.appConfigPatches.length}개):`);
|
|
262
|
+
for (const p of plan.appConfigPatches) {
|
|
263
|
+
lines.push(` ${p.pkg}: role: "tokens-only" 추가, components/utils paths/aliases 제거`);
|
|
264
|
+
}
|
|
265
|
+
lines.push("");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (plan.appPackageJsonPatches.length > 0) {
|
|
269
|
+
lines.push(`◇ ui-app package.json exports 정리 (${plan.appPackageJsonPatches.length}개):`);
|
|
270
|
+
for (const p of plan.appPackageJsonPatches) {
|
|
271
|
+
lines.push(` ${p.pkg}: ${p.removeExports.join(", ")} 제거`);
|
|
272
|
+
}
|
|
273
|
+
lines.push("");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (plan.importRewrites.length > 0) {
|
|
277
|
+
lines.push(`◇ apps/* import 재작성 (${plan.importRewrites.length}개 파일):`);
|
|
278
|
+
for (const r of plan.importRewrites.slice(0, 20)) {
|
|
279
|
+
lines.push(` ${path.relative(cwd, r.file)} (${r.count} 곳)`);
|
|
280
|
+
}
|
|
281
|
+
if (plan.importRewrites.length > 20) {
|
|
282
|
+
lines.push(` ... +${plan.importRewrites.length - 20} more files`);
|
|
283
|
+
}
|
|
284
|
+
lines.push("");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
plan.moves.length === 0 &&
|
|
289
|
+
plan.appConfigPatches.length === 0 &&
|
|
290
|
+
plan.appPackageJsonPatches.length === 0 &&
|
|
291
|
+
plan.importRewrites.length === 0
|
|
292
|
+
) {
|
|
293
|
+
lines.push("✓ 변경 사항 없음 — 이미 v0.65 레이아웃.");
|
|
294
|
+
} else {
|
|
295
|
+
lines.push("실제 적용: dryRun: false 로 재호출.");
|
|
296
|
+
}
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function renderConflicts(conflicts) {
|
|
301
|
+
const lines = [];
|
|
302
|
+
for (const c of conflicts.slice(0, 15)) {
|
|
303
|
+
lines.push(` ${c.logicalPath}`);
|
|
304
|
+
for (const v of c.variants) {
|
|
305
|
+
lines.push(` ${v.app}: ${v.hash}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (conflicts.length > 15) {
|
|
309
|
+
lines.push(` ... +${conflicts.length - 15} more`);
|
|
310
|
+
}
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function applyPlan(plan, ctx) {
|
|
315
|
+
const { uiAppsDir, uiCoreDir } = ctx;
|
|
316
|
+
|
|
317
|
+
// 1. ui-core 디렉토리/configs 보장
|
|
318
|
+
await fsp.mkdir(uiCoreDir, { recursive: true });
|
|
319
|
+
await fsp.mkdir(path.join(uiCoreDir, "src", "components"), { recursive: true });
|
|
320
|
+
await fsp.mkdir(path.join(uiCoreDir, "src", "hooks"), { recursive: true });
|
|
321
|
+
await fsp.mkdir(path.join(uiCoreDir, "src", "lib"), { recursive: true });
|
|
322
|
+
|
|
323
|
+
if (!plan.uiCoreAlreadyV065) {
|
|
324
|
+
await ensureUiCoreConfig(uiCoreDir);
|
|
325
|
+
await ensureUiCorePackageJsonExports(uiCoreDir);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 2. 파일 이동 — copy 후 source 삭제
|
|
329
|
+
for (const m of plan.moves) {
|
|
330
|
+
if (!m.alreadyInUiCore) {
|
|
331
|
+
await fsp.mkdir(path.dirname(m.dest), { recursive: true });
|
|
332
|
+
await fsp.copyFile(m.canonicalSrc, m.dest);
|
|
333
|
+
}
|
|
334
|
+
for (const app of m.sourceApps) {
|
|
335
|
+
const appSrc = path.join(uiAppsDir, app, m.logicalPath);
|
|
336
|
+
if (await fileExists(appSrc)) {
|
|
337
|
+
await fsp.rm(appSrc, { force: true });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 3. ui-app sh-ui.config.json 패치 — role 추가, components/utils paths/aliases 제거
|
|
343
|
+
for (const p of plan.appConfigPatches) {
|
|
344
|
+
const cfg = JSON.parse(await fsp.readFile(p.cfgPath, "utf-8"));
|
|
345
|
+
cfg.role = "tokens-only";
|
|
346
|
+
if (cfg.paths) {
|
|
347
|
+
delete cfg.paths.components;
|
|
348
|
+
delete cfg.paths.hooks;
|
|
349
|
+
delete cfg.paths.utils;
|
|
350
|
+
}
|
|
351
|
+
if (cfg.aliases) {
|
|
352
|
+
delete cfg.aliases.components;
|
|
353
|
+
delete cfg.aliases.hooks;
|
|
354
|
+
delete cfg.aliases.utils;
|
|
355
|
+
delete cfg.aliases.ui;
|
|
356
|
+
if (Object.keys(cfg.aliases).length === 0) delete cfg.aliases;
|
|
357
|
+
}
|
|
358
|
+
// role 을 platform/cssFramework 직후로 이동시키기 위해 재구성.
|
|
359
|
+
const ordered = {};
|
|
360
|
+
if (cfg.platform != null) ordered.platform = cfg.platform;
|
|
361
|
+
if (cfg.cssFramework != null) ordered.cssFramework = cfg.cssFramework;
|
|
362
|
+
ordered.role = "tokens-only";
|
|
363
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
364
|
+
if (k === "platform" || k === "cssFramework" || k === "role") continue;
|
|
365
|
+
ordered[k] = v;
|
|
366
|
+
}
|
|
367
|
+
await fsp.writeFile(p.cfgPath, JSON.stringify(ordered, null, 2) + "\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 4. ui-app package.json exports 정리
|
|
371
|
+
for (const p of plan.appPackageJsonPatches) {
|
|
372
|
+
const pkgJson = JSON.parse(await fsp.readFile(p.pkgJsonPath, "utf-8"));
|
|
373
|
+
if (pkgJson.exports) {
|
|
374
|
+
for (const k of p.removeExports) delete pkgJson.exports[k];
|
|
375
|
+
}
|
|
376
|
+
await fsp.writeFile(p.pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 5. apps/* import 재작성
|
|
380
|
+
for (const r of plan.importRewrites) {
|
|
381
|
+
await fsp.writeFile(r.file, r.rewritten);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 6. 비어진 ui-app 의 src/components/, src/hooks/, src/lib/ 디렉토리 정리.
|
|
385
|
+
// .gitkeep 남기지 않고 디렉토리 자체 제거 (v0.65 ui-app 은 styles 만).
|
|
386
|
+
for (const pkg of plan.uiPackages) {
|
|
387
|
+
for (const kind of COMPONENT_KINDS) {
|
|
388
|
+
const subDir = path.join(uiAppsDir, pkg, "src", kind);
|
|
389
|
+
if (!(await fileExists(subDir))) continue;
|
|
390
|
+
const remaining = await listFilesRecursive(subDir);
|
|
391
|
+
const allGitkeep = remaining.every((f) => path.basename(f) === ".gitkeep");
|
|
392
|
+
if (remaining.length === 0 || allGitkeep) {
|
|
393
|
+
await fsp.rm(subDir, { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function ensureUiCoreConfig(uiCoreDir) {
|
|
400
|
+
const cfgPath = path.join(uiCoreDir, "sh-ui.config.json");
|
|
401
|
+
if (await fileExists(cfgPath)) return;
|
|
402
|
+
const cfg = {
|
|
403
|
+
platform: "react",
|
|
404
|
+
cssFramework: "plain",
|
|
405
|
+
paths: {
|
|
406
|
+
components: "src/components",
|
|
407
|
+
hooks: "src/hooks",
|
|
408
|
+
utils: "src/lib/utils.ts",
|
|
409
|
+
},
|
|
410
|
+
aliases: {
|
|
411
|
+
components: "@workspace/ui-core/components",
|
|
412
|
+
hooks: "@workspace/ui-core/hooks",
|
|
413
|
+
utils: "@workspace/ui-core/lib/utils",
|
|
414
|
+
ui: "@workspace/ui-core/components",
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function ensureUiCorePackageJsonExports(uiCoreDir) {
|
|
421
|
+
const pkgJsonPath = path.join(uiCoreDir, "package.json");
|
|
422
|
+
if (!(await fileExists(pkgJsonPath))) return;
|
|
423
|
+
const pkgJson = JSON.parse(await fsp.readFile(pkgJsonPath, "utf-8"));
|
|
424
|
+
pkgJson.exports = {
|
|
425
|
+
"./components/*": "./src/components/*.tsx",
|
|
426
|
+
"./hooks/*": "./src/hooks/*.ts",
|
|
427
|
+
"./lib/*": "./src/lib/*.ts",
|
|
428
|
+
...(pkgJson.exports ?? {}),
|
|
429
|
+
};
|
|
430
|
+
await fsp.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n");
|
|
431
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// sh-ui 의 동작 컨텍스트 해석 — `sh-ui add` 등이 cwd 부터 부모 트리를 walk up 해
|
|
2
|
+
// **standalone (sh-ui.config.json 발견)** 또는 **monorepo (pnpm-workspace.yaml 발견)** 컨텍스트 결정.
|
|
3
|
+
//
|
|
4
|
+
// monorepo 에서 사용자가 어디서 명령을 실행했는지 (apps/web, packages/ui/ui-apps/ui-admin 등)
|
|
5
|
+
// 까지 추출해 hintApp 으로 넘김 — `sh-ui add tokens` 가 어느 ui-app 으로 라우팅될지의 기본값.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} startDir - 검색 시작점 (보통 process.cwd())
|
|
12
|
+
* @returns {{kind: 'config', root: string} | {kind: 'monorepo', root: string, hintApp: string|null} | null}
|
|
13
|
+
*/
|
|
14
|
+
export function findShUiContext(startDir) {
|
|
15
|
+
let dir = path.resolve(startDir);
|
|
16
|
+
while (true) {
|
|
17
|
+
if (existsSync(path.join(dir, "sh-ui.config.json"))) {
|
|
18
|
+
return { kind: "config", root: dir };
|
|
19
|
+
}
|
|
20
|
+
if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
|
|
21
|
+
const hintApp = extractHintApp(dir, startDir);
|
|
22
|
+
return { kind: "monorepo", root: dir, hintApp };
|
|
23
|
+
}
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) return null;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* monorepo 루트가 정해진 뒤 startDir 의 상대 경로에서 어느 앱 컨텍스트인지 추출.
|
|
32
|
+
*
|
|
33
|
+
* 매칭 패턴 (모노레포 루트 기준):
|
|
34
|
+
* - `apps/<name>/...` → <name>
|
|
35
|
+
* - `packages/ui/ui-apps/ui-<name>/...` → <name>
|
|
36
|
+
*
|
|
37
|
+
* 그 외(루트 자체, packages/ui/ui-core 등)는 null — 호출자가 prompt 또는 fallback.
|
|
38
|
+
*/
|
|
39
|
+
function extractHintApp(monorepoRoot, startDir) {
|
|
40
|
+
const rel = path.relative(monorepoRoot, path.resolve(startDir));
|
|
41
|
+
if (!rel || rel.startsWith("..")) return null;
|
|
42
|
+
const parts = rel.split(path.sep);
|
|
43
|
+
if (parts[0] === "apps" && parts[1]) {
|
|
44
|
+
return parts[1];
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
parts[0] === "packages" &&
|
|
48
|
+
parts[1] === "ui" &&
|
|
49
|
+
parts[2] === "ui-apps" &&
|
|
50
|
+
parts[3] &&
|
|
51
|
+
parts[3].startsWith("ui-")
|
|
52
|
+
) {
|
|
53
|
+
return parts[3].slice(3);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|