sh-ui-cli 0.65.0 → 0.66.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 +14 -0
- package/data/changelog/versions.json +13 -0
- package/package.json +1 -1
- package/src/create/cli-args.js +1 -1
- package/src/create/generator.js +61 -24
- package/src/create/index.mjs +8 -2
- package/src/mcp.mjs +73 -1
- package/src/migrate-v065.mjs +431 -0
package/bin/sh-ui.mjs
CHANGED
|
@@ -18,6 +18,8 @@ const usage = `사용법:
|
|
|
18
18
|
sh-ui rename-app <old> <new> monorepo 의 앱 이름 일괄 변경
|
|
19
19
|
(apps/<old>/, packages/ui/ui-apps/ui-<old>/
|
|
20
20
|
디렉토리 + 모든 import/path 패턴)
|
|
21
|
+
sh-ui migrate-v065 v0.64.x → v0.65 자동 마이그레이션
|
|
22
|
+
(--dry-run 기본, --apply 로 실제 적용)
|
|
21
23
|
sh-ui mcp MCP 서버(stdio) 시작 — IDE-내 AI용
|
|
22
24
|
sh-ui mcp init --client <name> IDE MCP 설정 파일에 sh-ui 엔트리 자동 추가
|
|
23
25
|
(claude-code | cursor | claude-desktop)
|
|
@@ -93,6 +95,18 @@ try {
|
|
|
93
95
|
await renameApp({ cwd: process.cwd(), oldName, newName, yes, dryRun, skipInstall });
|
|
94
96
|
break;
|
|
95
97
|
}
|
|
98
|
+
case "migrate-v065": {
|
|
99
|
+
const apply = rest.includes("--apply");
|
|
100
|
+
const skipImportRewrite = rest.includes("--skip-import-rewrite");
|
|
101
|
+
const { migrateToV065 } = await import("../src/migrate-v065.mjs");
|
|
102
|
+
const { summary } = await migrateToV065({
|
|
103
|
+
cwd: process.cwd(),
|
|
104
|
+
dryRun: !apply,
|
|
105
|
+
skipImportRewrite,
|
|
106
|
+
});
|
|
107
|
+
console.log(summary);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
96
110
|
case "remove":
|
|
97
111
|
case "rm": {
|
|
98
112
|
const force = rest.includes("--force");
|
|
@@ -2,6 +2,19 @@
|
|
|
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.66.0",
|
|
7
|
+
"date": "2026-05-09",
|
|
8
|
+
"title": "monorepo: sh_ui_add_app MCP 툴 + sh_ui_migrate_to_v065 자동 마이그레이션",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`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]` 도 동시 확장.",
|
|
12
|
+
"**`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 (자동 병합 안 함).",
|
|
13
|
+
"**`apps/docs/migrations/v0.65.md`** — 자동 도구 사용법 + 충돌 해결 가이드 + 수동 마이그레이션 6 단계 + 호환성 노트.",
|
|
14
|
+
"**addApp 옵션 객체 시그니처** — `addApp({ name, port, plugins, theme, css, cwd })`. 비대화형 (MCP daemon, CI) 지원 + 새 ui-app 의 tokens.css 에 theme 주입까지 createProject 흐름과 통일."
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.66.0"
|
|
17
|
+
},
|
|
5
18
|
{
|
|
6
19
|
"version": "0.65.0",
|
|
7
20
|
"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');
|
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,7 +67,13 @@ 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
|
await addComponent(positional[0], flags.app);
|
|
73
79
|
} else {
|
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
|
+
}
|