sh-ui-cli 0.22.2 → 0.23.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.
Files changed (111) hide show
  1. package/README.md +19 -2
  2. package/bin/sh-ui.mjs +7 -0
  3. package/data/changelog/versions.json +14 -0
  4. package/package.json +13 -2
  5. package/src/create/cli-args.js +63 -0
  6. package/src/create/generator.js +542 -0
  7. package/src/create/index.mjs +68 -0
  8. package/src/create/plugins/index.js +17 -0
  9. package/src/create/plugins/nextIntl.js +197 -0
  10. package/src/create/plugins/sentry.js +689 -0
  11. package/src/create/theme/decode.js +66 -0
  12. package/src/create/theme/inject.js +111 -0
  13. package/src/mcp.mjs +81 -27
  14. package/src/paths.mjs +5 -0
  15. package/templates/flutter-standalone/README.md +34 -0
  16. package/templates/flutter-standalone/analysis_options.yaml +1 -0
  17. package/templates/flutter-standalone/lib/main.dart +103 -0
  18. package/templates/flutter-standalone/lib/sh_ui/foundation/sh_ui_tokens.dart +389 -0
  19. package/templates/flutter-standalone/pubspec.yaml +20 -0
  20. package/templates/flutter-standalone/sh-ui.config.json +15 -0
  21. package/templates/monorepo/.dockerignore +7 -0
  22. package/templates/monorepo/.eslintrc.js +8 -0
  23. package/templates/monorepo/.prettierrc +17 -0
  24. package/templates/monorepo/README.md +103 -0
  25. package/templates/monorepo/package.json +24 -0
  26. package/templates/monorepo/packages/eslint-config/base.js +31 -0
  27. package/templates/monorepo/packages/eslint-config/fsd.js +119 -0
  28. package/templates/monorepo/packages/eslint-config/next.js +65 -0
  29. package/templates/monorepo/packages/eslint-config/package.json +31 -0
  30. package/templates/monorepo/packages/eslint-config/react-internal.js +36 -0
  31. package/templates/monorepo/packages/typescript-config/base.json +20 -0
  32. package/templates/monorepo/packages/typescript-config/nextjs.json +13 -0
  33. package/templates/monorepo/packages/typescript-config/package.json +5 -0
  34. package/templates/monorepo/packages/typescript-config/react-library.json +8 -0
  35. package/templates/monorepo/packages/ui/ui-apps/.gitkeep +0 -0
  36. package/templates/monorepo/packages/ui/ui-core/eslint.config.js +3 -0
  37. package/templates/monorepo/packages/ui/ui-core/package.json +23 -0
  38. package/templates/monorepo/packages/ui/ui-core/src/lib/utils.ts +6 -0
  39. package/templates/monorepo/packages/ui/ui-core/tsconfig.json +11 -0
  40. package/templates/monorepo/pnpm-workspace.yaml +5 -0
  41. package/templates/monorepo/tsconfig.json +3 -0
  42. package/templates/monorepo/turbo.json +26 -0
  43. package/templates/nextjs-app/.env.example +2 -0
  44. package/templates/nextjs-app/Dockerfile +11 -0
  45. package/templates/nextjs-app/README.md +64 -0
  46. package/templates/nextjs-app/app/layout.tsx +22 -0
  47. package/templates/nextjs-app/app/page.tsx +7 -0
  48. package/templates/nextjs-app/eslint.config.js +10 -0
  49. package/templates/nextjs-app/next.config.ts +12 -0
  50. package/templates/nextjs-app/package.json +45 -0
  51. package/templates/nextjs-app/postcss.config.mjs +1 -0
  52. package/templates/nextjs-app/src/app/layouts/.gitkeep +0 -0
  53. package/templates/nextjs-app/src/app/providers/GlobalProvider/index.tsx +23 -0
  54. package/templates/nextjs-app/src/app/providers/index.tsx +1 -0
  55. package/templates/nextjs-app/src/app/providers/tanstack/QueryClientProvider.tsx +27 -0
  56. package/templates/nextjs-app/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  57. package/templates/nextjs-app/src/app/providers/theme/ThemeProviders.tsx +12 -0
  58. package/templates/nextjs-app/src/entities/.gitkeep +0 -0
  59. package/templates/nextjs-app/src/features/.gitkeep +0 -0
  60. package/templates/nextjs-app/src/shared/api/.gitkeep +0 -0
  61. package/templates/nextjs-app/src/shared/config/.gitkeep +0 -0
  62. package/templates/nextjs-app/src/shared/hooks/.gitkeep +0 -0
  63. package/templates/nextjs-app/src/shared/lib/.gitkeep +0 -0
  64. package/templates/nextjs-app/src/shared/model/.gitkeep +0 -0
  65. package/templates/nextjs-app/src/shared/ui/.gitkeep +0 -0
  66. package/templates/nextjs-app/src/views/.gitkeep +0 -0
  67. package/templates/nextjs-app/src/widgets/.gitkeep +0 -0
  68. package/templates/nextjs-app/tsconfig.json +23 -0
  69. package/templates/nextjs-app/vitest.config.ts +15 -0
  70. package/templates/nextjs-app/vitest.setup.ts +1 -0
  71. package/templates/nextjs-standalone/.env.example +2 -0
  72. package/templates/nextjs-standalone/.prettierrc +17 -0
  73. package/templates/nextjs-standalone/README.md +77 -0
  74. package/templates/nextjs-standalone/app/globals.css +33 -0
  75. package/templates/nextjs-standalone/app/layout.tsx +22 -0
  76. package/templates/nextjs-standalone/app/page.tsx +7 -0
  77. package/templates/nextjs-standalone/eslint.config.js +162 -0
  78. package/templates/nextjs-standalone/next.config.ts +10 -0
  79. package/templates/nextjs-standalone/package.json +66 -0
  80. package/templates/nextjs-standalone/postcss.config.mjs +5 -0
  81. package/templates/nextjs-standalone/sh-ui.config.json +19 -0
  82. package/templates/nextjs-standalone/src/app/layouts/.gitkeep +0 -0
  83. package/templates/nextjs-standalone/src/app/providers/GlobalProvider/index.tsx +23 -0
  84. package/templates/nextjs-standalone/src/app/providers/index.tsx +1 -0
  85. package/templates/nextjs-standalone/src/app/providers/tanstack/QueryClientProvider.tsx +27 -0
  86. package/templates/nextjs-standalone/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  87. package/templates/nextjs-standalone/src/app/providers/theme/ThemeProviders.tsx +12 -0
  88. package/templates/nextjs-standalone/src/entities/.gitkeep +0 -0
  89. package/templates/nextjs-standalone/src/features/.gitkeep +0 -0
  90. package/templates/nextjs-standalone/src/shared/api/.gitkeep +0 -0
  91. package/templates/nextjs-standalone/src/shared/config/.gitkeep +0 -0
  92. package/templates/nextjs-standalone/src/shared/hooks/.gitkeep +0 -0
  93. package/templates/nextjs-standalone/src/shared/lib/utils.ts +6 -0
  94. package/templates/nextjs-standalone/src/shared/model/.gitkeep +0 -0
  95. package/templates/nextjs-standalone/src/shared/styles/tokens.css +95 -0
  96. package/templates/nextjs-standalone/src/shared/ui/.gitkeep +0 -0
  97. package/templates/nextjs-standalone/src/views/.gitkeep +0 -0
  98. package/templates/nextjs-standalone/src/widgets/.gitkeep +0 -0
  99. package/templates/nextjs-standalone/tsconfig.json +39 -0
  100. package/templates/nextjs-standalone/vitest.config.ts +15 -0
  101. package/templates/nextjs-standalone/vitest.setup.ts +1 -0
  102. package/templates/ui-app-template/eslint.config.js +3 -0
  103. package/templates/ui-app-template/package.json +38 -0
  104. package/templates/ui-app-template/postcss.config.mjs +5 -0
  105. package/templates/ui-app-template/sh-ui.config.json +14 -0
  106. package/templates/ui-app-template/src/components/.gitkeep +0 -0
  107. package/templates/ui-app-template/src/hooks/.gitkeep +0 -0
  108. package/templates/ui-app-template/src/lib/.gitkeep +0 -0
  109. package/templates/ui-app-template/src/styles/globals.css +37 -0
  110. package/templates/ui-app-template/src/styles/tokens.css +95 -0
  111. package/templates/ui-app-template/tsconfig.json +11 -0
@@ -0,0 +1,542 @@
1
+ import { input, select, checkbox, confirm } from '@inquirer/prompts';
2
+ import { execSync } from 'node:child_process';
3
+ import fs from 'fs-extra';
4
+ import path from 'node:path';
5
+ import { getPluginChoices, getPluginsByNames } from './plugins/index.js';
6
+ import { decodeTheme } from './theme/decode.js';
7
+ import {
8
+ replaceSection,
9
+ buildCssColorsBlock,
10
+ buildCssRadiusBlock,
11
+ buildDartColorsBlock,
12
+ buildDartRadiusBlock,
13
+ } from './theme/inject.js';
14
+ import { getTemplatesRoot } from '../paths.mjs';
15
+
16
+ const TEMPLATES_DIR = getTemplatesRoot();
17
+
18
+ // ─── Create new project ───
19
+
20
+ // 비대화형 환경(TTY 없음 — 에이전트, CI, 파이프) 에서는 prompt 가 멈추므로
21
+ // 누락된 필수 인자가 있으면 즉시 에러로 종료한다. 호출 시점에 평가해 테스트가
22
+ // `process.stdin.isTTY = true` 로 prompt 흐름을 그대로 검증할 수 있게 한다.
23
+ function assertNoTtyFlag(value, flagLabel) {
24
+ if (value === undefined || value === null) {
25
+ throw new Error(
26
+ `비대화형 환경(TTY 없음)에서는 ${flagLabel} 가 필요합니다. ` +
27
+ `sh-ui create --help 참고.`,
28
+ );
29
+ }
30
+ }
31
+
32
+ export async function createProject(options = {}) {
33
+ if (!process.stdin.isTTY) {
34
+ assertNoTtyFlag(options.name, '<project-name> (positional)');
35
+ assertNoTtyFlag(options.platform, '--platform');
36
+ if (options.platform === 'next') {
37
+ assertNoTtyFlag(options.structure, '--structure');
38
+ }
39
+ }
40
+
41
+ const projectName = options.name ?? await input({
42
+ message: '프로젝트 이름:',
43
+ default: 'my-app',
44
+ });
45
+
46
+ const platform = options.platform ?? await select({
47
+ message: '플랫폼:',
48
+ choices: [
49
+ { name: 'Next.js', value: 'next' },
50
+ { name: 'Flutter', value: 'flutter' },
51
+ ],
52
+ });
53
+
54
+ const theme = options.theme ? decodeTheme(options.theme) : null;
55
+
56
+ const targetDir = path.resolve(process.cwd(), projectName);
57
+
58
+ if (await fs.pathExists(targetDir)) {
59
+ if (options.yes) {
60
+ await fs.remove(targetDir);
61
+ } else {
62
+ const overwrite = await confirm({
63
+ message: `${projectName} 디렉토리가 이미 존재합니다. 덮어쓸까요?`,
64
+ default: false,
65
+ });
66
+ if (!overwrite) {
67
+ console.log('취소되었습니다.');
68
+ return;
69
+ }
70
+ await fs.remove(targetDir);
71
+ }
72
+ }
73
+
74
+ if (platform === 'flutter') {
75
+ await generateFlutter(targetDir, projectName, theme);
76
+ console.log(`\n✅ ${projectName} Flutter 프로젝트가 생성되었습니다!`);
77
+ console.log(`\n cd ${projectName}`);
78
+ console.log(' flutter pub get');
79
+ console.log(' flutter run\n');
80
+ return;
81
+ }
82
+
83
+ // platform === 'next' 경로
84
+ const projectType = options.structure ?? await select({
85
+ message: '프로젝트 구조:',
86
+ choices: [
87
+ { name: '단독 (Next.js standalone)', value: 'standalone' },
88
+ { name: '모노레포 (Turborepo + pnpm)', value: 'monorepo' },
89
+ ],
90
+ });
91
+
92
+ // plugins 는 미지정시 기본 빈 배열 — prompt 띄우지 않는다.
93
+ // (플러그인을 쓰려면 명시적으로 --plugins sentry,next-intl 사용)
94
+ const selectedPluginNames = options.plugins ?? [];
95
+
96
+ const plugins = getPluginsByNames(selectedPluginNames);
97
+ plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
98
+
99
+ if (projectType === 'standalone') {
100
+ await generateStandalone(targetDir, projectName, plugins, theme);
101
+ } else {
102
+ await generateMonorepo(targetDir, projectName, plugins, { yes: options.yes, theme });
103
+ }
104
+
105
+ console.log(`\n✅ ${projectName} 프로젝트가 생성되었습니다!`);
106
+ console.log(`\n cd ${projectName}`);
107
+ console.log(' pnpm install');
108
+ console.log(' pnpm dev\n');
109
+ }
110
+
111
+ // ─── Add app to existing monorepo ───
112
+
113
+ export async function addApp() {
114
+ const isMonorepo = await fs.pathExists(
115
+ path.resolve(process.cwd(), 'pnpm-workspace.yaml'),
116
+ );
117
+ if (!isMonorepo) {
118
+ console.log('❌ 현재 디렉토리가 모노레포가 아닙니다. pnpm-workspace.yaml이 없습니다.');
119
+ return;
120
+ }
121
+
122
+ const appName = await input({
123
+ message: '앱 이름:',
124
+ default: 'web',
125
+ });
126
+
127
+ const port = await input({
128
+ message: '포트 번호:',
129
+ default: '3000',
130
+ });
131
+
132
+ const selectedPlugins = await checkbox({
133
+ message: '추가 기능 선택 (Space로 선택):',
134
+ choices: getPluginChoices(),
135
+ });
136
+
137
+ const plugins = getPluginsByNames(selectedPlugins);
138
+ plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
139
+
140
+ const appsDir = path.resolve(process.cwd(), 'apps', appName);
141
+
142
+ if (await fs.pathExists(appsDir)) {
143
+ console.log(`❌ apps/${appName} 디렉토리가 이미 존재합니다.`);
144
+ return;
145
+ }
146
+
147
+ await generateApp(appsDir, appName, port, plugins);
148
+
149
+ console.log(`\n✅ apps/${appName} 이 추가되었습니다!`);
150
+ console.log('\n pnpm install');
151
+ console.log(` pnpm --filter ${appName} dev\n`);
152
+ }
153
+
154
+ // ─── Add component to ui packages ───
155
+
156
+ export async function addComponent(componentName, appName) {
157
+ const cwd = process.cwd();
158
+ const isMonorepo = await fs.pathExists(path.join(cwd, 'pnpm-workspace.yaml'));
159
+
160
+ if (!isMonorepo) {
161
+ // Standalone: 현재 디렉토리에서 바로 실행
162
+ if (!componentName) {
163
+ componentName = await input({ message: '컴포넌트 이름:' });
164
+ }
165
+ console.log(`\n📦 sh-ui 컴포넌트 추가: ${componentName}`);
166
+ execSync(`npx sh-ui add ${componentName}`, { cwd, stdio: 'inherit' });
167
+ console.log(`\n✅ ${componentName} 추가 완료!`);
168
+ return;
169
+ }
170
+
171
+ // Monorepo: packages/ui/ui-apps/* 에서 실행
172
+ const uiAppsDir = path.join(cwd, 'packages', 'ui', 'ui-apps');
173
+ if (!(await fs.pathExists(uiAppsDir))) {
174
+ console.log('❌ packages/ui/ui-apps/ 디렉토리가 없습니다.');
175
+ return;
176
+ }
177
+
178
+ const entries = await fs.readdir(uiAppsDir, { withFileTypes: true });
179
+ const uiPackages = entries
180
+ .filter((e) => e.isDirectory() && e.name.startsWith('ui-') && e.name !== 'ui-app-template')
181
+ .map((e) => e.name);
182
+
183
+ if (uiPackages.length === 0) {
184
+ console.log('❌ ui-* 패키지가 없습니다.');
185
+ return;
186
+ }
187
+
188
+ if (!componentName) {
189
+ componentName = await input({ message: '컴포넌트 이름:' });
190
+ }
191
+
192
+ let targets;
193
+ if (appName) {
194
+ const pkgName = `ui-${appName}`;
195
+ if (!uiPackages.includes(pkgName)) {
196
+ console.log(`❌ packages/ui/ui-apps/${pkgName} 이 존재하지 않습니다.`);
197
+ console.log(` 사용 가능: ${uiPackages.join(', ')}`);
198
+ return;
199
+ }
200
+ targets = [pkgName];
201
+ } else {
202
+ const choice = await select({
203
+ message: '어디에 추가할까요?',
204
+ choices: [
205
+ { name: '모든 ui 패키지', value: 'all' },
206
+ ...uiPackages.map((name) => ({ name: `packages/ui/ui-apps/${name}`, value: name })),
207
+ ],
208
+ });
209
+ targets = choice === 'all' ? uiPackages : [choice];
210
+ }
211
+
212
+ for (const pkg of targets) {
213
+ const pkgDir = path.join(uiAppsDir, pkg);
214
+ console.log(`\n📦 packages/ui/ui-apps/${pkg}에 ${componentName} 추가 중...`);
215
+ try {
216
+ execSync(`npx sh-ui add ${componentName}`, { cwd: pkgDir, stdio: 'inherit' });
217
+ console.log(`✅ packages/ui/ui-apps/${pkg} 완료`);
218
+ } catch (error) {
219
+ console.log(`❌ packages/ui/ui-apps/${pkg} 실패: ${error.message}`);
220
+ }
221
+ }
222
+
223
+ console.log('\n✅ 컴포넌트 추가 완료!');
224
+ }
225
+
226
+ // ─── Generators ───
227
+
228
+ async function generateFlutter(targetDir, projectName, theme) {
229
+ await fs.copy(path.join(TEMPLATES_DIR, 'flutter-standalone'), targetDir);
230
+ await replaceInAllFiles(targetDir, '{{project_name}}', projectName);
231
+ await injectDartTheme(targetDir, theme);
232
+ }
233
+
234
+ async function generateStandalone(targetDir, projectName, plugins, theme) {
235
+ await fs.copy(path.join(TEMPLATES_DIR, 'nextjs-standalone'), targetDir);
236
+
237
+ // Update package.json
238
+ const pkgPath = path.join(targetDir, 'package.json');
239
+ const pkg = await fs.readJson(pkgPath);
240
+ pkg.name = projectName;
241
+ for (const plugin of plugins) {
242
+ if (plugin.dependencies) {
243
+ Object.assign(pkg.dependencies, plugin.dependencies);
244
+ }
245
+ if (plugin.devDependencies) {
246
+ Object.assign(pkg.devDependencies, plugin.devDependencies);
247
+ }
248
+ }
249
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
250
+
251
+ await writeNextConfig(targetDir, plugins, { isMonorepo: false });
252
+ await appendEnvVars(path.join(targetDir, '.env.example'), plugins);
253
+ await writePluginFiles(targetDir, plugins);
254
+ await composeProviders(targetDir, plugins);
255
+ await applyTransforms(targetDir, plugins);
256
+ await injectCssTheme(targetDir, theme);
257
+ }
258
+
259
+ async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme } = {}) {
260
+ await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
261
+
262
+ // Update root package.json
263
+ const rootPkgPath = path.join(targetDir, 'package.json');
264
+ const rootPkg = await fs.readJson(rootPkgPath);
265
+ rootPkg.name = projectName;
266
+ await fs.writeJson(rootPkgPath, rootPkg, { spaces: 2 });
267
+
268
+ // Update turbo.json
269
+ const turboPath = path.join(targetDir, 'turbo.json');
270
+ const turbo = await fs.readJson(turboPath);
271
+ for (const plugin of plugins) {
272
+ if (plugin.turboEnvVars) {
273
+ turbo.globalEnv.push(...plugin.turboEnvVars);
274
+ }
275
+ }
276
+ await fs.writeJson(turboPath, turbo, { spaces: 2 });
277
+
278
+ // Create first app
279
+ const appName = yes ? 'web' : await input({
280
+ message: '첫 번째 앱 이름:',
281
+ default: 'web',
282
+ });
283
+
284
+ const port = yes ? '3000' : await input({
285
+ message: '포트 번호:',
286
+ default: '3000',
287
+ });
288
+
289
+ const appsDir = path.join(targetDir, 'apps', appName);
290
+ await generateApp(appsDir, appName, port, plugins);
291
+ const uiAppDir = path.join(targetDir, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
292
+ await injectCssTheme(uiAppDir, theme);
293
+ }
294
+
295
+ async function generateApp(targetDir, appName, port, plugins) {
296
+ await fs.copy(path.join(TEMPLATES_DIR, 'nextjs-app'), targetDir);
297
+
298
+ // Replace ui-app-name placeholder with actual app name in all files
299
+ await replaceInAllFiles(targetDir, 'ui-app-name', `ui-${appName}`);
300
+ await replaceInAllFiles(targetDir, 'app-name', appName);
301
+
302
+ // Update package.json
303
+ const pkgPath = path.join(targetDir, 'package.json');
304
+ const pkg = await fs.readJson(pkgPath);
305
+ pkg.name = appName;
306
+ pkg.scripts.dev = `next dev -p ${port} --turbopack`;
307
+ for (const plugin of plugins) {
308
+ if (plugin.dependencies) {
309
+ Object.assign(pkg.dependencies, plugin.dependencies);
310
+ }
311
+ if (plugin.devDependencies) {
312
+ Object.assign(pkg.devDependencies, plugin.devDependencies);
313
+ }
314
+ }
315
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
316
+
317
+ await writeNextConfig(targetDir, plugins, { isMonorepo: true, appName });
318
+
319
+ // Update Dockerfile
320
+ const dockerPath = path.join(targetDir, 'Dockerfile');
321
+ if (await fs.pathExists(dockerPath)) {
322
+ let dockerfile = await fs.readFile(dockerPath, 'utf-8');
323
+ dockerfile = dockerfile.replace(/EXPOSE \d+/, `EXPOSE ${port}`);
324
+ dockerfile = dockerfile.replace(/ENV PORT=\d+/, `ENV PORT=${port}`);
325
+ await fs.writeFile(dockerPath, dockerfile);
326
+ }
327
+
328
+ // Create packages/ui/ui-apps/ui-{appName}/ from ui-app-template
329
+ const monorepoRoot = path.resolve(targetDir, '..', '..');
330
+ const uiPkgDir = path.join(monorepoRoot, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
331
+ if (!(await fs.pathExists(uiPkgDir))) {
332
+ await fs.copy(path.join(TEMPLATES_DIR, 'ui-app-template'), uiPkgDir);
333
+ await replaceInAllFiles(uiPkgDir, 'ui-app-name', `ui-${appName}`);
334
+ await replaceInAllFiles(uiPkgDir, 'app-name', appName);
335
+ }
336
+
337
+ await appendEnvVars(path.join(targetDir, '.env.example'), plugins);
338
+ await writePluginFiles(targetDir, plugins);
339
+ await composeProviders(targetDir, plugins);
340
+ await applyTransforms(targetDir, plugins);
341
+ }
342
+
343
+ // ─── Helpers ───
344
+
345
+ async function replaceInAllFiles(dir, search, replace) {
346
+ const entries = await fs.readdir(dir, { withFileTypes: true });
347
+ for (const entry of entries) {
348
+ const fullPath = path.join(dir, entry.name);
349
+ if (entry.isDirectory()) {
350
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
351
+ await replaceInAllFiles(fullPath, search, replace);
352
+ } else {
353
+ const content = await fs.readFile(fullPath, 'utf-8');
354
+ if (content.includes(search)) {
355
+ await fs.writeFile(fullPath, content.replaceAll(search, replace));
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ async function writeNextConfig(targetDir, plugins, { isMonorepo, appName }) {
362
+ const imports = [`import type { NextConfig } from 'next';`];
363
+ const preExport = [];
364
+ let configBody;
365
+
366
+ if (isMonorepo) {
367
+ const uiPkgName = `ui-${appName ?? 'app'}`;
368
+ configBody = `const nextConfig: NextConfig = {
369
+ reactCompiler: true,
370
+ transpilePackages: ['@workspace/ui-core', '@workspace/${uiPkgName}'],
371
+ output: 'standalone',
372
+ images: {
373
+ unoptimized: process.env.NODE_ENV === 'development',
374
+ },
375
+ };`;
376
+ } else {
377
+ configBody = `const nextConfig: NextConfig = {
378
+ reactCompiler: true,
379
+ images: {
380
+ unoptimized: process.env.NODE_ENV === 'development',
381
+ },
382
+ };`;
383
+ }
384
+
385
+ for (const plugin of plugins) {
386
+ if (plugin.imports) imports.push(...plugin.imports);
387
+ if (plugin.preExport) preExport.push(...plugin.preExport);
388
+ }
389
+
390
+ let exportExpr = 'nextConfig';
391
+ for (const plugin of plugins) {
392
+ if (plugin.wrapExport) {
393
+ exportExpr = plugin.wrapExport(exportExpr);
394
+ }
395
+ }
396
+
397
+ const lines = [imports.join('\n'), '', configBody];
398
+
399
+ if (preExport.length > 0) {
400
+ lines.push('', preExport.join('\n'));
401
+ }
402
+
403
+ lines.push('', `export default ${exportExpr};`, '');
404
+
405
+ await fs.writeFile(path.join(targetDir, 'next.config.ts'), lines.join('\n'));
406
+ }
407
+
408
+ async function appendEnvVars(envPath, plugins) {
409
+ const additions = [];
410
+ for (const plugin of plugins) {
411
+ if (plugin.envVars && plugin.envVars.length > 0) {
412
+ additions.push('', ...plugin.envVars);
413
+ }
414
+ }
415
+ if (additions.length > 0) {
416
+ const existing = await fs.readFile(envPath, 'utf-8');
417
+ await fs.writeFile(envPath, existing.trimEnd() + '\n' + additions.join('\n') + '\n');
418
+ }
419
+ }
420
+
421
+ async function writePluginFiles(targetDir, plugins) {
422
+ for (const plugin of plugins) {
423
+ if (plugin.files) {
424
+ for (const [filePath, content] of Object.entries(plugin.files)) {
425
+ const fullPath = path.join(targetDir, filePath);
426
+ await fs.ensureDir(path.dirname(fullPath));
427
+ await fs.writeFile(fullPath, content);
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ async function composeProviders(targetDir, plugins) {
434
+ const extraImports = [];
435
+ const wrappers = [];
436
+
437
+ for (const plugin of plugins) {
438
+ if (plugin.providerImports) extraImports.push(...plugin.providerImports);
439
+ if (plugin.providerWrappers) wrappers.push(...plugin.providerWrappers);
440
+ }
441
+
442
+ if (extraImports.length === 0 && wrappers.length === 0) return;
443
+
444
+ const globalProviderPath = path.join(targetDir, 'src/app/providers/GlobalProvider/index.tsx');
445
+ if (!(await fs.pathExists(globalProviderPath))) return;
446
+
447
+ let content = await fs.readFile(globalProviderPath, 'utf-8');
448
+
449
+ // import 추가 (파일 최상단에)
450
+ for (const imp of extraImports) {
451
+ if (!content.includes(imp)) {
452
+ content = imp + '\n' + content;
453
+ }
454
+ }
455
+
456
+ // wrapper 적용: <ThemeProviders> 바깥쪽에 감싸기
457
+ for (const wrapper of wrappers) {
458
+ if (content.includes(`<${wrapper}>`)) continue;
459
+ content = content.replace(
460
+ /(<ThemeProviders>)/,
461
+ `<${wrapper}>\n $1`,
462
+ );
463
+ content = content.replace(
464
+ /(<\/ThemeProviders>)/,
465
+ `$1\n </${wrapper}>`,
466
+ );
467
+ }
468
+
469
+ await fs.writeFile(globalProviderPath, content);
470
+ }
471
+
472
+ async function applyTransforms(targetDir, plugins) {
473
+ for (const plugin of plugins) {
474
+ if (!plugin.transforms) continue;
475
+
476
+ for (const transform of plugin.transforms) {
477
+ const { type } = transform;
478
+
479
+ if (type === 'move') {
480
+ const fromPath = path.join(targetDir, transform.from);
481
+ const toPath = path.join(targetDir, transform.to);
482
+ if (await fs.pathExists(fromPath)) {
483
+ await fs.ensureDir(path.dirname(toPath));
484
+ await fs.move(fromPath, toPath, { overwrite: true });
485
+ }
486
+ }
487
+
488
+ if (type === 'replace') {
489
+ const filePath = path.join(targetDir, transform.path);
490
+ if (transform.content) {
491
+ await fs.writeFile(filePath, transform.content);
492
+ }
493
+ if (transform.contentFn) {
494
+ if (await fs.pathExists(filePath)) {
495
+ const existing = await fs.readFile(filePath, 'utf-8');
496
+ const updated = transform.contentFn(existing);
497
+ await fs.writeFile(filePath, updated);
498
+ }
499
+ }
500
+ }
501
+
502
+ if (type === 'delete') {
503
+ const filePath = path.join(targetDir, transform.path);
504
+ await fs.remove(filePath);
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ // ─── Theme 주입 ───
511
+
512
+ /** 여러 후보 경로 중 존재하는 첫 tokens.css 에 theme 주입 */
513
+ async function injectCssTheme(projectDir, theme) {
514
+ if (!theme) return;
515
+ const candidates = [
516
+ 'src/shared/styles/tokens.css',
517
+ 'src/styles/tokens.css',
518
+ ];
519
+ for (const rel of candidates) {
520
+ const abs = path.join(projectDir, rel);
521
+ if (await fs.pathExists(abs)) {
522
+ let css = await fs.readFile(abs, 'utf-8');
523
+ css = replaceSection(css, 'theme-colors', '/*', '*/', buildCssColorsBlock(theme));
524
+ css = replaceSection(css, 'theme-radius', '/*', '*/', buildCssRadiusBlock(theme));
525
+ await fs.writeFile(abs, css);
526
+ return;
527
+ }
528
+ }
529
+ throw new Error(`theme 주입 실패: tokens.css 파일을 찾을 수 없음 (${projectDir})`);
530
+ }
531
+
532
+ async function injectDartTheme(projectDir, theme) {
533
+ if (!theme) return;
534
+ const abs = path.join(projectDir, 'lib/sh_ui/foundation/sh_ui_tokens.dart');
535
+ if (!(await fs.pathExists(abs))) {
536
+ throw new Error(`theme 주입 실패: sh_ui_tokens.dart 가 없음 (${abs})`);
537
+ }
538
+ let dart = await fs.readFile(abs, 'utf-8');
539
+ dart = replaceSection(dart, 'theme-colors', '//', '', buildDartColorsBlock(theme));
540
+ dart = replaceSection(dart, 'theme-radius', '//', '', buildDartRadiusBlock(theme));
541
+ await fs.writeFile(abs, dart);
542
+ }
@@ -0,0 +1,68 @@
1
+ // sh-ui create — 프로젝트 스캐폴드 진입점.
2
+ // bin/sh-ui.mjs 의 `create` 서브커맨드와 sh-ui-create 호환 shim 양쪽에서 호출된다.
3
+
4
+ import { parseArgs } from './cli-args.js';
5
+ import { createProject, addApp, addComponent } from './generator.js';
6
+
7
+ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
8
+
9
+ 사용법:
10
+ sh-ui create [name] [options]
11
+ sh-ui create add-app
12
+ sh-ui create add-component <name> [--app <name>]
13
+
14
+ 옵션:
15
+ --platform <next|flutter> 타겟 플랫폼
16
+ --structure <standalone|monorepo> Next.js 프로젝트 구조 (next 일 때)
17
+ --plugins <a,b> 플러그인 (sentry, next-intl). 미지정/"" → 없음
18
+ --theme <base64> 테마 JSON (base64). 선택
19
+ --yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
20
+ -h, --help 이 도움말
21
+
22
+ 예 (대화형):
23
+ sh-ui create
24
+
25
+ 예 (비대화형 / 에이전트 / CI):
26
+ sh-ui create my-app --platform next --structure standalone --yes
27
+ sh-ui create my-app --platform next --structure monorepo --plugins sentry,next-intl --yes
28
+ sh-ui create my-app --platform flutter --yes
29
+
30
+ 비대화형 환경(TTY 없음)에서는 누락된 필수 인자가 있으면 prompt 대신 에러로 종료한다.
31
+ `;
32
+
33
+ /**
34
+ * @param {string[]} rest - sh-ui create 뒤에 오는 인자 배열 (예: ["my-app", "--platform", "next"])
35
+ */
36
+ export async function runCreate(rest) {
37
+ // parseArgs 가 process.argv 형태(앞 두 개는 스킵)를 기대하므로 더미 두 개를 prepend.
38
+ let parsed;
39
+ try {
40
+ parsed = parseArgs(['node', 'sh-ui-create', ...rest]);
41
+ } catch (e) {
42
+ console.error(`❌ ${e.message}`);
43
+ console.error(`\n도움말: sh-ui create --help`);
44
+ process.exit(1);
45
+ }
46
+
47
+ const { command, flags, positional } = parsed;
48
+
49
+ if (flags.help) {
50
+ console.log(HELP_TEXT);
51
+ return;
52
+ }
53
+
54
+ if (command === 'add-app') {
55
+ await addApp();
56
+ } else if (command === 'add-component') {
57
+ await addComponent(positional[0], flags.app);
58
+ } else {
59
+ await createProject({
60
+ name: positional[0],
61
+ platform: flags.platform,
62
+ structure: flags.structure,
63
+ plugins: flags.plugins,
64
+ theme: flags.theme,
65
+ yes: flags.yes,
66
+ });
67
+ }
68
+ }
@@ -0,0 +1,17 @@
1
+ import { sentryPlugin } from './sentry.js';
2
+ import { nextIntlPlugin } from './nextIntl.js';
3
+
4
+ export const allPlugins = [sentryPlugin, nextIntlPlugin];
5
+
6
+ export function getPluginChoices() {
7
+ return allPlugins.map((p) => ({
8
+ name: p.label,
9
+ value: p.name,
10
+ }));
11
+ }
12
+
13
+ export function getPluginsByNames(names) {
14
+ return allPlugins
15
+ .filter((p) => names.includes(p.name))
16
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
17
+ }