sh-ui-cli 0.56.4 → 0.57.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 CHANGED
@@ -15,17 +15,21 @@ const usage = `사용법:
15
15
  특수값: tokens → 설정 기반 토큰 파일 생성
16
16
  sh-ui list 현재 설치된 컴포넌트 목록 표시
17
17
  sh-ui remove <component...> 설치된 컴포넌트 파일 삭제
18
+ sh-ui rename-app <old> <new> monorepo 의 앱 이름 일괄 변경
19
+ (apps/<old>/, packages/ui/ui-apps/ui-<old>/
20
+ 디렉토리 + 모든 import/path 패턴)
18
21
  sh-ui mcp MCP 서버(stdio) 시작 — IDE-내 AI용
19
22
  sh-ui mcp init --client <name> IDE MCP 설정 파일에 sh-ui 엔트리 자동 추가
20
23
  (claude-code | cursor | claude-desktop)
21
24
  옵션:
22
- --skip-install (add) 외부 패키지 자동 설치 생략
25
+ --skip-install (add, rename-app) 외부 패키지 자동 설치 생략
23
26
  --diff (add) 파일을 쓰지 않고 변경 내역만 출력
24
27
  --force (add) 기존 파일을 모두 덮어쓰기 (prompt 없음)
25
28
  (remove) 사용자가 수정한 파일도 삭제
26
29
  --keep (add) 기존 파일을 모두 유지 (prompt 없음)
27
30
  --all (list) 설치되지 않은 컴포넌트까지 표시
28
- --dry-run (remove) 삭제 대상만 출력하고 실행 안 함
31
+ --dry-run (remove, rename-app) 변경 대상만 출력하고 실행 안 함
32
+ --yes (rename-app) 대화형 확인 생략
29
33
  `;
30
34
 
31
35
  try {
@@ -74,6 +78,21 @@ try {
74
78
  }
75
79
  break;
76
80
  }
81
+ case "rename-app": {
82
+ const yes = rest.includes("--yes");
83
+ const dryRun = rest.includes("--dry-run");
84
+ const skipInstall = rest.includes("--skip-install");
85
+ const positional = rest.filter((a) => !a.startsWith("--"));
86
+ if (positional.length < 2) {
87
+ console.error("에러: rename-app 은 <old> <new> 두 인자가 필요합니다.\n");
88
+ console.error(usage);
89
+ process.exit(1);
90
+ }
91
+ const [oldName, newName] = positional;
92
+ const { renameApp } = await import("../src/rename-app.mjs");
93
+ await renameApp({ cwd: process.cwd(), oldName, newName, yes, dryRun, skipInstall });
94
+ break;
95
+ }
77
96
  case "remove":
78
97
  case "rm": {
79
98
  const force = rest.includes("--force");
@@ -2,6 +2,21 @@
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.57.0",
7
+ "date": "2026-05-04",
8
+ "title": "rename-app — monorepo 앱 이름 일괄 변경 명령어 + MCP 툴",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`sh-ui rename-app <old> <new>`** — monorepo 의 앱 이름을 한 번에 변경. `apps/<old>/`, `packages/ui/ui-apps/ui-<old>/` 두 디렉토리 이동 + `@workspace/ui-<old>` / `apps/<old>` / `--filter <old>` / `--app <old>` 같은 컨텍스트 묶인 패턴만 치환. 사용자 코드, package.json name, tsconfig paths, Dockerfile WORKDIR, next.config transpilePackages, sh-ui.config aliases, README, `.github/workflows/*.yml`, 루트 package.json scripts 까지 자동 갱신.",
12
+ "**MCP 툴 `sh_ui_rename_app`** — IDE-내 AI 가 \"apps/web 을 dashboard 로 바꿔줘\" 류 요청 받으면 자동 호출. `dryRun: true` 로 변경 매트릭스 미리보기 후 실행 권장. instructions 블록에도 가이드 추가.",
13
+ "**옵션** — `--dry-run` (변경 매트릭스만 출력) / `--yes` (대화형 확인 생략) / `--skip-install` (마지막 `pnpm install` 생략).",
14
+ "**false-positive 방지** — bare 단어(예: `web`)는 절대 치환하지 않음. 컨텍스트(슬래시, 따옴표, 백틱, 공백, 개행) 로 묶인 패턴만 매치 — `core-web-vitals` (ESLint) / `safari-web-extension` (Sentry) 같은 생태계 상수는 보존.",
15
+ "**lockfile 자동 재생성** — 마지막 단계에서 `pnpm install` 실행해 workspace 링크 갱신. 실패해도 friendly 안내 메시지로 사용자가 수동 실행 가능.",
16
+ "**fix — Next 16 호환: `app/layout.tsx` html/body 누락 에러 해소** — next-intl 플러그인이 `app/layout.tsx` 를 패스스루(`return children`) 로 남기던 방식이 Next 16 의 \"Missing <html> and <body> tags in the root layout\" 런타임 에러를 일으키던 문제 수정. 이제 `app/layout.tsx` 를 통째로 `app/[locale]/layout.tsx` 로 이동(globals.css side-effect import 보존)하고 본체만 locale-aware 버전으로 교체 — `app/layout.tsx` 가 사라지면서 `[locale]/layout.tsx` 가 Next 의 root layout 으로 인식되고 그 안의 `RootLayout` (html/body 보유) 이 정상 적용. smoke matrix 에 회귀 가드 추가."
17
+ ],
18
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.57.0"
19
+ },
5
20
  {
6
21
  "version": "0.56.4",
7
22
  "date": "2026-05-04",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.56.4",
3
+ "version": "0.57.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,25 +40,43 @@ export const nextIntlPlugin = {
40
40
  { type: 'move', from: 'app/page.tsx', to: 'app/[locale]/page.tsx' },
41
41
  { type: 'move', from: 'app/error.tsx', to: 'app/[locale]/error.tsx' },
42
42
  {
43
- // 기본 nextjs-app 템플릿의 app/layout.tsx globals.cssside-effect import 한다 —
44
- // next-intl 도입 layout 본체는 [locale]/layout.tsx 옮기지만, CSS import 는 root
45
- // layout 살아 있어야 사용자 프로젝트의 Tailwind 스타일이 동작한다. content 통째 교체
46
- // 대신 contentFn 으로 side-effect import (`import 'x';` 형태, binding 없음) 추출해
47
- // 새 본체 앞에 prepend. 이름 있는 import (예: `import { RootLayout } from ...`) 는 새 본체와
48
- // 식별자 충돌 가능성이 있어 제외.
43
+ // Next 16 부터 root layout (app/layout.tsx) 반드시 <html>/<body> 가져야 한다.
44
+ // next-intl 적용 시에는 [locale] 가 root 역할을 맡으므로, 기본 app/layout.tsx 그대로
45
+ // [locale]/layout.tsx 이동시켜 globals.css side-effect import 보존한 뒤,
46
+ // body locale-aware 버전으로 교체한다. 결과적으로 app/layout.tsx 존재하지 않게 되고
47
+ // [locale]/layout.tsx Next root layout 으로 인식된다.
48
+ type: 'move',
49
+ from: 'app/layout.tsx',
50
+ to: 'app/[locale]/layout.tsx',
51
+ },
52
+ {
53
+ // 위에서 옮겨진 [locale]/layout.tsx 는 비-locale 버전 — body 를 locale-aware 로 갈아끼운다.
54
+ // side-effect import (`import 'x';` 형태, binding 없음) 만 보존하고 나머지는 통째 교체.
55
+ // 이름 있는 import (예: `import { RootLayout } from ...`) 는 새 본체와 식별자 충돌 가능성이
56
+ // 있어 제외.
49
57
  type: 'replace',
50
- path: 'app/layout.tsx',
58
+ path: 'app/[locale]/layout.tsx',
51
59
  contentFn: (existing) => {
52
60
  const sideEffectImports = existing
53
61
  .split('\n')
54
62
  .filter((line) => /^\s*import\s+['"][^'"]+['"];?\s*$/.test(line))
55
63
  .join('\n');
56
- const body = `export default async function RootLayout({
64
+ const body = `import type { Metadata } from 'next';
65
+ import { RootLayout } from '@/src/app/layouts/RootLayout';
66
+
67
+ export const metadata: Metadata = {
68
+ title: 'My App',
69
+ description: 'My App Description',
70
+ };
71
+
72
+ export default function Layout({
57
73
  children,
58
- }: {
74
+ params,
75
+ }: Readonly<{
59
76
  children: React.ReactNode;
60
- }) {
61
- return children;
77
+ params: Promise<{ locale: string }>;
78
+ }>) {
79
+ return <RootLayout params={params}>{children}</RootLayout>;
62
80
  }
63
81
  `;
64
82
  return sideEffectImports ? `${sideEffectImports}\n\n${body}` : body;
@@ -194,24 +212,7 @@ export const { Link, redirect, usePathname, useRouter, getPathname } =
194
212
  }
195
213
  `,
196
214
 
197
- 'app/[locale]/layout.tsx': `import type { Metadata } from 'next';
198
- import { RootLayout } from '@/src/app/layouts/RootLayout';
199
-
200
- export const metadata: Metadata = {
201
- title: 'My App',
202
- description: 'My App Description',
203
- };
204
-
205
- export default function Layout({
206
- children,
207
- params,
208
- }: Readonly<{
209
- children: React.ReactNode;
210
- params: Promise<{ locale: string }>;
211
- }>) {
212
- return <RootLayout params={params}>{children}</RootLayout>;
213
- }
214
- `,
215
+ // app/[locale]/layout.tsx transforms 에서 생성된다 (위 move + replace 참고)
215
216
 
216
217
  'proxy.ts': `import createIntlMiddleware from 'next-intl/middleware';
217
218
  import { routing } from '@/src/shared/config/i18n/routing';
package/src/mcp.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  // sh_ui_get_changelog - 변경 내역(versions.json) 반환
16
16
  // sh_ui_encode_theme - 토큰 객체 → base64 (사용자가 손본 톤을 영구 보관)
17
17
  // sh_ui_decode_theme - base64 → 토큰 객체 (기존 테마 일부만 수정 후 재인코딩)
18
+ // sh_ui_rename_app - monorepo 의 앱 이름 일괄 변경 (디렉토리 + import/path)
18
19
 
19
20
  import { readFile } from "node:fs/promises";
20
21
  import { existsSync } from "node:fs";
@@ -27,6 +28,7 @@ import { init } from "./init.mjs";
27
28
  import { add } from "./add.mjs";
28
29
  import { list } from "./list.mjs";
29
30
  import { remove } from "./remove.mjs";
31
+ import { renameApp } from "./rename-app.mjs";
30
32
  import { createProject } from "./create/generator.js";
31
33
  import {
32
34
  getRegistryRoot,
@@ -171,6 +173,10 @@ function buildServerInstructions(cliName) {
171
173
  - \`sh_ui_add_component\` / \`sh_ui_remove_component\` — 설치/삭제
172
174
  - \`sh_ui_get_changelog\` — 최근 변경 내역
173
175
 
176
+ ## 앱 이름 변경 (monorepo)
177
+
178
+ 사용자가 "apps/web 을 apps/dashboard 로 바꿔줘" 같이 모노레포 앱 이름 변경을 요청하면 \`sh_ui_rename_app\` 사용 — 손으로 6~10 군데 (디렉토리, package.json name, tsconfig paths, Dockerfile WORKDIR, next.config transpilePackages, sh-ui.config aliases, README, .github/workflows) 갈아엎지 않도록 자동화. \`dryRun: true\` 로 먼저 변경 매트릭스 보여주고 사용자 확인 후 실행 권장.
179
+
174
180
  ## 테마 커스터마이징 (스캐폴드 결과 톤이 마음에 안 들 때)
175
181
 
176
182
  스캐폴드 후 사용자가 "눈 아프다" / "Linear 톤으로" 같이 톤 조정을 요청하면, **\`tokens.css\` 직접 편집** + **편집 결과를 base64 로 백업** 두 단계를 같이 한다 — 그래야 다음에 같은 프로젝트를 재생성해도 톤이 보존된다.
@@ -524,6 +530,40 @@ export async function startMcpServer() {
524
530
  },
525
531
  );
526
532
 
533
+ // 모노레포 앱 이름 일괄 변경 — 디렉토리 이동 + import/path 패턴 치환 + lockfile 재생성.
534
+ // dryRun=true 면 변경 매트릭스만 반환해 AI 가 사용자에게 미리보기 가능.
535
+ server.registerTool(
536
+ "sh_ui_rename_app",
537
+ {
538
+ description:
539
+ "monorepo 의 앱 이름 일괄 변경 — apps/<old>/, packages/ui/ui-apps/ui-<old>/ 두 디렉토리 이동 + " +
540
+ "@workspace/ui-<old> / apps/<old> / --filter <old> / --app <old> / cd apps/<old> 패턴 치환 + " +
541
+ "사용자 코드, package.json name, tsconfig paths, Dockerfile WORKDIR, sh-ui.config.json aliases, README, .github/workflows 모두 자동 갱신. " +
542
+ "monorepo 전용 (pnpm-workspace.yaml 필수). dryRun 으로 변경 매트릭스 미리보기 가능. " +
543
+ "false-positive 방지를 위해 bare 단어(예: 'web')는 절대 치환하지 않고 컨텍스트(슬래시·공백·따옴표) 로 묶인 패턴만 처리.",
544
+ inputSchema: {
545
+ oldName: z.string().min(1).describe("현재 앱 이름 (예: 'web'). apps/<old>/ 가 존재해야 함."),
546
+ newName: z.string().min(1).describe("새 앱 이름 (예: 'dashboard'). 영숫자 + 하이픈만 허용."),
547
+ cwd: z.string().optional().describe("monorepo 루트 디렉토리. 기본 process.cwd()"),
548
+ dryRun: z.boolean().optional().describe("변경 매트릭스만 반환, 실제 파일 변경 X. 기본 false"),
549
+ skipInstall: z.boolean().optional().describe("마지막 pnpm install 생략. 기본 false"),
550
+ },
551
+ },
552
+ async (input) => {
553
+ const result = await captureConsole(() =>
554
+ renameApp({
555
+ cwd: resolveCwd(input),
556
+ oldName: input.oldName,
557
+ newName: input.newName,
558
+ yes: true, // MCP 컨텍스트는 비대화형 — 호출자(AI) 가 사용자 확인을 이미 받았다고 가정
559
+ dryRun: input.dryRun === true,
560
+ skipInstall: input.skipInstall === true,
561
+ }),
562
+ );
563
+ return textResult(result || "✓ rename-app 완료");
564
+ },
565
+ );
566
+
527
567
  // 변경 내역 조회 — 보너스: 사용자가 "최근 변경 알려줘" 류 요청 시
528
568
  server.registerTool(
529
569
  "sh_ui_get_changelog",
@@ -0,0 +1,321 @@
1
+ // monorepo 의 앱 이름 (apps/<old>/ + packages/ui/ui-apps/ui-<old>/) 을 일괄 변경.
2
+ //
3
+ // 디렉토리 이동 + 정해진 6개 패턴 치환을 자동화. 사용자가 손으로
4
+ // 6~10 군데 (package.json 이름, tsconfig paths, Dockerfile WORKDIR,
5
+ // next.config transpilePackages, sh-ui.config aliases, README, ...) 를
6
+ // 일일이 갈아엎지 않도록.
7
+ //
8
+ // false-positive 방지를 위해 bare 단어 (`web`) 는 절대 치환하지 않고,
9
+ // 컨텍스트(슬래시·따옴표·`--filter ` 공백) 로 묶인 패턴만 치환한다.
10
+ //
11
+ // 사용:
12
+ // sh-ui rename-app web dashboard 대화형 확인 후 실행
13
+ // sh-ui rename-app web dashboard --yes 비대화형
14
+ // sh-ui rename-app web dashboard --dry-run 변경 매트릭스만 출력
15
+
16
+ import { existsSync, statSync } from "node:fs";
17
+ import { readdir, readFile, rename, writeFile } from "node:fs/promises";
18
+ import { spawn } from "node:child_process";
19
+ import { join, relative, resolve } from "node:path";
20
+ import { createInterface } from "node:readline/promises";
21
+
22
+ /** 텍스트 파일로 처리할 확장자 (전체 파일을 읽어 치환). */
23
+ const TEXT_EXT = new Set([
24
+ ".ts", ".tsx", ".js", ".mjs", ".cjs",
25
+ ".json", ".jsonc",
26
+ ".css", ".scss",
27
+ ".md", ".mdx",
28
+ ".yml", ".yaml",
29
+ ".html", ".env", ".sh",
30
+ ]);
31
+
32
+ /** 확장자 없는 텍스트 파일들 (Dockerfile 등) — 정확한 파일명으로 매칭. */
33
+ const TEXT_BASENAMES = new Set([
34
+ "Dockerfile",
35
+ "Dockerfile.dev",
36
+ "Dockerfile.prod",
37
+ ".env",
38
+ ".env.example",
39
+ ".env.local",
40
+ ".gitignore",
41
+ ".dockerignore",
42
+ ]);
43
+
44
+ /** 스캔하지 않을 디렉토리 — 빌드 산출물·캐시·의존성. */
45
+ const SKIP_DIRS = new Set([
46
+ "node_modules",
47
+ ".next",
48
+ ".turbo",
49
+ ".git",
50
+ "dist",
51
+ "build",
52
+ ".cache",
53
+ ]);
54
+
55
+ /** 모노레포 루트에서 추가로 스캔할 파일/디렉토리 (디렉토리 이동 외). */
56
+ const ROOT_TARGETS = [
57
+ "package.json",
58
+ "README.md",
59
+ "turbo.json",
60
+ "docker-compose.yml",
61
+ "docker-compose.yaml",
62
+ "vercel.json",
63
+ ".github", // 디렉토리 — 안의 yml/yaml 파일들 스캔
64
+ ];
65
+
66
+ function buildPatterns(oldName, newName) {
67
+ // false-positive 방지 위해 컨텍스트(/, ", ', 공백) 로 묶인 패턴만.
68
+ // 순서는 더 긴 패턴부터 (안 그러면 prefix 매치되어 부정확).
69
+ return [
70
+ [`@workspace/ui-${oldName}`, `@workspace/ui-${newName}`],
71
+ [`ui-apps/ui-${oldName}`, `ui-apps/ui-${newName}`],
72
+ [`packages/ui/ui-apps/ui-${oldName}`, `packages/ui/ui-apps/ui-${newName}`],
73
+ [`apps/${oldName}/`, `apps/${newName}/`],
74
+ [`apps/${oldName}"`, `apps/${newName}"`],
75
+ [`apps/${oldName}'`, `apps/${newName}'`],
76
+ [`apps/${oldName}\n`, `apps/${newName}\n`],
77
+ [`apps/${oldName} `, `apps/${newName} `],
78
+ [`--filter ${oldName} `, `--filter ${newName} `],
79
+ [`--filter ${oldName}\n`, `--filter ${newName}\n`],
80
+ [`--filter ${oldName}"`, `--filter ${newName}"`],
81
+ [`--filter ${oldName}'`, `--filter ${newName}'`],
82
+ [`--filter ${oldName}\``, `--filter ${newName}\``],
83
+ [`--app ${oldName} `, `--app ${newName} `],
84
+ [`--app ${oldName}\n`, `--app ${newName}\n`],
85
+ [`--app ${oldName}"`, `--app ${newName}"`],
86
+ [`--app ${oldName}'`, `--app ${newName}'`],
87
+ [`--app ${oldName}\``, `--app ${newName}\``],
88
+ ];
89
+ }
90
+
91
+ /** package.json 내부의 정확한 name 필드만 따로 처리 (정규식 1개). */
92
+ function rewritePackageJsonName(content, oldName, newName) {
93
+ // "name": "old" 또는 "name": "@workspace/ui-old" 두 케이스.
94
+ // app 자체의 name 은 정확히 oldName 이어야 매칭.
95
+ return content.replace(
96
+ new RegExp(`"name"\\s*:\\s*"${escapeRegex(oldName)}"`),
97
+ `"name": "${newName}"`,
98
+ );
99
+ }
100
+
101
+ function escapeRegex(s) {
102
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ }
104
+
105
+ /** 한 파일의 텍스트 치환. 변경 횟수 + 새 내용 반환. */
106
+ function applyPatternsToContent(content, patterns, oldName, newName) {
107
+ let next = content;
108
+ let hits = 0;
109
+ for (const [from, to] of patterns) {
110
+ if (next.includes(from)) {
111
+ const before = next;
112
+ next = next.split(from).join(to);
113
+ hits += (before.length - next.length) / (from.length - to.length) || 0;
114
+ }
115
+ }
116
+ // package.json name 필드도 (별도 처리 — 패턴에 안 잡힘)
117
+ const afterPkg = rewritePackageJsonName(next, oldName, newName);
118
+ if (afterPkg !== next) {
119
+ hits += 1;
120
+ next = afterPkg;
121
+ }
122
+ return { content: next, hits };
123
+ }
124
+
125
+ function isTextFile(filePath) {
126
+ const base = filePath.split("/").pop();
127
+ if (!base) return false;
128
+ if (TEXT_BASENAMES.has(base)) return true;
129
+ const dot = base.lastIndexOf(".");
130
+ if (dot < 0) return false;
131
+ return TEXT_EXT.has(base.slice(dot));
132
+ }
133
+
134
+ /** 디렉토리 재귀 순회. SKIP_DIRS 는 건너뜀. 텍스트 파일 경로 yield. */
135
+ async function* walkTextFiles(dir) {
136
+ let entries;
137
+ try {
138
+ entries = await readdir(dir, { withFileTypes: true });
139
+ } catch {
140
+ return;
141
+ }
142
+ for (const entry of entries) {
143
+ const full = join(dir, entry.name);
144
+ if (entry.isDirectory()) {
145
+ if (SKIP_DIRS.has(entry.name)) continue;
146
+ yield* walkTextFiles(full);
147
+ } else if (entry.isFile()) {
148
+ if (isTextFile(full)) yield full;
149
+ }
150
+ }
151
+ }
152
+
153
+ /** Symlink 안 따라가는 단순 안전한 fs.rename. cross-device 시 fallback 없음 (모노레포 내부). */
154
+ async function moveDir(from, to) {
155
+ await rename(from, to);
156
+ }
157
+
158
+ async function confirm(message) {
159
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
160
+ try {
161
+ const ans = await rl.question(`${message} [y/N] `);
162
+ return /^y(es)?$/i.test(ans.trim());
163
+ } finally {
164
+ rl.close();
165
+ }
166
+ }
167
+
168
+ function runPnpmInstall(cwd) {
169
+ console.log("\npnpm install — workspace 링크 재생성");
170
+ return new Promise((ok, bad) => {
171
+ const child = spawn("pnpm", ["install"], { cwd, stdio: "inherit", shell: process.platform === "win32" });
172
+ child.on("error", bad);
173
+ child.on("exit", (code) =>
174
+ code === 0 ? ok() : bad(new Error(`pnpm install exited with code ${code}`)),
175
+ );
176
+ });
177
+ }
178
+
179
+ /**
180
+ * @param {object} opts
181
+ * @param {string} opts.cwd
182
+ * @param {string} opts.oldName
183
+ * @param {string} opts.newName
184
+ * @param {boolean} [opts.yes] — 대화형 확인 생략
185
+ * @param {boolean} [opts.dryRun] — 변경 매트릭스만 출력, 실제 변경 X
186
+ * @param {boolean} [opts.skipInstall] — 마지막 pnpm install 생략
187
+ * @returns {Promise<{moves: Array<[string,string]>, edits: Array<{path:string, hits:number}>}>}
188
+ */
189
+ export async function renameApp({ cwd, oldName, newName, yes = false, dryRun = false, skipInstall = false }) {
190
+ const root = resolve(cwd);
191
+
192
+ // ─── 환경 검증 ───
193
+ if (!existsSync(join(root, "pnpm-workspace.yaml"))) {
194
+ throw new Error(
195
+ `${root} 가 pnpm 모노레포 루트가 아닙니다 (pnpm-workspace.yaml 없음). rename-app 은 모노레포 전용입니다.`,
196
+ );
197
+ }
198
+ if (!oldName || !newName) {
199
+ throw new Error("oldName / newName 둘 다 필요합니다.");
200
+ }
201
+ if (oldName === newName) {
202
+ throw new Error(`oldName 과 newName 이 같습니다 (${oldName}).`);
203
+ }
204
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(newName)) {
205
+ throw new Error(`newName 은 영숫자 + 하이픈만 허용 (받은 값: ${newName}).`);
206
+ }
207
+
208
+ const oldAppDir = join(root, "apps", oldName);
209
+ const newAppDir = join(root, "apps", newName);
210
+ const oldUiDir = join(root, "packages", "ui", "ui-apps", `ui-${oldName}`);
211
+ const newUiDir = join(root, "packages", "ui", "ui-apps", `ui-${newName}`);
212
+
213
+ if (!existsSync(oldAppDir)) throw new Error(`apps/${oldName} 가 존재하지 않습니다.`);
214
+ if (existsSync(newAppDir)) throw new Error(`apps/${newName} 가 이미 존재합니다.`);
215
+
216
+ const hasUiPkg = existsSync(oldUiDir);
217
+ if (hasUiPkg && existsSync(newUiDir)) {
218
+ throw new Error(`packages/ui/ui-apps/ui-${newName} 가 이미 존재합니다.`);
219
+ }
220
+
221
+ const patterns = buildPatterns(oldName, newName);
222
+
223
+ // ─── 변경 매트릭스 계산 (디렉토리 이동 전 — 원본 경로 기준 스캔) ───
224
+ const moves = [[oldAppDir, newAppDir]];
225
+ if (hasUiPkg) moves.push([oldUiDir, newUiDir]);
226
+
227
+ const edits = [];
228
+
229
+ async function scanFile(absPath, displayPath) {
230
+ const raw = await readFile(absPath, "utf-8");
231
+ const { content, hits } = applyPatternsToContent(raw, patterns, oldName, newName);
232
+ if (hits > 0 && content !== raw) {
233
+ edits.push({ path: displayPath, abs: absPath, content, hits });
234
+ }
235
+ }
236
+
237
+ // 두 패키지 디렉토리 (원본 경로) 안 텍스트 파일들
238
+ for await (const file of walkTextFiles(oldAppDir)) {
239
+ await scanFile(file, relative(root, file));
240
+ }
241
+ if (hasUiPkg) {
242
+ for await (const file of walkTextFiles(oldUiDir)) {
243
+ await scanFile(file, relative(root, file));
244
+ }
245
+ }
246
+
247
+ // 루트 추가 타겟
248
+ for (const target of ROOT_TARGETS) {
249
+ const abs = join(root, target);
250
+ if (!existsSync(abs)) continue;
251
+ const stat = statSync(abs);
252
+ if (stat.isDirectory()) {
253
+ for await (const file of walkTextFiles(abs)) {
254
+ await scanFile(file, relative(root, file));
255
+ }
256
+ } else if (stat.isFile() && isTextFile(abs)) {
257
+ await scanFile(abs, relative(root, abs));
258
+ }
259
+ }
260
+
261
+ // ─── 미리보기 출력 ───
262
+ console.log(`\n📦 ${oldName} → ${newName}`);
263
+ console.log("\n디렉토리 이동:");
264
+ for (const [from, to] of moves) {
265
+ console.log(` ${relative(root, from)} → ${relative(root, to)}`);
266
+ }
267
+ console.log(`\n파일 내용 수정 (${edits.length}개):`);
268
+ for (const e of edits) {
269
+ console.log(` ${e.path} (${e.hits}곳)`);
270
+ }
271
+ if (edits.length === 0) {
272
+ console.log(" (없음)");
273
+ }
274
+
275
+ if (dryRun) {
276
+ console.log("\n--dry-run 모드 — 실제 변경하지 않았습니다.");
277
+ return { moves, edits: edits.map(({ path, hits }) => ({ path, hits })) };
278
+ }
279
+
280
+ // ─── 확인 ───
281
+ if (!yes) {
282
+ const ok = await confirm("\n계속 진행할까요?");
283
+ if (!ok) {
284
+ console.log("취소했습니다.");
285
+ return { moves: [], edits: [] };
286
+ }
287
+ }
288
+
289
+ // ─── 디렉토리 이동 먼저 ───
290
+ for (const [from, to] of moves) {
291
+ await moveDir(from, to);
292
+ }
293
+
294
+ // ─── 파일 내용 수정 (이동된 새 경로로 매핑) ───
295
+ for (const edit of edits) {
296
+ let newAbs = edit.abs;
297
+ if (newAbs.startsWith(oldAppDir + "/")) {
298
+ newAbs = newAppDir + newAbs.slice(oldAppDir.length);
299
+ } else if (hasUiPkg && newAbs.startsWith(oldUiDir + "/")) {
300
+ newAbs = newUiDir + newAbs.slice(oldUiDir.length);
301
+ }
302
+ await writeFile(newAbs, edit.content, "utf-8");
303
+ }
304
+
305
+ console.log(`\n✓ ${moves.length}개 디렉토리 이동, ${edits.length}개 파일 수정`);
306
+
307
+ // ─── pnpm install 로 lockfile 재생성 ───
308
+ if (!skipInstall) {
309
+ try {
310
+ await runPnpmInstall(root);
311
+ console.log("✓ pnpm install 완료 — workspace 링크 갱신됨");
312
+ } catch (e) {
313
+ console.warn(`⚠ pnpm install 실패: ${e.message}`);
314
+ console.warn(" 수동으로 'pnpm install' 을 실행해 lockfile 을 재생성하세요.");
315
+ }
316
+ } else {
317
+ console.log("\n⚠ --skip-install — lockfile 재생성을 위해 'pnpm install' 을 직접 실행하세요.");
318
+ }
319
+
320
+ return { moves, edits: edits.map(({ path, hits }) => ({ path, hits })) };
321
+ }