sh-ui-cli 0.59.8 → 0.61.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 (94) hide show
  1. package/data/changelog/versions.json +53 -0
  2. package/data/registry/flutter/widgets/sh_ui_input.dart +0 -79
  3. package/data/registry/react/components/input/index.module.tsx +0 -70
  4. package/data/registry/react/components/input/index.tailwind.tsx +0 -53
  5. package/data/registry/react/components/input/index.tsx +0 -70
  6. package/data/registry/react/components/input/index.vanilla-extract.tsx +0 -63
  7. package/data/summaries/react.json +1 -1
  8. package/package.json +2 -2
  9. package/src/create/architectures/flat.js +1 -1
  10. package/src/create/generator.js +717 -17
  11. package/src/create/index.mjs +1 -1
  12. package/src/create/plugins/authJwt.js +51 -1
  13. package/src/create/plugins/nextIntl.js +171 -17
  14. package/src/create/plugins/sentry.js +43 -23
  15. package/src/mcp.mjs +2 -2
  16. package/templates/flutter-standalone/sh-ui.config.json +0 -1
  17. package/templates/monorepo/README.md +14 -5
  18. package/templates/monorepo/packages/eslint-config/flat.js +71 -0
  19. package/templates/monorepo/packages/eslint-config/fsd.js +0 -21
  20. package/templates/monorepo/packages/eslint-config/package.json +2 -3
  21. package/templates/monorepo/packages/typescript-config/package.json +6 -1
  22. package/templates/monorepo/packages/ui/ui-core/tsconfig.json +1 -1
  23. package/templates/monorepo/pnpm-workspace.yaml +2 -1
  24. package/templates/nextjs-app/.env.example +3 -2
  25. package/templates/nextjs-app/Dockerfile +36 -5
  26. package/templates/nextjs-app/README.md +9 -7
  27. package/templates/nextjs-app/_arch/flat/app/api/proxy/[...path]/route.ts +1 -1
  28. package/templates/nextjs-app/_arch/flat/app/layout.tsx +2 -2
  29. package/templates/nextjs-app/_arch/flat/components/common/PrefetchBoundary/index.tsx +1 -1
  30. package/templates/nextjs-app/_arch/flat/components/layouts/RootLayout.tsx +2 -0
  31. package/templates/nextjs-app/_arch/flat/components/providers/GlobalProvider/index.tsx +3 -3
  32. package/templates/nextjs-app/_arch/flat/components/providers/theme/ThemeProvider.tsx +12 -0
  33. package/templates/nextjs-app/_arch/flat/eslint.config.js +10 -0
  34. package/templates/nextjs-app/_arch/flat/lib/api/clientFetch.ts +4 -4
  35. package/templates/nextjs-app/_arch/flat/lib/api/errorMessages.ts +37 -0
  36. package/templates/nextjs-app/_arch/flat/lib/hooks/useAppMutation.ts +14 -7
  37. package/templates/nextjs-app/_arch/flat/lib/test/renderWithProviders.tsx +32 -7
  38. package/templates/nextjs-app/_arch/flat/lib/utils/formatDate.ts +4 -0
  39. package/templates/nextjs-app/_arch/flat/lib/utils/formatPrice.ts +13 -5
  40. package/templates/nextjs-app/_arch/flat/lib/utils/getQueryClient.ts +1 -1
  41. package/templates/nextjs-app/_arch/flat/tsconfig.json +0 -1
  42. package/templates/nextjs-app/_arch/fsd/app/api/proxy/[...path]/route.ts +1 -1
  43. package/templates/nextjs-app/_arch/fsd/app/layout.tsx +2 -2
  44. package/templates/nextjs-app/_arch/fsd/src/app/layouts/RootLayout.tsx +2 -0
  45. package/templates/nextjs-app/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +3 -3
  46. package/templates/nextjs-app/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +12 -0
  47. package/templates/nextjs-app/_arch/fsd/src/shared/api/clientFetch.ts +4 -4
  48. package/templates/nextjs-app/_arch/fsd/src/shared/api/errorMessages.ts +37 -0
  49. package/templates/nextjs-app/_arch/fsd/src/shared/hooks/useAppMutation.ts +14 -7
  50. package/templates/nextjs-app/_arch/fsd/src/shared/lib/formatDate.ts +4 -0
  51. package/templates/nextjs-app/_arch/fsd/src/shared/lib/formatPrice.ts +13 -5
  52. package/templates/nextjs-app/_arch/fsd/src/shared/lib/getQueryClient.ts +1 -1
  53. package/templates/nextjs-app/_arch/fsd/src/shared/test/renderWithProviders.tsx +32 -7
  54. package/templates/nextjs-app/_arch/fsd/src/shared/ui/PrefetchBoundary/index.tsx +1 -1
  55. package/templates/nextjs-app/vitest.config.ts +4 -0
  56. package/templates/nextjs-standalone/.env.example +3 -2
  57. package/templates/nextjs-standalone/Dockerfile +35 -0
  58. package/templates/nextjs-standalone/_arch/flat/app/api/proxy/[...path]/route.ts +1 -1
  59. package/templates/nextjs-standalone/_arch/flat/app/layout.tsx +2 -2
  60. package/templates/nextjs-standalone/_arch/flat/components/common/PrefetchBoundary/index.tsx +1 -1
  61. package/templates/nextjs-standalone/_arch/flat/components/layouts/RootLayout.tsx +2 -0
  62. package/templates/nextjs-standalone/_arch/flat/components/providers/GlobalProvider/index.tsx +3 -3
  63. package/templates/nextjs-standalone/_arch/flat/components/providers/theme/ThemeProvider.tsx +12 -0
  64. package/templates/nextjs-standalone/_arch/flat/eslint.config.js +123 -0
  65. package/templates/nextjs-standalone/_arch/flat/lib/api/clientFetch.ts +4 -4
  66. package/templates/nextjs-standalone/_arch/flat/lib/api/errorMessages.ts +37 -0
  67. package/templates/nextjs-standalone/_arch/flat/lib/hooks/useAppMutation.ts +14 -7
  68. package/templates/nextjs-standalone/_arch/flat/lib/test/renderWithProviders.tsx +32 -7
  69. package/templates/nextjs-standalone/_arch/flat/lib/utils/formatDate.ts +4 -0
  70. package/templates/nextjs-standalone/_arch/flat/lib/utils/formatPrice.ts +13 -5
  71. package/templates/nextjs-standalone/_arch/flat/lib/utils/getQueryClient.ts +1 -1
  72. package/templates/nextjs-standalone/_arch/flat/tsconfig.json +1 -2
  73. package/templates/nextjs-standalone/_arch/fsd/app/api/proxy/[...path]/route.ts +1 -1
  74. package/templates/nextjs-standalone/_arch/fsd/app/layout.tsx +2 -2
  75. package/templates/nextjs-standalone/_arch/fsd/src/app/layouts/RootLayout.tsx +2 -0
  76. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +3 -3
  77. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +12 -0
  78. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/clientFetch.ts +4 -4
  79. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/errorMessages.ts +37 -0
  80. package/templates/nextjs-standalone/_arch/fsd/src/shared/hooks/useAppMutation.ts +14 -7
  81. package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatDate.ts +4 -0
  82. package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatPrice.ts +13 -5
  83. package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/getQueryClient.ts +1 -1
  84. package/templates/nextjs-standalone/_arch/fsd/src/shared/test/renderWithProviders.tsx +32 -7
  85. package/templates/nextjs-standalone/_arch/fsd/src/shared/ui/PrefetchBoundary/index.tsx +1 -1
  86. package/templates/nextjs-standalone/eslint.config.js +0 -15
  87. package/templates/nextjs-standalone/package.json +0 -2
  88. package/templates/ui-app-template/package.json +2 -2
  89. package/templates/ui-app-template/postcss.config.mjs +1 -1
  90. package/templates/monorepo/.eslintrc.js +0 -8
  91. package/templates/nextjs-app/_arch/flat/components/providers/theme/ThemeProviders.tsx +0 -12
  92. package/templates/nextjs-app/_arch/fsd/src/app/providers/theme/ThemeProviders.tsx +0 -12
  93. package/templates/nextjs-standalone/_arch/flat/components/providers/theme/ThemeProviders.tsx +0 -12
  94. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/theme/ThemeProviders.tsx +0 -12
@@ -1,8 +1,76 @@
1
1
  import { input, select, checkbox, confirm } from '@inquirer/prompts';
2
2
  import { execSync } from 'node:child_process';
3
- import fs from 'fs-extra';
3
+ import * as fsp from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+
7
+ /**
8
+ * Node native fs/promises 위에 얹은 fs-extra 호환 슬림 어댑터.
9
+ *
10
+ * 왜 직접 어댑터를 두는가:
11
+ * 1. fs-extra v11 이 long-running 프로세스(MCP daemon) 에서 internal state 를
12
+ * 누적시켜 directory filter / recursive copy 가 부분 실패하는 회귀가 보고됨.
13
+ * Node 24 의 native `fs.cp` 는 그런 모듈 단위 캐시가 없어 안전.
14
+ * 2. 의존성 1개 제거 — 부트 시간/번들 크기 이득.
15
+ *
16
+ * 의도적으로 fs-extra 의 API 표면 일부만 재현 — 사용처(generator.js) 에서 쓰는 메서드만.
17
+ * `move` 는 cross-device 시 `rename` 이 EXDEV 를 던지므로 copy+remove fallback 포함.
18
+ */
19
+ const fs = {
20
+ ...fsp,
21
+ /** 파일/디렉토리 존재 여부 — fs-extra `pathExists` 동등. */
22
+ pathExists: async (p) => {
23
+ try {
24
+ await fsp.access(p);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ },
30
+ /** JSON 파일 읽기 — fs-extra `readJson` 동등. */
31
+ readJson: async (p) => JSON.parse(await fsp.readFile(p, 'utf-8')),
32
+ /** JSON 파일 쓰기 — fs-extra `writeJson` 동등 (`spaces` 만 지원). */
33
+ writeJson: async (p, obj, opts = {}) => {
34
+ const indent = opts.spaces ?? 2;
35
+ await fsp.writeFile(p, JSON.stringify(obj, null, indent) + '\n');
36
+ },
37
+ /** 재귀 삭제 — fs-extra `remove` 동등. 없으면 무시. */
38
+ remove: async (p) => fsp.rm(p, { recursive: true, force: true }),
39
+ /** 디렉토리 보장 (mkdir -p) — fs-extra `ensureDir` 동등. */
40
+ ensureDir: async (p) => fsp.mkdir(p, { recursive: true }),
41
+ /**
42
+ * 재귀 복사 — fs-extra `copy` 의 핵심 옵션 (`filter`, `overwrite`) 만 지원.
43
+ * Node `fs.cp` 의 `force/errorOnExist` 조합으로 fs-extra 의 `overwrite` 시맨틱 재현:
44
+ * - overwrite: true → force=true (기존 파일 덮어씀)
45
+ * - overwrite: false → force=false + errorOnExist=false (기존 파일 스킵, 충돌 throw 없음)
46
+ */
47
+ copy: async (src, dest, opts = {}) => {
48
+ const overwrite = opts.overwrite ?? false;
49
+ return fsp.cp(src, dest, {
50
+ recursive: true,
51
+ filter: opts.filter,
52
+ force: overwrite,
53
+ errorOnExist: false,
54
+ });
55
+ },
56
+ /**
57
+ * 이동 — fs-extra `move` 동등. 같은 파일시스템이면 `rename`, cross-device 면 copy+remove.
58
+ * `overwrite: true` 시 기존 dest 를 먼저 제거.
59
+ */
60
+ move: async (src, dest, opts = {}) => {
61
+ if (opts.overwrite) {
62
+ await fsp.rm(dest, { recursive: true, force: true });
63
+ }
64
+ try {
65
+ await fsp.rename(src, dest);
66
+ } catch (e) {
67
+ if (e.code !== 'EXDEV') throw e;
68
+ // cross-device — fallback: copy + remove src.
69
+ await fsp.cp(src, dest, { recursive: true, force: true, errorOnExist: false });
70
+ await fsp.rm(src, { recursive: true, force: true });
71
+ }
72
+ },
73
+ };
6
74
  import { getPluginChoices, getPluginsByNames } from './plugins/index.js';
7
75
  import {
8
76
  assertArchPlatformCompat,
@@ -47,15 +115,19 @@ const TEMPLATES_DIR = getTemplatesRoot();
47
115
 
48
116
  /**
49
117
  * 템플릿 복사 직후 sh-ui.config.json 의 cssFramework 필드를 갱신.
50
- * 템플릿엔 이미 기본값이 박혀 있지만, 사용자가 --css 로 다른 값을 지정한
51
- * 경우 값으로 덮어쓴다. 1단계는 plain 지원하므로 사실상 idempotent
52
- * 이지만 2단계 emitter 추가되면 호출만으로 곧바로 동작.
118
+ *
119
+ * Flutter cssFramework 의미 없으므로 platform=flutter 필드 자체를 안 쓴다.
120
+ * Next.js plain/tailwind/css-modules 따라 컴포넌트 변종 결정 + base 파일도 분기 emit.
53
121
  */
54
122
  async function patchShUiConfig(configPath, cssFramework) {
55
- const fw = cssFramework ?? CSS_FRAMEWORK_DEFAULT;
56
123
  if (!(await fs.pathExists(configPath))) return;
57
124
  const config = await fs.readJson(configPath);
58
- config.cssFramework = fw;
125
+ // Flutter 는 cssFramework 무관 — 필드 자체를 두지 않는다.
126
+ if (config.platform === 'flutter') {
127
+ delete config.cssFramework;
128
+ } else {
129
+ config.cssFramework = cssFramework ?? CSS_FRAMEWORK_DEFAULT;
130
+ }
59
131
  await fs.writeJson(configPath, config, { spaces: 2 });
60
132
  }
61
133
 
@@ -384,11 +456,13 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
384
456
  await fs.copy(path.join(TEMPLATES_DIR, 'nextjs-standalone'), targetDir, {
385
457
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
386
458
  });
459
+ await ensureArchCleanup(targetDir);
387
460
  await fs.copy(
388
461
  path.join(TEMPLATES_DIR, 'nextjs-standalone', '_arch', arch.name),
389
462
  targetDir,
390
463
  { overwrite: true },
391
464
  );
465
+ await assertArchOverlayApplied(targetDir, arch);
392
466
 
393
467
  // Update package.json
394
468
  const pkgPath = path.join(targetDir, 'package.json');
@@ -402,6 +476,9 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
402
476
  Object.assign(pkg.devDependencies, plugin.devDependencies);
403
477
  }
404
478
  }
479
+ // 플러그인 deps 가 알파벳 정렬을 깨므로 마지막에 정렬해서 일관된 출력 보장.
480
+ if (pkg.dependencies) pkg.dependencies = sortObjectKeys(pkg.dependencies);
481
+ if (pkg.devDependencies) pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
405
482
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });
406
483
 
407
484
  await writeNextConfig(targetDir, plugins, { isMonorepo: false, arch });
@@ -409,6 +486,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
409
486
  await writePluginFiles(targetDir, plugins, arch);
410
487
  await composeProviders(targetDir, plugins, arch);
411
488
  await applyTransforms(targetDir, plugins, arch);
489
+ await applyCssFrameworkVariant(targetDir, css, { isMonorepo: false, plugins, arch });
412
490
  await injectCssTheme(targetDir, theme);
413
491
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css);
414
492
  }
@@ -444,24 +522,27 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
444
522
  });
445
523
 
446
524
  const appsDir = path.join(targetDir, 'apps', appName);
447
- await generateApp(appsDir, appName, port, plugins, arch);
525
+ await generateApp(appsDir, appName, port, plugins, arch, css);
526
+ // generateApp 이 ui-{app} 패키지의 cssFramework 변종까지 처리. 여기선 theme + sh-ui.config.json 만.
448
527
  const uiAppDir = path.join(targetDir, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
449
528
  await injectCssTheme(uiAppDir, theme);
450
529
  await patchShUiConfig(path.join(uiAppDir, 'sh-ui.config.json'), css);
451
530
  }
452
531
 
453
- async function generateApp(targetDir, appName, port, plugins, arch) {
532
+ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailwind') {
454
533
  // 베이스 템플릿 (arch-neutral 파일들) 만 카피 — _arch/ 디렉토리는 스킵.
455
534
  // 그 후 선택된 arch 의 오버레이를 위에 머지해 arch-coupled 파일들 (layout.tsx,
456
535
  // src/ 또는 lib+components/, tsconfig.json paths 블록 등) 을 떨어뜨린다.
457
536
  await fs.copy(path.join(TEMPLATES_DIR, 'nextjs-app'), targetDir, {
458
537
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
459
538
  });
539
+ await ensureArchCleanup(targetDir);
460
540
  await fs.copy(
461
541
  path.join(TEMPLATES_DIR, 'nextjs-app', '_arch', arch.name),
462
542
  targetDir,
463
543
  { overwrite: true },
464
544
  );
545
+ await assertArchOverlayApplied(targetDir, arch);
465
546
 
466
547
  // Replace ui-app-name placeholder with actual app name in all files
467
548
  await replaceInAllFiles(targetDir, 'ui-app-name', `ui-${appName}`);
@@ -480,6 +561,8 @@ async function generateApp(targetDir, appName, port, plugins, arch) {
480
561
  Object.assign(pkg.devDependencies, plugin.devDependencies);
481
562
  }
482
563
  }
564
+ if (pkg.dependencies) pkg.dependencies = sortObjectKeys(pkg.dependencies);
565
+ if (pkg.devDependencies) pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
483
566
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });
484
567
 
485
568
  await writeNextConfig(targetDir, plugins, { isMonorepo: true, appName, arch });
@@ -506,10 +589,618 @@ async function generateApp(targetDir, appName, port, plugins, arch) {
506
589
  await writePluginFiles(targetDir, plugins, arch);
507
590
  await composeProviders(targetDir, plugins, arch);
508
591
  await applyTransforms(targetDir, plugins, arch);
592
+ // monorepo 의 cssFramework 변종은 web app 디렉토리 + ui-app 패키지 디렉토리 양쪽에 적용.
593
+ await applyCssFrameworkVariant(targetDir, css, { isMonorepo: true, plugins, arch });
594
+ if (await fs.pathExists(uiPkgDir)) {
595
+ await applyCssFrameworkVariant(uiPkgDir, css, { isMonorepo: true, plugins, arch, isUiPackage: true });
596
+ }
597
+ }
598
+
599
+ /**
600
+ * 베이스 템플릿 카피 직후 `_arch/` 잔여 정리.
601
+ *
602
+ * `fs.copy` 의 `filter` 가 어떤 환경(특히 long-running MCP daemon — fs-extra v11
603
+ * 내부 캐시가 누적될 때) 에서 디렉토리 제외에 실패해 `_arch/{flat,fsd}/...` 가
604
+ * 그대로 사용자 프로젝트에 들어가는 회귀가 보고됐다. 베이스 카피 직후 이 헬퍼가
605
+ * 명시적으로 `_arch/` 를 제거해 필터 실패와 무관하게 항상 깨끗한 상태를 보장한다.
606
+ *
607
+ * 정상 케이스에서는 no-op (디렉토리가 이미 없으므로).
608
+ */
609
+ async function ensureArchCleanup(targetDir) {
610
+ const archDir = path.join(targetDir, '_arch');
611
+ if (await fs.pathExists(archDir)) {
612
+ await fs.remove(archDir);
613
+ }
614
+ }
615
+
616
+ /**
617
+ * arch 오버레이 카피 직후 핵심 파일이 떨어졌는지 검증.
618
+ *
619
+ * 오버레이가 부분적으로만 동작하는 회귀(같은 fs-extra 회귀와 짝을 이뤄
620
+ * 두 번째 `fs.copy` 가 src/{app,entities,...} 를 빠뜨리는 케이스) 가 발생하면
621
+ * 곧바로 알아챌 수 있도록 sentinel 파일 존재 검사. 실패하면 명확한 메시지로
622
+ * throw — 한참 뒤 plugin transform 의 ENOENT 로 노출되는 것보다 진단이 쉽다.
623
+ */
624
+ async function assertArchOverlayApplied(targetDir, arch) {
625
+ const sentinel = `${arch.paths.layouts}/RootLayout.tsx`;
626
+ const sentinelPath = path.join(targetDir, sentinel);
627
+ if (!(await fs.pathExists(sentinelPath))) {
628
+ throw new Error(
629
+ `arch 오버레이 누락: ${arch.name} 의 sentinel 파일(${sentinel}) 이 ${targetDir} 에 없습니다. ` +
630
+ `오버레이 카피가 정상적으로 동작하지 않은 것으로 보입니다. ` +
631
+ `(MCP daemon 환경이라면 daemon 재시작 후 재시도 — long-running fs-extra 인스턴스의 internal state 회귀 의심.)`
632
+ );
633
+ }
509
634
  }
510
635
 
511
636
  // ─── Helpers ───
512
637
 
638
+ /**
639
+ * 객체의 key 를 알파벳 순으로 정렬한 새 객체 반환. package.json deps 같이 사용자가
640
+ * 자주 보는 필드의 일관된 출력에 사용.
641
+ */
642
+ function sortObjectKeys(obj) {
643
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
644
+ }
645
+
646
+ /**
647
+ * cssFramework 별 base 파일 변환.
648
+ *
649
+ * 베이스 템플릿은 'tailwind' 기준으로 emit 되어 있다 (글로벌 css, postcss config,
650
+ * package.json deps, page.tsx, error.tsx 등 모두 Tailwind 가정). cssFramework 가
651
+ * 'plain' 또는 'css-modules' 면 이 함수가 후처리로 Tailwind 의존성을 제거하고
652
+ * inline-style / .module.css 변종으로 교체한다.
653
+ *
654
+ * 'tailwind' 일 때는 no-op (현재 기본값).
655
+ */
656
+ async function applyCssFrameworkVariant(targetDir, cssFramework, { isMonorepo, plugins, arch, isUiPackage = false } = {}) {
657
+ if (cssFramework === 'tailwind') return;
658
+ if (cssFramework !== 'plain' && cssFramework !== 'css-modules') return;
659
+
660
+ // 1) package.json — Tailwind deps 제거.
661
+ const pkgPath = path.join(targetDir, 'package.json');
662
+ if (await fs.pathExists(pkgPath)) {
663
+ const pkg = await fs.readJson(pkgPath);
664
+ const TAILWIND_DEPS = ['tailwindcss', '@tailwindcss/postcss', 'prettier-plugin-tailwindcss'];
665
+ let changed = false;
666
+ for (const key of TAILWIND_DEPS) {
667
+ if (pkg.dependencies && key in pkg.dependencies) {
668
+ delete pkg.dependencies[key];
669
+ changed = true;
670
+ }
671
+ if (pkg.devDependencies && key in pkg.devDependencies) {
672
+ delete pkg.devDependencies[key];
673
+ changed = true;
674
+ }
675
+ }
676
+ if (changed) await fs.writeJson(pkgPath, pkg, { spaces: 2 });
677
+ }
678
+
679
+ // 2) postcss.config.mjs — Tailwind plugin 제거. plain/cssmodules 둘 다 비움.
680
+ // (Next.js 가 css-modules 는 자동 처리)
681
+ const postcssPath = path.join(targetDir, 'postcss.config.mjs');
682
+ if (await fs.pathExists(postcssPath)) {
683
+ if (isUiPackage) {
684
+ // ui 패키지의 postcss.config.mjs 는 host app 의 것을 re-export — 비워두면 안 됨.
685
+ // 비-tailwind 일 땐 빈 plugins 객체로 교체.
686
+ await fs.writeFile(postcssPath, `const config = {\n plugins: {},\n};\n\nexport default config;\n`);
687
+ } else {
688
+ await fs.writeFile(postcssPath, `const config = {\n plugins: {},\n};\n\nexport default config;\n`);
689
+ }
690
+ }
691
+
692
+ // 3) globals.css — Tailwind import + @theme inline 제거. 토큰 import + 기본 reset 만.
693
+ const globalsCandidates = [
694
+ path.join(targetDir, 'app/globals.css'),
695
+ path.join(targetDir, 'src/styles/globals.css'),
696
+ ];
697
+ for (const globals of globalsCandidates) {
698
+ if (await fs.pathExists(globals)) {
699
+ // tokens.css 위치 결정 — globals.css 와 같은 directory 내 또는 인접.
700
+ // standalone fsd: app/globals.css 와 src/shared/styles/tokens.css → '../src/shared/styles/tokens.css'
701
+ // standalone flat: app/globals.css 와 lib/styles/tokens.css → '../lib/styles/tokens.css'
702
+ // ui-app-template (monorepo ui pkg): src/styles/globals.css ↔ src/styles/tokens.css → './tokens.css'
703
+ // 기존 globals.css 의 import 줄을 보존해 그대로 사용 (이미 정확한 상대경로).
704
+ const existing = await fs.readFile(globals, 'utf-8');
705
+ const tokenImport = existing
706
+ .split('\n')
707
+ .find((l) => /@import\s+['"][^'"]+tokens\.css['"]/.test(l));
708
+ const newContent = `${tokenImport ?? "@import './tokens.css';"}
709
+
710
+ /* Plain CSS — Tailwind 미사용. tokens.css 의 변수만 노출. */
711
+ body {
712
+ margin: 0;
713
+ min-height: 100vh;
714
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
715
+ 'Helvetica Neue', Arial, sans-serif;
716
+ background: var(--background);
717
+ color: var(--foreground);
718
+ -webkit-font-smoothing: antialiased;
719
+ }
720
+
721
+ *,
722
+ *::before,
723
+ *::after {
724
+ box-sizing: border-box;
725
+ }
726
+ `;
727
+ await fs.writeFile(globals, newContent);
728
+ }
729
+ }
730
+
731
+ // 4) ui 패키지면 여기까지. host app 의 page/error 변환은 host 디렉토리에서만.
732
+ if (isUiPackage) return;
733
+
734
+ // 5) page.tsx — Tailwind 클래스 → inline-style (plain) 또는 .module.css (cssmodules).
735
+ // 위치는 plugin 활성 여부에 따라 달라짐.
736
+ const intlActive = plugins?.some((p) => p.name === 'next-intl');
737
+ const pageCandidates = intlActive
738
+ ? [path.join(targetDir, 'app/[locale]/page.tsx')]
739
+ : [path.join(targetDir, 'app/page.tsx')];
740
+
741
+ for (const pagePath of pageCandidates) {
742
+ if (!(await fs.pathExists(pagePath))) continue;
743
+ if (cssFramework === 'plain') {
744
+ await fs.writeFile(pagePath, `export default function Home() {
745
+ return (
746
+ <main
747
+ style={{
748
+ display: 'flex',
749
+ minHeight: '100vh',
750
+ flexDirection: 'column',
751
+ alignItems: 'center',
752
+ justifyContent: 'center',
753
+ }}
754
+ >
755
+ <h1 style={{ fontSize: '2.25rem', fontWeight: 700, margin: 0 }}>
756
+ Hello World
757
+ </h1>
758
+ </main>
759
+ );
760
+ }
761
+ `);
762
+ } else {
763
+ // css-modules
764
+ const dir = path.dirname(pagePath);
765
+ await fs.writeFile(path.join(dir, 'page.module.css'), `.main {
766
+ display: flex;
767
+ min-height: 100vh;
768
+ flex-direction: column;
769
+ align-items: center;
770
+ justify-content: center;
771
+ }
772
+
773
+ .title {
774
+ font-size: 2.25rem;
775
+ font-weight: 700;
776
+ margin: 0;
777
+ }
778
+ `);
779
+ await fs.writeFile(pagePath, `import styles from './page.module.css';
780
+
781
+ export default function Home() {
782
+ return (
783
+ <main className={styles.main}>
784
+ <h1 className={styles.title}>Hello World</h1>
785
+ </main>
786
+ );
787
+ }
788
+ `);
789
+ }
790
+ }
791
+
792
+ // 6) sentry plugin 의 error.tsx 변환 — Tailwind 클래스가 박혀있어서 plain/cssmodules 에서 작동 안 함.
793
+ // intl + sentry 면 nextIntl 이 [locale]/error.tsx 를 i18n-aware 로 replace 하므로 그것도 변환.
794
+ const sentryActive = plugins?.some((p) => p.name === 'sentry');
795
+ if (sentryActive) {
796
+ const errorCandidates = intlActive
797
+ ? [path.join(targetDir, 'app/[locale]/error.tsx')]
798
+ : [path.join(targetDir, 'app/error.tsx')];
799
+ for (const errPath of errorCandidates) {
800
+ if (!(await fs.pathExists(errPath))) continue;
801
+ const useI18n = intlActive;
802
+ await fs.writeFile(errPath, buildErrorTsxNonTailwind({ useI18n, cssFramework, arch }));
803
+ if (cssFramework === 'css-modules') {
804
+ await fs.writeFile(
805
+ path.join(path.dirname(errPath), 'error.module.css'),
806
+ buildErrorModuleCss(),
807
+ );
808
+ }
809
+ }
810
+ }
811
+
812
+ // 7) .prettierrc — tailwind plugin 제거.
813
+ await stripTailwindFromPrettier(path.join(targetDir, '.prettierrc'));
814
+
815
+ // 8) monorepo 인 경우 root .prettierrc 와 root package.json 도 정리 (root 의 prettier-plugin-tailwindcss).
816
+ // applyCssFrameworkVariant 는 apps/web 마다 호출되지만 root 정리는 1회면 충분 — idempotent 라 OK.
817
+ if (isMonorepo && !isUiPackage) {
818
+ const monorepoRoot = path.resolve(targetDir, '..', '..');
819
+ await stripTailwindFromPrettier(path.join(monorepoRoot, '.prettierrc'));
820
+ const rootPkgPath = path.join(monorepoRoot, 'package.json');
821
+ if (await fs.pathExists(rootPkgPath)) {
822
+ const rootPkg = await fs.readJson(rootPkgPath);
823
+ const TAILWIND_DEPS = ['prettier-plugin-tailwindcss'];
824
+ let changed = false;
825
+ for (const key of TAILWIND_DEPS) {
826
+ if (rootPkg.devDependencies && key in rootPkg.devDependencies) {
827
+ delete rootPkg.devDependencies[key];
828
+ changed = true;
829
+ }
830
+ }
831
+ if (changed) await fs.writeJson(rootPkgPath, rootPkg, { spaces: 2 });
832
+ }
833
+ }
834
+ }
835
+
836
+ async function stripTailwindFromPrettier(prettierPath) {
837
+ if (!(await fs.pathExists(prettierPath))) return;
838
+ const c = await fs.readJson(prettierPath);
839
+ if (!Array.isArray(c.plugins)) return;
840
+ c.plugins = c.plugins.filter((p) => p !== 'prettier-plugin-tailwindcss');
841
+ if (c.plugins.length === 0) delete c.plugins;
842
+ await fs.writeJson(prettierPath, c, { spaces: 2 });
843
+ }
844
+
845
+ /**
846
+ * sentry 의 error.tsx 를 plain/cssmodules 로 변환한 콘텐츠 생성.
847
+ * useI18n=true 면 next-intl 의 useTranslations + Link 사용.
848
+ */
849
+ function buildErrorTsxNonTailwind({ useI18n, cssFramework, arch }) {
850
+ const i18nImports = useI18n
851
+ ? `import { useTranslations } from 'next-intl';\n`
852
+ : '';
853
+ const configAlias = arch ? arch.aliases.config : '@/src/shared/config';
854
+ const linkImport = useI18n
855
+ ? `import { Link } from '${configAlias}/i18n/navigation';\n`
856
+ : `import Link from 'next/link';\n`;
857
+ const tHook = useI18n ? ` const t = useTranslations('error');\n` : '';
858
+ const titleText = useI18n ? `{t('title')}` : `오류가 발생했습니다`;
859
+ const descText = useI18n
860
+ ? `{t('description')}`
861
+ : `예상치 못한 오류가 발생했습니다. 다시 시도해주세요.`;
862
+ const fallback = useI18n ? `t('unexpectedError')` : `'알 수 없는 오류'`;
863
+ const tryAgain = useI18n ? `{t('button.tryAgain')}` : `다시 시도`;
864
+ const goHome = useI18n ? `{t('button.goHome')}` : `홈으로 이동`;
865
+
866
+ if (cssFramework === 'css-modules') {
867
+ return `'use client';
868
+
869
+ import * as Sentry from '@sentry/nextjs';
870
+ import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
871
+ ${i18nImports}${linkImport}import { useEffect } from 'react';
872
+
873
+ import styles from './error.module.css';
874
+
875
+ export default function Error({
876
+ error,
877
+ reset,
878
+ }: {
879
+ error: Error & { digest?: string };
880
+ reset: () => void;
881
+ }) {
882
+ ${tHook} useEffect(() => {
883
+ Sentry.captureException(error);
884
+ }, [error]);
885
+
886
+ return (
887
+ <div className={styles.wrapper}>
888
+ <div className={styles.card}>
889
+ <div className={styles.iconRow}>
890
+ <div className={styles.iconCircle}>
891
+ <AlertTriangle className={styles.icon} />
892
+ </div>
893
+ </div>
894
+
895
+ <h2 className={styles.title}>${titleText}</h2>
896
+ <p className={styles.description}>${descText}</p>
897
+
898
+ <div className={styles.errorBox}>
899
+ <p className={styles.errorText}>{error.message || ${fallback}}</p>
900
+ </div>
901
+
902
+ <div className={styles.actions}>
903
+ <button onClick={reset} className={styles.primaryButton}>
904
+ <RefreshCw className={styles.buttonIcon} />
905
+ ${tryAgain}
906
+ </button>
907
+
908
+ <Link href='/' className={styles.secondaryButton}>
909
+ <Home className={styles.buttonIcon} />
910
+ ${goHome}
911
+ </Link>
912
+ </div>
913
+
914
+ {process.env.NODE_ENV === 'development' && error.digest && (
915
+ <div className={styles.digest}>
916
+ <p className={styles.digestText}>Error ID: {error.digest}</p>
917
+ </div>
918
+ )}
919
+ </div>
920
+ </div>
921
+ );
922
+ }
923
+ `;
924
+ }
925
+
926
+ // plain — inline style (토큰 var 활용)
927
+ return `'use client';
928
+
929
+ import * as Sentry from '@sentry/nextjs';
930
+ import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
931
+ ${i18nImports}${linkImport}import { useEffect } from 'react';
932
+
933
+ const wrapper: React.CSSProperties = {
934
+ display: 'flex',
935
+ minHeight: '100vh',
936
+ alignItems: 'center',
937
+ justifyContent: 'center',
938
+ padding: '0 16px',
939
+ };
940
+ const card: React.CSSProperties = {
941
+ width: '100%',
942
+ maxWidth: 448,
943
+ borderRadius: 8,
944
+ border: '1px solid var(--border)',
945
+ background: 'var(--background)',
946
+ padding: 24,
947
+ boxShadow: 'var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.15))',
948
+ };
949
+
950
+ export default function Error({
951
+ error,
952
+ reset,
953
+ }: {
954
+ error: Error & { digest?: string };
955
+ reset: () => void;
956
+ }) {
957
+ ${tHook} useEffect(() => {
958
+ Sentry.captureException(error);
959
+ }, [error]);
960
+
961
+ return (
962
+ <div style={wrapper}>
963
+ <div style={card}>
964
+ <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
965
+ <div
966
+ style={{
967
+ width: 64,
968
+ height: 64,
969
+ borderRadius: '50%',
970
+ background: 'color-mix(in srgb, var(--danger) 10%, transparent)',
971
+ display: 'flex',
972
+ alignItems: 'center',
973
+ justifyContent: 'center',
974
+ }}
975
+ >
976
+ <AlertTriangle style={{ width: 32, height: 32, color: 'var(--danger)' }} />
977
+ </div>
978
+ </div>
979
+
980
+ <h2
981
+ style={{
982
+ fontSize: 24,
983
+ fontWeight: 700,
984
+ textAlign: 'center',
985
+ color: 'var(--foreground)',
986
+ margin: '0 0 8px',
987
+ }}
988
+ >
989
+ ${titleText}
990
+ </h2>
991
+ <p
992
+ style={{
993
+ fontSize: 14,
994
+ color: 'var(--foreground-muted)',
995
+ textAlign: 'center',
996
+ margin: '0 0 24px',
997
+ }}
998
+ >
999
+ ${descText}
1000
+ </p>
1001
+
1002
+ <div
1003
+ style={{
1004
+ borderRadius: 6,
1005
+ border: '1px solid color-mix(in srgb, var(--danger) 30%, transparent)',
1006
+ background: 'color-mix(in srgb, var(--danger) 5%, transparent)',
1007
+ padding: 12,
1008
+ }}
1009
+ >
1010
+ <p style={{ fontSize: 14, color: 'var(--danger)', margin: 0 }}>
1011
+ {error.message || ${fallback}}
1012
+ </p>
1013
+ </div>
1014
+
1015
+ <div style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
1016
+ <button
1017
+ onClick={reset}
1018
+ style={{
1019
+ width: '100%',
1020
+ display: 'flex',
1021
+ alignItems: 'center',
1022
+ justifyContent: 'center',
1023
+ gap: 8,
1024
+ padding: '8px 16px',
1025
+ borderRadius: 6,
1026
+ border: 'none',
1027
+ background: 'var(--primary)',
1028
+ color: 'var(--primary-foreground)',
1029
+ fontSize: 14,
1030
+ fontWeight: 500,
1031
+ cursor: 'pointer',
1032
+ }}
1033
+ >
1034
+ <RefreshCw style={{ width: 16, height: 16 }} />
1035
+ ${tryAgain}
1036
+ </button>
1037
+
1038
+ <Link
1039
+ href='/'
1040
+ style={{
1041
+ width: '100%',
1042
+ display: 'flex',
1043
+ alignItems: 'center',
1044
+ justifyContent: 'center',
1045
+ gap: 8,
1046
+ padding: '8px 16px',
1047
+ borderRadius: 6,
1048
+ border: '1px solid var(--border)',
1049
+ color: 'var(--foreground)',
1050
+ fontSize: 14,
1051
+ fontWeight: 500,
1052
+ textDecoration: 'none',
1053
+ }}
1054
+ >
1055
+ <Home style={{ width: 16, height: 16 }} />
1056
+ ${goHome}
1057
+ </Link>
1058
+ </div>
1059
+
1060
+ {process.env.NODE_ENV === 'development' && error.digest && (
1061
+ <div
1062
+ style={{
1063
+ marginTop: 16,
1064
+ borderRadius: 6,
1065
+ background: 'var(--background-subtle)',
1066
+ padding: 12,
1067
+ }}
1068
+ >
1069
+ <p style={{ fontSize: 12, color: 'var(--foreground-subtle)', margin: 0 }}>
1070
+ Error ID: {error.digest}
1071
+ </p>
1072
+ </div>
1073
+ )}
1074
+ </div>
1075
+ </div>
1076
+ );
1077
+ }
1078
+ `;
1079
+ }
1080
+
1081
+ function buildErrorModuleCss() {
1082
+ return `.wrapper {
1083
+ display: flex;
1084
+ min-height: 100vh;
1085
+ align-items: center;
1086
+ justify-content: center;
1087
+ padding: 0 16px;
1088
+ }
1089
+
1090
+ .card {
1091
+ width: 100%;
1092
+ max-width: 448px;
1093
+ border-radius: 8px;
1094
+ border: 1px solid var(--border);
1095
+ background: var(--background);
1096
+ padding: 24px;
1097
+ box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
1098
+ }
1099
+
1100
+ .iconRow {
1101
+ display: flex;
1102
+ justify-content: center;
1103
+ margin-bottom: 16px;
1104
+ }
1105
+
1106
+ .iconCircle {
1107
+ width: 64px;
1108
+ height: 64px;
1109
+ border-radius: 50%;
1110
+ background: color-mix(in srgb, var(--danger) 10%, transparent);
1111
+ display: flex;
1112
+ align-items: center;
1113
+ justify-content: center;
1114
+ }
1115
+
1116
+ .icon {
1117
+ width: 32px;
1118
+ height: 32px;
1119
+ color: var(--danger);
1120
+ }
1121
+
1122
+ .title {
1123
+ font-size: 24px;
1124
+ font-weight: 700;
1125
+ text-align: center;
1126
+ color: var(--foreground);
1127
+ margin: 0 0 8px;
1128
+ }
1129
+
1130
+ .description {
1131
+ font-size: 14px;
1132
+ color: var(--foreground-muted);
1133
+ text-align: center;
1134
+ margin: 0 0 24px;
1135
+ }
1136
+
1137
+ .errorBox {
1138
+ border-radius: 6px;
1139
+ border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
1140
+ background: color-mix(in srgb, var(--danger) 5%, transparent);
1141
+ padding: 12px;
1142
+ }
1143
+
1144
+ .errorText {
1145
+ font-size: 14px;
1146
+ color: var(--danger);
1147
+ margin: 0;
1148
+ }
1149
+
1150
+ .actions {
1151
+ margin-top: 24px;
1152
+ display: flex;
1153
+ flex-direction: column;
1154
+ gap: 12px;
1155
+ }
1156
+
1157
+ .primaryButton,
1158
+ .secondaryButton {
1159
+ width: 100%;
1160
+ display: flex;
1161
+ align-items: center;
1162
+ justify-content: center;
1163
+ gap: 8px;
1164
+ padding: 8px 16px;
1165
+ border-radius: 6px;
1166
+ font-size: 14px;
1167
+ font-weight: 500;
1168
+ cursor: pointer;
1169
+ text-decoration: none;
1170
+ }
1171
+
1172
+ .primaryButton {
1173
+ border: none;
1174
+ background: var(--primary);
1175
+ color: var(--primary-foreground);
1176
+ }
1177
+
1178
+ .secondaryButton {
1179
+ border: 1px solid var(--border);
1180
+ color: var(--foreground);
1181
+ background: transparent;
1182
+ }
1183
+
1184
+ .buttonIcon {
1185
+ width: 16px;
1186
+ height: 16px;
1187
+ }
1188
+
1189
+ .digest {
1190
+ margin-top: 16px;
1191
+ border-radius: 6px;
1192
+ background: var(--background-subtle);
1193
+ padding: 12px;
1194
+ }
1195
+
1196
+ .digestText {
1197
+ font-size: 12px;
1198
+ color: var(--foreground-subtle);
1199
+ margin: 0;
1200
+ }
1201
+ `;
1202
+ }
1203
+
513
1204
  /**
514
1205
  * 스캐폴드 마무리 — `gitignore` 파일을 `.gitignore` 로 되돌리고 `git init` 실행.
515
1206
  *
@@ -725,17 +1416,26 @@ async function composeProviders(targetDir, plugins, arch) {
725
1416
  }
726
1417
  }
727
1418
 
728
- // wrapper 적용: <ThemeProviders> 바깥쪽에 감싸기
1419
+ // wrapper 적용: <ThemeProvider>...</ThemeProvider> 블록 전체를 잡아 새 wrapper 로 감싼다.
1420
+ // 인덴트 일관성 보장 — 단순 prefix 삽입으로는 내부 라인의 들여쓰기가 wrapper 추가
1421
+ // 깊이만큼 따라 들어가지 않아 들쭉날쭉했다 (v0.59.x 까지의 이슈).
1422
+ // 이제는 블록을 통째로 매칭해 내부 모든 줄에 +2 spaces, 같은 인덴트 레벨에 wrapper
1423
+ // 태그를 두고 재구성한다.
1424
+ const blockRegex = /([ \t]*)(<ThemeProvider>[\s\S]*?<\/ThemeProvider>)/;
729
1425
  for (const wrapper of wrappers) {
730
1426
  if (content.includes(`<${wrapper}>`)) continue;
731
- content = content.replace(
732
- /(<ThemeProviders>)/,
733
- `<${wrapper}>\n $1`,
734
- );
735
- content = content.replace(
736
- /(<\/ThemeProviders>)/,
737
- `$1\n </${wrapper}>`,
738
- );
1427
+ const match = content.match(blockRegex);
1428
+ if (!match) continue;
1429
+ const [, indent, block] = match;
1430
+ // 블록 내부 모든 줄에 2 space 추가. 첫 줄은 아래 템플릿의 prefix 가 처리하므로
1431
+ // newline 뒤에만 spaces 를 끼워넣는다.
1432
+ const indentedBlock = block.replace(/\n/g, '\n ');
1433
+ const replacement = [
1434
+ `${indent}<${wrapper}>`,
1435
+ `${indent} ${indentedBlock}`,
1436
+ `${indent}</${wrapper}>`,
1437
+ ].join('\n');
1438
+ content = content.replace(blockRegex, replacement);
739
1439
  }
740
1440
 
741
1441
  await fs.writeFile(globalProviderPath, content);