sh-ui-cli 0.86.1 → 0.88.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 (46) hide show
  1. package/data/changelog/versions.json +27 -0
  2. package/package.json +1 -1
  3. package/src/create/cli-args.js +1 -1
  4. package/src/create/describeTemplate.js +64 -11
  5. package/src/create/generator.js +199 -9
  6. package/src/create/index.mjs +1 -0
  7. package/src/create/templateManifest.js +53 -0
  8. package/src/mcp.mjs +27 -1
  9. package/templates/tauri-shell/Cargo.toml +21 -0
  10. package/templates/tauri-shell/README.md +49 -0
  11. package/templates/tauri-shell/build.rs +3 -0
  12. package/templates/tauri-shell/capabilities/default.json +12 -0
  13. package/templates/tauri-shell/src/lib.rs +8 -0
  14. package/templates/tauri-shell/src/main.rs +6 -0
  15. package/templates/tauri-shell/tauri.conf.json +29 -0
  16. package/templates/vite-app/_arch/flat/src/App.tsx +13 -0
  17. package/templates/vite-app/_arch/flat/src/components/layouts/RootLayout.tsx +5 -0
  18. package/templates/vite-app/_arch/flat/src/components/providers/GlobalProvider/index.tsx +13 -0
  19. package/templates/vite-app/_arch/flat/src/components/providers/index.tsx +1 -0
  20. package/templates/vite-app/_arch/flat/src/components/providers/theme/ThemeProvider.tsx +59 -0
  21. package/templates/vite-app/_arch/flat/src/lib/api/queryClient.ts +12 -0
  22. package/templates/vite-app/_arch/flat/src/lib/hooks/useTheme.ts +8 -0
  23. package/templates/vite-app/_arch/flat/src/lib/utils/utils.ts +6 -0
  24. package/templates/vite-app/_arch/flat/src/main.tsx +10 -0
  25. package/templates/vite-app/_arch/flat/tsconfig.app.json +19 -0
  26. package/templates/vite-app/_arch/fsd/src/App.tsx +13 -0
  27. package/templates/vite-app/_arch/fsd/src/app/layouts/RootLayout.tsx +5 -0
  28. package/templates/vite-app/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +13 -0
  29. package/templates/vite-app/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +59 -0
  30. package/templates/vite-app/_arch/fsd/src/main.tsx +10 -0
  31. package/templates/vite-app/_arch/fsd/src/shared/api/queryClient.ts +12 -0
  32. package/templates/vite-app/_arch/fsd/src/shared/hooks/useTheme.ts +8 -0
  33. package/templates/vite-app/_arch/fsd/src/shared/lib/utils.ts +6 -0
  34. package/templates/vite-app/_arch/fsd/tsconfig.app.json +18 -0
  35. package/templates/vite-app/eslint.config.js +3 -0
  36. package/templates/vite-app/gitignore +8 -0
  37. package/templates/vite-app/index.html +22 -0
  38. package/templates/vite-app/package.json +48 -0
  39. package/templates/vite-app/src/App.tsx +5 -0
  40. package/templates/vite-app/src/Home.tsx +7 -0
  41. package/templates/vite-app/src/main.tsx +9 -0
  42. package/templates/vite-app/tsconfig.json +7 -0
  43. package/templates/vite-app/tsconfig.node.json +12 -0
  44. package/templates/vite-app/vite.config.ts +11 -0
  45. package/templates/vite-app/vitest.config.ts +13 -0
  46. package/templates/vite-app/vitest.setup.ts +1 -0
@@ -2,6 +2,33 @@
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.88.0",
7
+ "date": "2026-05-14",
8
+ "title": "Tauri 2.x 데스크탑 셸 — sh_ui_create_project platform=vite tauri=true",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`--tauri` 플래그 + MCP `tauri: true` 옵션** — `sh-ui-cli create --platform vite --structure standalone --tauri` 한 줄로 Vite SPA + Tauri 2.x 네이티브 셸 (`src-tauri/`) 일괄 emit. ai-org 같은 local-first 데스크탑 앱의 1급 진입점. v0.86.0 의 vite 프리셋과 짝 (Q1-Q5 design decisions 모두 confirmed: assume Rust installed, `src-tauri/` sibling layout, minimal capabilities, identifier placeholder, Tauri 2.x only).",
12
+ "**Tauri 2.x idioms 표준 준수** — `Cargo.toml` 의 lib name 은 snake_case (`{tauri_crate_name}_lib`), `tauri.conf.json` 의 `identifier` 는 `app.{crate}.dev` 플레이스홀더 (README 에 production 전 교체 TODO 명시), `capabilities/default.json` 은 `core:default` + `opener:default` 만 — fs/dialog/shell 권한은 사용자가 명시적 opt-in.",
13
+ "**Vite 패치 자동 적용** — Tauri 권장값(`clearScreen: false`, `server.strictPort: true`, `server.host: false`, `server.port: 5173`) 을 `vite.config.ts` 에 emit. `@tauri-apps/cli` (devDep), `@tauri-apps/api` + `@tauri-apps/plugin-opener` (dep) + `tauri`/`tauri:dev`/`tauri:build` scripts 자동 추가. `.gitignore` 에 `src-tauri/target/` 추가 (Rust 빌드 산출물 보호).",
14
+ "**가드 + 회귀 테스트** — `tauri: true + platform != vite` 또는 `tauri: true + structure == monorepo` 는 CLI 진입점 + MCP 진입점 양쪽에서 명시적 Korean 에러. monorepo + Tauri 는 v0.89 후속. smoke V7a-f 6 개 시나리오로 회귀 가드 (file emission, placeholder substitution, package/vite/gitignore patches, opt-out 회귀, 잘못된 조합 거부)."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.88.0"
17
+ },
18
+ {
19
+ "version": "0.87.0",
20
+ "date": "2026-05-14",
21
+ "title": "Vite monorepo — apps/{name} + packages/ui/ui-core 공유 SoT",
22
+ "type": "minor",
23
+ "highlights": [
24
+ "**`sh-ui-cli create --platform vite --structure monorepo`** — vite 도 monorepo 진입점이 됨. ai-org 같은 단일 vite SPA 시나리오를 넘어, 여러 vite 앱이 packages/ui/ui-core 의 컴포넌트를 공유하는 워크스페이스 구조까지 1급 지원. apps/{name}/ (vite-app 템플릿) + packages/ui/ui-core/ (v0.65 단일 SoT) + packages/ui/ui-apps/ui-{name}/ (tokens-only role) 자동 emit.",
25
+ "**vite-app 템플릿 신설** — packages/cli/templates/vite-app/ 추가 (31 파일, base + flat + fsd overlays). nextjs-app 과 동일 패턴이지만 vite 5 + @tailwindcss/vite + vite-tsconfig-paths 기반. `@workspace/ui-core` / `@workspace/ui-{name}` workspace 별칭은 tsconfig.app.json 단일 SoT 로 핀고정, vite 빌드 타임에는 vite-tsconfig-paths 가 그대로 해석. workspace 별칭과 빌드 별칭이 어긋날 일 없음.",
26
+ "**generateMonorepo 가 platform 분기** — { platform: 'next' | 'vite' } 옵션 추가. vite 일 때 generateViteApp 으로 위임 (next 의 plugin pipeline / next.config 분기 없이 vite.config.ts 의 server.port 만 patch). ui-app-template + monorepo 베이스 + ui-core 는 두 플랫폼이 공유 — 코드 중복 없음.",
27
+ "**describeTemplate vite monorepo 분기** — monorepo 루트 + apps/{name} 베이스 + arch 오버레이 + ui-app 패키지 4 개 그룹으로 미리보기 산출. apps/docs CreateProjectDialog 와 MCP `sh_ui_describe_template` 둘 다 동일 결과 제공.",
28
+ "**스모크 회귀 가드** — `scenario V6` 가 vite 모노레포 스캐폴드의 파일 트리 + tsconfig workspace 별칭 치환 + ui-app role: tokens-only 확정. 빌드 사이클(`pnpm install + pnpm build`)은 Task E manual 에서 fsd/flat 양쪽 PASS — `composite: true` 와 base.json 의 `incremental: false` 충돌 + `clsx`/`tailwind-merge` 누락 두 가지를 사전 차단."
29
+ ],
30
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.87.0"
31
+ },
5
32
  {
6
33
  "version": "0.86.1",
7
34
  "date": "2026-05-14",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.86.1",
3
+ "version": "0.88.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,7 +13,7 @@ const VALID_PLUGINS = allPlugins.map((p) => p.name);
13
13
  const VALID_ARCHES = allArchitectures.map((a) => a.name);
14
14
 
15
15
  const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port'];
16
- const BOOL_FLAGS = ['yes', 'help', 'dry-run'];
16
+ const BOOL_FLAGS = ['yes', 'help', 'dry-run', 'tauri'];
17
17
 
18
18
  const SUBCOMMANDS = ['add-app', 'add-component'];
19
19
 
@@ -35,6 +35,7 @@ import { CSS_FRAMEWORK_DEFAULT } from '../constants.js';
35
35
  * @property {'tailwind'|'plain'|'css-modules'} [cssFramework]
36
36
  * @property {string} [projectName]
37
37
  * @property {string} [appName] monorepo 첫 앱 이름. 기본 'web'
38
+ * @property {boolean} [tauri] platform=vite + structure=standalone 일 때 Tauri 2.x 셸 같이 emit
38
39
  */
39
40
 
40
41
  /**
@@ -62,6 +63,7 @@ export function describeTemplate(opts = {}) {
62
63
  plugins: pluginNames = [],
63
64
  cssFramework = CSS_FRAMEWORK_DEFAULT,
64
65
  appName: rawAppName = 'web',
66
+ tauri = false,
65
67
  } = opts;
66
68
 
67
69
  if (platform === 'flutter') {
@@ -72,22 +74,73 @@ export function describeTemplate(opts = {}) {
72
74
  }
73
75
 
74
76
  if (platform === 'vite') {
75
- // standalone only in Phase 1. monorepo is added in Task 13.
76
- const tpl = TEMPLATE_MANIFEST['vite-standalone'];
77
- if (!tpl) {
78
- throw new Error("Template manifest missing entry for 'vite-standalone'.");
79
- }
80
77
  const safeArchName = isKnownArch(archName) ? archName : DEFAULT_ARCH;
81
78
  const archObj = getArchByName(safeArchName);
82
79
  if (!archObj.platforms.includes('vite')) {
83
80
  throw new Error(`Arch '${safeArchName}' is not compatible with vite.`);
84
81
  }
85
- const baseFiles = tpl.base.slice();
86
- const archFiles = (tpl.arches?.[safeArchName] ?? []).slice();
87
- return finalize([
88
- makeGroup('base', '베이스 (vite-standalone)', baseFiles),
89
- makeGroup('arch', `Arch (${safeArchName})`, archFiles),
90
- ]);
82
+
83
+ if (structure === 'standalone') {
84
+ const tpl = TEMPLATE_MANIFEST['vite-standalone'];
85
+ if (!tpl) {
86
+ throw new Error("Template manifest missing entry for 'vite-standalone'.");
87
+ }
88
+ const baseFiles = tpl.base.slice();
89
+ const archFiles = (tpl.arches?.[safeArchName] ?? []).slice();
90
+ const groups = [
91
+ makeGroup('base', '베이스 (vite-standalone)', baseFiles),
92
+ makeGroup('arch', `Arch (${safeArchName})`, archFiles),
93
+ ];
94
+ if (tauri) {
95
+ const tauriTpl = TEMPLATE_MANIFEST['tauri-shell'];
96
+ if (!tauriTpl) {
97
+ throw new Error("Template manifest missing entry for 'tauri-shell'.");
98
+ }
99
+ const tauriFiles = tauriTpl.base.map((p) => `src-tauri/${p}`);
100
+ groups.push(makeGroup('tauri', 'Tauri 셸 (src-tauri/)', tauriFiles));
101
+ }
102
+ return finalize(groups);
103
+ }
104
+
105
+ // monorepo — vite app 변형. Next monorepo 브랜치와 동일 구조이지만 vite-app 템플릿
106
+ // 사용 + 플러그인 없음 (vite 는 아직 plugin 시스템 없음).
107
+ const appName = rawAppName || 'web';
108
+ const viteAppTpl = TEMPLATE_MANIFEST['vite-app'];
109
+ if (!viteAppTpl) {
110
+ throw new Error("Template manifest missing entry for 'vite-app'.");
111
+ }
112
+ const groups = [];
113
+
114
+ groups.push(makeGroup(
115
+ 'monorepo',
116
+ '모노레포 루트',
117
+ TEMPLATE_MANIFEST['monorepo'].base.slice(),
118
+ ));
119
+
120
+ const prefix = `apps/${appName}/`;
121
+ groups.push(makeGroup(
122
+ `app-base`,
123
+ `apps/${appName} — vite-app 베이스`,
124
+ viteAppTpl.base.map((p) => prefix + p),
125
+ ));
126
+ const appArchFiles = (viteAppTpl.arches?.[safeArchName] ?? []).map((p) => prefix + p);
127
+ if (appArchFiles.length > 0) {
128
+ groups.push(makeGroup(
129
+ `app-arch`,
130
+ `apps/${appName} — Arch (${safeArchName})`,
131
+ appArchFiles,
132
+ ));
133
+ }
134
+
135
+ groups.push(makeGroup(
136
+ 'ui-app',
137
+ `packages/ui/ui-apps/ui-${appName}`,
138
+ TEMPLATE_MANIFEST['ui-app-template'].base.map(
139
+ (p) => `packages/ui/ui-apps/ui-${appName}/${p}`,
140
+ ),
141
+ ));
142
+
143
+ return finalize(groups);
91
144
  }
92
145
 
93
146
  // platform === 'next'
@@ -213,6 +213,23 @@ export async function createProject(options = {}) {
213
213
  ],
214
214
  });
215
215
 
216
+ // tauri 옵션은 platform=vite + structure=standalone 일 때만 의미. 다른 조합은 명시적 에러.
217
+ // (MCP 진입점은 mcp.mjs 가 이미 동일 가드 — CLI 직접 호출에도 동일 안전망.)
218
+ if (options.tauri) {
219
+ if (platform !== 'vite') {
220
+ throw new Error(
221
+ `tauri: true 는 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). ` +
222
+ `--tauri 옵션 제거 또는 --platform vite 사용.`,
223
+ );
224
+ }
225
+ if (options.structure === 'monorepo') {
226
+ throw new Error(
227
+ 'platform=vite + structure=monorepo + tauri=true 는 아직 지원 안 함 (v0.89 후속). ' +
228
+ 'standalone 으로 시도하거나 tauri 옵션 제거.',
229
+ );
230
+ }
231
+ }
232
+
216
233
  // arch 결정 — platform 확정 후. 사용자가 --arch 미지정 시:
217
234
  // - next → DEFAULT_ARCH ('fsd')
218
235
  // - flutter → 현재 Flutter arch 디스크립터 없음 → null. 미래에 flutter arch 추가되면
@@ -333,13 +350,16 @@ export async function createProject(options = {}) {
333
350
  });
334
351
 
335
352
  if (projectType === 'standalone') {
336
- await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase);
353
+ await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase, { tauri: !!options.tauri });
337
354
  } else {
338
- // monorepo path is added in Task 12. For now, fail loudly so users know.
339
- throw new Error(
340
- 'platform=vite + structure=monorepo 는 아직 구현되지 않았습니다 (Phase 2 — v0.87 예정). ' +
341
- 'standalone 사용하거나 platform=next monorepo 만든 vite 앱을 수동으로 추가해주세요.',
342
- );
355
+ // monorepo + tauri v0.89 후속 명시적 에러
356
+ if (options.tauri) {
357
+ throw new Error(
358
+ 'platform=vite + structure=monorepo + tauri=true 아직 지원 (v0.89 후속). ' +
359
+ 'standalone 으로 시도하거나 tauri 옵션 제거.',
360
+ );
361
+ }
362
+ await generateMonorepo(targetDir, projectName, [], { yes: options.yes, theme, css: cssFramework, arch, themeBase, platform: 'vite' });
343
363
  }
344
364
 
345
365
  await finalizeProject(targetDir, { dryRun: options.dryRun });
@@ -357,6 +377,11 @@ export async function createProject(options = {}) {
357
377
  console.log(`\n cd ${projectName}`);
358
378
  console.log(' pnpm install');
359
379
  console.log(' pnpm dev\n');
380
+ if (options.tauri && projectType === 'standalone') {
381
+ console.log('Tauri 데스크탑 셸:');
382
+ console.log(' pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시 후 5~10초');
383
+ console.log(' (Rust 미설치 시 https://rustup.rs/ 참고)\n');
384
+ }
360
385
  console.log('다음 단계 — 베이스 컴포넌트 추가 (예시):');
361
386
  console.log(' npx sh-ui-cli add button card input dialog\n');
362
387
  return;
@@ -735,7 +760,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
735
760
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
736
761
  }
737
762
 
738
- async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase) {
763
+ async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { tauri = false } = {}) {
739
764
  // 베이스 (arch-neutral) + arch 오버레이 — generateStandalone 과 같은 패턴.
740
765
  await fs.copy(path.join(TEMPLATES_DIR, 'vite-standalone'), targetDir, {
741
766
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
@@ -770,9 +795,109 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
770
795
  await applyCssFrameworkVariant(targetDir, css, { isMonorepo: false, plugins: [], arch });
771
796
  await injectCssTheme(targetDir, theme);
772
797
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
798
+
799
+ // Tauri 셸 emit (옵션) — vite SPA + native window. standalone 만 v1 지원.
800
+ if (tauri) {
801
+ await emitTauri(targetDir, projectName);
802
+ await patchViteForTauri(targetDir);
803
+ }
773
804
  }
774
805
 
775
- async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase } = {}) {
806
+ /**
807
+ * tauri-shell 템플릿을 `<targetDir>/src-tauri/` 로 복사하고 placeholder 치환.
808
+ *
809
+ * - `{{project_name}}` → projectName (kebab-case 유지, npm 패키지명과 동일)
810
+ * - `{{tauri_crate_name}}` → snake_case 변환 (Rust crate name 규칙: 영숫자+언더스코어).
811
+ * 하이픈/점/대문자가 들어 있으면 모두 안전한 형태로 정규화.
812
+ *
813
+ * generateViteStandalone 에서 tauri: true 인 경우 호출. monorepo+tauri 는 v0.89 후속.
814
+ */
815
+ async function emitTauri(targetDir, projectName) {
816
+ const srcTauriDir = path.join(targetDir, 'src-tauri');
817
+ await fs.copy(path.join(TEMPLATES_DIR, 'tauri-shell'), srcTauriDir);
818
+
819
+ // crate name: snake_case 강제 — Rust 식별자는 영숫자+'_' 만 허용
820
+ const tauriCrateName = projectName
821
+ .toLowerCase()
822
+ .replace(/[^a-z0-9]+/g, '_')
823
+ .replace(/^_+|_+$/g, '');
824
+
825
+ await replaceInAllFiles(srcTauriDir, '{{tauri_crate_name}}', tauriCrateName);
826
+ await replaceInAllFiles(srcTauriDir, '{{project_name}}', projectName);
827
+ }
828
+
829
+ /**
830
+ * vite 앱의 package.json + vite.config.ts 를 Tauri 친화적으로 패치.
831
+ *
832
+ * - package.json: `@tauri-apps/cli` (devDep), `@tauri-apps/api` (dep), `tauri`/`tauri:dev`/`tauri:build` scripts 추가
833
+ * - vite.config.ts: Tauri 공식 권장값 추가 — `clearScreen: false`, `server.strictPort: true`,
834
+ * `server.host: false`, `server.port: 5173`. 그래야 Tauri 가 dev server 를 안정적으로 wrap.
835
+ *
836
+ * NOTE: vite.config.ts 를 전부 재작성한다. 현재 base template 의 vite.config.ts 는 arch-neutral
837
+ * 이라 안전. 후속 task 에서 arch-specific vite.config.ts overlay 가 생기면 이 자리에서 머지 전략
838
+ * 필요 (현재는 단순 overwrite).
839
+ */
840
+ async function patchViteForTauri(targetDir) {
841
+ const pkgPath = path.join(targetDir, 'package.json');
842
+ const pkg = await fs.readJson(pkgPath);
843
+
844
+ pkg.dependencies = pkg.dependencies ?? {};
845
+ pkg.devDependencies = pkg.devDependencies ?? {};
846
+ pkg.scripts = pkg.scripts ?? {};
847
+
848
+ pkg.dependencies['@tauri-apps/api'] = '^2.0.0';
849
+ pkg.dependencies['@tauri-apps/plugin-opener'] = '^2.0.0';
850
+ pkg.devDependencies['@tauri-apps/cli'] = '^2.0.0';
851
+
852
+ pkg.scripts.tauri = 'tauri';
853
+ pkg.scripts['tauri:dev'] = 'tauri dev';
854
+ pkg.scripts['tauri:build'] = 'tauri build';
855
+
856
+ pkg.dependencies = sortObjectKeys(pkg.dependencies);
857
+ pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
858
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
859
+
860
+ // vite.config.ts 재작성 — Tauri 공식 권장 설정 추가.
861
+ const viteCfgPath = path.join(targetDir, 'vite.config.ts');
862
+ const viteCfg = `import { defineConfig } from 'vite';
863
+ import react from '@vitejs/plugin-react';
864
+ import tailwindcss from '@tailwindcss/vite';
865
+ import tsconfigPaths from 'vite-tsconfig-paths';
866
+
867
+ // Tauri 권장 설정 (https://v2.tauri.app/start/frontend/vite/)
868
+ // - clearScreen: false — Rust 컴파일 에러가 터미널을 가리지 않게
869
+ // - server.strictPort — Tauri 가 사용할 포트를 고정 (충돌 시 에러)
870
+ // - server.host: false — Tauri dev 가 host network 안 열어도 됨
871
+ export default defineConfig({
872
+ plugins: [react(), tailwindcss(), tsconfigPaths()],
873
+ clearScreen: false,
874
+ server: {
875
+ port: 5173,
876
+ strictPort: true,
877
+ host: false,
878
+ },
879
+ });
880
+ `;
881
+ await fs.writeFile(viteCfgPath, viteCfg);
882
+
883
+ // .gitignore 에 src-tauri/target 추가 — Rust 빌드 산출물 (수 GB 가능).
884
+ // 스캐폴드 단계에서는 파일명이 `gitignore` (점 없음); finalizeProject 가 나중에 `.gitignore` 로 rename.
885
+ // 양쪽 이름 모두 시도해서 호출 순서가 달라져도 안전하게 적용.
886
+ const gitignoreCandidates = ['.gitignore', 'gitignore'];
887
+ for (const name of gitignoreCandidates) {
888
+ const p = path.join(targetDir, name);
889
+ if (await fs.pathExists(p)) {
890
+ let ignore = await fs.readFile(p, 'utf-8');
891
+ if (!ignore.includes('src-tauri/target')) {
892
+ ignore += `\n# Tauri build artifacts\nsrc-tauri/target/\n`;
893
+ await fs.writeFile(p, ignore);
894
+ }
895
+ break;
896
+ }
897
+ }
898
+ }
899
+
900
+ async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next' } = {}) {
776
901
  await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
777
902
 
778
903
  // Update root package.json
@@ -803,7 +928,11 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
803
928
  });
804
929
 
805
930
  const appsDir = path.join(targetDir, 'apps', appName);
806
- await generateApp(appsDir, appName, port, plugins, arch, css);
931
+ if (platform === 'vite') {
932
+ await generateViteApp(appsDir, appName, port, arch, css);
933
+ } else {
934
+ await generateApp(appsDir, appName, port, plugins, arch, css);
935
+ }
807
936
  // generateApp 이 ui-{app} 패키지의 cssFramework 변종까지 처리. 여기선 theme + sh-ui.config.json 만.
808
937
  const uiAppDir = path.join(targetDir, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
809
938
  await injectCssTheme(uiAppDir, theme);
@@ -901,6 +1030,67 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
901
1030
  }
902
1031
  }
903
1032
 
1033
+ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind') {
1034
+ // 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
1035
+ await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
1036
+ filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
1037
+ });
1038
+ await ensureArchCleanup(targetDir);
1039
+ await fs.copy(
1040
+ path.join(TEMPLATES_DIR, 'vite-app', '_arch', arch.name),
1041
+ targetDir,
1042
+ { overwrite: true },
1043
+ );
1044
+ // vite-app 의 flat overlay 는 src/ 하위 — arch.paths.layouts(next 관용) 앞에 src/ 보정.
1045
+ // (generateViteStandalone 의 동일 인라인 가드와 같은 이유 — fsd 는 이미 src/app/layouts.)
1046
+ const layoutsPath = arch.paths.layouts.startsWith('src/')
1047
+ ? arch.paths.layouts
1048
+ : `src/${arch.paths.layouts}`;
1049
+ const sentinelPath = path.join(targetDir, `${layoutsPath}/RootLayout.tsx`);
1050
+ if (!(await fs.pathExists(sentinelPath))) {
1051
+ throw new Error(
1052
+ `arch 오버레이 누락: vite-app + ${arch.name} 의 sentinel 파일(${layoutsPath}/RootLayout.tsx) 이 ${targetDir} 에 없습니다.`,
1053
+ );
1054
+ }
1055
+
1056
+ // 워크스페이스 placeholder 치환 — `ui-app-name` → `ui-{appName}`, `app-name` → `{appName}`.
1057
+ await replaceInAllFiles(targetDir, 'ui-app-name', `ui-${appName}`);
1058
+ await replaceInAllFiles(targetDir, 'app-name', appName);
1059
+
1060
+ // package.json — name + dep sort
1061
+ const pkgPath = path.join(targetDir, 'package.json');
1062
+ const pkg = await fs.readJson(pkgPath);
1063
+ pkg.name = appName;
1064
+ if (pkg.dependencies) pkg.dependencies = sortObjectKeys(pkg.dependencies);
1065
+ if (pkg.devDependencies) pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
1066
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
1067
+
1068
+ // vite.config.ts 의 server.port 를 사용자 지정 port 로 patch.
1069
+ // generateMonorepo 가 받은 port 가 next 의 --port 와 같은 의미로 흐른다.
1070
+ const viteCfgPath = path.join(targetDir, 'vite.config.ts');
1071
+ if (await fs.pathExists(viteCfgPath)) {
1072
+ let viteCfg = await fs.readFile(viteCfgPath, 'utf-8');
1073
+ viteCfg = viteCfg.replace(/port:\s*\d+/, `port: ${port}`);
1074
+ await fs.writeFile(viteCfgPath, viteCfg);
1075
+ }
1076
+
1077
+ // ui-{appName} 패키지 생성 — generateApp 과 동일 패턴 (ui-app-template 카피 후 placeholder 치환).
1078
+ const monorepoRoot = path.resolve(targetDir, '..', '..');
1079
+ const uiPkgDir = path.join(monorepoRoot, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
1080
+ if (!(await fs.pathExists(uiPkgDir))) {
1081
+ await fs.copy(path.join(TEMPLATES_DIR, 'ui-app-template'), uiPkgDir);
1082
+ await replaceInAllFiles(uiPkgDir, 'ui-app-name', `ui-${appName}`);
1083
+ await replaceInAllFiles(uiPkgDir, 'app-name', appName);
1084
+ }
1085
+
1086
+ // cssFramework 변종 — vite app 디렉토리 + ui-app 패키지 양쪽.
1087
+ // 플러그인 없음 (vite 는 아직 플러그인 시스템 없음 — v0.87 스코프 밖).
1088
+ await applyCssFrameworkVariant(targetDir, css, { isMonorepo: true, plugins: [], arch });
1089
+ if (await fs.pathExists(uiPkgDir)) {
1090
+ await applyCssFrameworkVariant(uiPkgDir, css, { isMonorepo: true, plugins: [], arch, isUiPackage: true });
1091
+ }
1092
+ }
1093
+
904
1094
  /**
905
1095
  * 베이스 템플릿 카피 직후 `_arch/` 잔여 정리.
906
1096
  *
@@ -89,6 +89,7 @@ export async function runCreate(rest) {
89
89
  arch: flags.arch,
90
90
  theme: flags.theme,
91
91
  css: flags.css,
92
+ tauri: flags.tauri,
92
93
  yes: flags.yes,
93
94
  dryRun: flags.dryRun,
94
95
  });
@@ -314,6 +314,18 @@ export const TEMPLATE_MANIFEST = {
314
314
  ]
315
315
  }
316
316
  },
317
+ "tauri-shell": {
318
+ "base": [
319
+ ".gitignore",
320
+ "Cargo.toml",
321
+ "README.md",
322
+ "build.rs",
323
+ "capabilities/default.json",
324
+ "src/lib.rs",
325
+ "src/main.rs",
326
+ "tauri.conf.json"
327
+ ]
328
+ },
317
329
  "ui-app-template": {
318
330
  "base": [
319
331
  "eslint.config.js",
@@ -325,6 +337,47 @@ export const TEMPLATE_MANIFEST = {
325
337
  "tsconfig.json"
326
338
  ]
327
339
  },
340
+ "vite-app": {
341
+ "base": [
342
+ "eslint.config.js",
343
+ "gitignore",
344
+ "index.html",
345
+ "package.json",
346
+ "src/App.tsx",
347
+ "src/Home.tsx",
348
+ "src/main.tsx",
349
+ "tsconfig.json",
350
+ "tsconfig.node.json",
351
+ "vite.config.ts",
352
+ "vitest.config.ts",
353
+ "vitest.setup.ts"
354
+ ],
355
+ "arches": {
356
+ "flat": [
357
+ "src/App.tsx",
358
+ "src/components/layouts/RootLayout.tsx",
359
+ "src/components/providers/GlobalProvider/index.tsx",
360
+ "src/components/providers/index.tsx",
361
+ "src/components/providers/theme/ThemeProvider.tsx",
362
+ "src/lib/api/queryClient.ts",
363
+ "src/lib/hooks/useTheme.ts",
364
+ "src/lib/utils/utils.ts",
365
+ "src/main.tsx",
366
+ "tsconfig.app.json"
367
+ ],
368
+ "fsd": [
369
+ "src/App.tsx",
370
+ "src/app/layouts/RootLayout.tsx",
371
+ "src/app/providers/GlobalProvider/index.tsx",
372
+ "src/app/providers/theme/ThemeProvider.tsx",
373
+ "src/main.tsx",
374
+ "src/shared/api/queryClient.ts",
375
+ "src/shared/hooks/useTheme.ts",
376
+ "src/shared/lib/utils.ts",
377
+ "tsconfig.app.json"
378
+ ]
379
+ }
380
+ },
328
381
  "vite-standalone": {
329
382
  "base": [
330
383
  "CLAUDE.md",
package/src/mcp.mjs CHANGED
@@ -366,7 +366,8 @@ export async function startMcpServer() {
366
366
  "sh_ui_create_project",
367
367
  {
368
368
  description:
369
- "빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone) | Flutter. " +
369
+ "빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone/monorepo) | Flutter. " +
370
+ "+ vite + standalone 인 경우 `tauri: true` 로 Tauri 2.x 데스크탑 셸 (`src-tauri/`) 까지 한 번에 emit (Rust toolchain 필요). " +
370
371
  `FSD 폴더 구조 + 토큰 + sh-ui.config.json 일괄 생성. 사용자가 '새 프로젝트' / '빈 폴더' / '스캐폴드부터' 류 요청을 하면 이 툴 사용 (Bash 로 npx ${cliName} create 직접 호출보다 우선). ` +
371
372
  "**단일 진입점** — theme/plugins/cssFramework/structure 모두 호출 시점에 정해서 한 번에 박는다. 호출 후 sh-ui.config.json/tokens.css 를 손으로 패치하지 말 것 (다음 재스캐폴드 시 유실). " +
372
373
  "산출물: theme 인자가 프리셋이면 sh-ui.config.json 의 theme.base 가 그 이름, base64 면 'custom'. paths.styles · paths.tokens 도 자동 박혀서 sh_ui_add_component 가 사후 패치 없이 동작.",
@@ -394,6 +395,12 @@ export async function startMcpServer() {
394
395
  .describe("부모 디렉토리. 기본 process.cwd()"),
395
396
  force: z.boolean().optional()
396
397
  .describe("기존 디렉토리 덮어쓰기. 기본 false (안전)"),
398
+ tauri: z.boolean().optional()
399
+ .describe(
400
+ "Tauri 2.x 데스크탑 셸 (`src-tauri/`) 함께 emit. platform=vite + structure=standalone 일 때만 지원. " +
401
+ "Rust toolchain (`cargo`/`rustc`) 가 시스템에 설치되어 있어야 첫 `pnpm tauri dev` 가 동작. " +
402
+ "기본 false. monorepo + tauri 는 v0.89 후속.",
403
+ ),
397
404
  },
398
405
  },
399
406
  async (input) => {
@@ -415,6 +422,24 @@ export async function startMcpServer() {
415
422
  } catch (e) {
416
423
  return { isError: true, content: [{ type: "text", text: e.message }] };
417
424
  }
425
+ if (input.tauri && input.platform !== "vite") {
426
+ return {
427
+ isError: true,
428
+ content: [{
429
+ type: "text",
430
+ text: "tauri: true 는 platform=vite 일 때만 지원합니다 (현재 platform=" + input.platform + ").",
431
+ }],
432
+ };
433
+ }
434
+ if (input.tauri && input.structure === "monorepo") {
435
+ return {
436
+ isError: true,
437
+ content: [{
438
+ type: "text",
439
+ text: "platform=vite + structure=monorepo + tauri=true 는 아직 지원 안 함 (v0.89 후속). standalone 으로 시도하거나 tauri 옵션 제거.",
440
+ }],
441
+ };
442
+ }
418
443
  const targetParent = resolveCwd(input);
419
444
  const targetDir = resolve(targetParent, input.name);
420
445
  if (existsSync(targetDir) && !input.force) {
@@ -440,6 +465,7 @@ export async function startMcpServer() {
440
465
  arch: input.arch,
441
466
  theme: input.theme,
442
467
  css: input.cssFramework,
468
+ tauri: input.tauri,
443
469
  yes: true, // 사전 검사를 마쳤으니 generator 의 confirm 프롬프트 우회
444
470
  }),
445
471
  );
@@ -0,0 +1,21 @@
1
+ [package]
2
+ name = "{{tauri_crate_name}}"
3
+ version = "0.1.0"
4
+ description = "{{project_name}} desktop shell"
5
+ authors = ["you"]
6
+ edition = "2021"
7
+
8
+ # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
9
+
10
+ [lib]
11
+ name = "{{tauri_crate_name}}_lib"
12
+ crate-type = ["staticlib", "cdylib", "rlib"]
13
+
14
+ [build-dependencies]
15
+ tauri-build = { version = "2", features = [] }
16
+
17
+ [dependencies]
18
+ tauri = { version = "2", features = [] }
19
+ tauri-plugin-opener = "2"
20
+ serde = { version = "1", features = ["derive"] }
21
+ serde_json = "1"
@@ -0,0 +1,49 @@
1
+ # {{project_name}} — Tauri 데스크탑 셸
2
+
3
+ 이 디렉토리는 Tauri 2.x 의 Rust 코드입니다. 부모 디렉토리의 vite SPA 가
4
+ 프론트엔드, 이 디렉토리가 native 윈도우 셸.
5
+
6
+ ## 첫 실행
7
+
8
+ ```bash
9
+ # 부모 디렉토리에서 (vite 앱 루트)
10
+ pnpm install
11
+ pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시되면 이후 5~10초
12
+ ```
13
+
14
+ Rust toolchain (`cargo`, `rustc`) 이 시스템에 설치되어 있어야 합니다. 없으면
15
+ https://rustup.rs/ 참고.
16
+
17
+ ## 프로덕션 빌드 전 체크리스트
18
+
19
+ 1. **Bundle identifier** — `tauri.conf.json` 의 `identifier: "app.{{tauri_crate_name}}.dev"` 를
20
+ 실제 도메인 기반 unique ID 로 교체 (예: `com.yourcompany.{{tauri_crate_name}}`).
21
+ 동일 ID 로 publish 된 다른 앱과 충돌 시 OS install 이 깨질 수 있음.
22
+ 2. **Icons** — `tauri.conf.json` 의 `bundle.icon: []` 가 비어 있습니다. 프로덕션 빌드 시:
23
+ - 1024x1024 PNG 준비 (square, 투명 배경 권장)
24
+ - 부모 디렉토리에서 `pnpm tauri icon path/to/source.png` 실행 — `src-tauri/icons/` 에
25
+ 플랫폼별 variants 자동 emit + `bundle.icon` 자동 채워짐
26
+ 3. **Capabilities** — `capabilities/default.json` 은 최소 권한. fs / dialog / shell 등
27
+ 확장 API 가 필요하면 https://v2.tauri.app/security/ 참고.
28
+ 4. **Window 옵션** — `tauri.conf.json` 의 `app.windows[0]` 에 `decorations`, `transparent`,
29
+ `alwaysOnTop` 등 추가 가능.
30
+
31
+ ## Rust 코드 추가
32
+
33
+ `src/lib.rs` 의 `invoke_handler![]` 안에 새 command 등록:
34
+
35
+ ```rust
36
+ #[tauri::command]
37
+ fn my_command(name: &str) -> String {
38
+ format!("Hello, {}!", name)
39
+ }
40
+
41
+ // run() 안의 .invoke_handler 에:
42
+ .invoke_handler(tauri::generate_handler![my_command])
43
+ ```
44
+
45
+ 프론트엔드에서:
46
+ ```ts
47
+ import { invoke } from '@tauri-apps/api/core';
48
+ const greeting = await invoke<string>('my_command', { name: 'World' });
49
+ ```
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ tauri_build::build()
3
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "../gen/schemas/desktop-schema.json",
3
+ "identifier": "default",
4
+ "description": "기본 capability — 핵심 Tauri API 만 허용. 추가 권한 (fs / dialog / shell 등) 은 https://v2.tauri.app/security/ 참고해서 명시적 추가.",
5
+ "windows": [
6
+ "main"
7
+ ],
8
+ "permissions": [
9
+ "core:default",
10
+ "opener:default"
11
+ ]
12
+ }
@@ -0,0 +1,8 @@
1
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
2
+ pub fn run() {
3
+ tauri::Builder::default()
4
+ .plugin(tauri_plugin_opener::init())
5
+ .invoke_handler(tauri::generate_handler![])
6
+ .run(tauri::generate_context!())
7
+ .expect("error while running tauri application");
8
+ }
@@ -0,0 +1,6 @@
1
+ // Prevents additional console window on Windows in release.
2
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
+
4
+ fn main() {
5
+ {{tauri_crate_name}}_lib::run()
6
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://schema.tauri.app/config/2",
3
+ "productName": "{{project_name}}",
4
+ "version": "0.1.0",
5
+ "identifier": "app.{{tauri_crate_name}}.dev",
6
+ "build": {
7
+ "beforeDevCommand": "pnpm dev",
8
+ "devUrl": "http://localhost:5173",
9
+ "beforeBuildCommand": "pnpm build",
10
+ "frontendDist": "../dist"
11
+ },
12
+ "app": {
13
+ "windows": [
14
+ {
15
+ "title": "{{project_name}}",
16
+ "width": 1200,
17
+ "height": 800
18
+ }
19
+ ],
20
+ "security": {
21
+ "csp": null
22
+ }
23
+ },
24
+ "bundle": {
25
+ "active": true,
26
+ "targets": "all",
27
+ "icon": []
28
+ }
29
+ }
@@ -0,0 +1,13 @@
1
+ import { RootLayout } from '@/components/layouts/RootLayout';
2
+ import { GlobalProvider } from '@/components/providers';
3
+ import Home from './Home';
4
+
5
+ export default function App() {
6
+ return (
7
+ <GlobalProvider>
8
+ <RootLayout>
9
+ <Home />
10
+ </RootLayout>
11
+ </GlobalProvider>
12
+ );
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export function RootLayout({ children }: { children: ReactNode }) {
4
+ return <div className="min-h-screen bg-background text-foreground">{children}</div>;
5
+ }
@@ -0,0 +1,13 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { type ReactNode, useState } from 'react';
3
+ import { createQueryClient } from '@/lib/api/queryClient';
4
+ import { ThemeProvider } from '../theme/ThemeProvider';
5
+
6
+ export function GlobalProvider({ children }: { children: ReactNode }) {
7
+ const [queryClient] = useState(() => createQueryClient());
8
+ return (
9
+ <ThemeProvider>
10
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
11
+ </ThemeProvider>
12
+ );
13
+ }
@@ -0,0 +1 @@
1
+ export { GlobalProvider } from './GlobalProvider';
@@ -0,0 +1,59 @@
1
+ import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+
3
+ export type Theme = 'light' | 'dark' | 'system';
4
+
5
+ type ThemeContextValue = {
6
+ theme: Theme;
7
+ resolvedTheme: 'light' | 'dark';
8
+ setTheme: (theme: Theme) => void;
9
+ };
10
+
11
+ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
+
13
+ const STORAGE_KEY = 'theme';
14
+
15
+ function getSystemTheme(): 'light' | 'dark' {
16
+ if (typeof window === 'undefined') return 'light';
17
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
18
+ }
19
+
20
+ function resolveTheme(theme: Theme): 'light' | 'dark' {
21
+ return theme === 'system' ? getSystemTheme() : theme;
22
+ }
23
+
24
+ export function ThemeProvider({ children }: { children: ReactNode }) {
25
+ const [theme, setThemeState] = useState<Theme>(() => {
26
+ if (typeof window === 'undefined') return 'system';
27
+ return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
+ });
29
+
30
+ const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
31
+
32
+ useEffect(() => {
33
+ const resolved = resolveTheme(theme);
34
+ setResolvedTheme(resolved);
35
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
36
+ }, [theme]);
37
+
38
+ useEffect(() => {
39
+ if (theme !== 'system') return;
40
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
41
+ const handler = () => {
42
+ const resolved = getSystemTheme();
43
+ setResolvedTheme(resolved);
44
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
45
+ };
46
+ mq.addEventListener('change', handler);
47
+ return () => mq.removeEventListener('change', handler);
48
+ }, [theme]);
49
+
50
+ const setTheme = useCallback((next: Theme) => {
51
+ setThemeState(next);
52
+ if (next === 'system') localStorage.removeItem(STORAGE_KEY);
53
+ else localStorage.setItem(STORAGE_KEY, next);
54
+ }, []);
55
+
56
+ const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
57
+
58
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
+ }
@@ -0,0 +1,12 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ export function createQueryClient() {
4
+ return new QueryClient({
5
+ defaultOptions: {
6
+ queries: {
7
+ staleTime: 60 * 1000,
8
+ refetchOnWindowFocus: false,
9
+ },
10
+ },
11
+ });
12
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { ThemeContext } from '@/components/providers/theme/ThemeProvider';
3
+
4
+ export function useTheme() {
5
+ const ctx = useContext(ThemeContext);
6
+ if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
7
+ return ctx;
8
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import '@workspace/ui-app-name/globals.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,19 @@
1
+ {
2
+ "extends": "@workspace/typescript-config/react-library.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "incremental": true,
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "jsx": "react-jsx",
9
+ "noEmit": true,
10
+ "baseUrl": ".",
11
+ "paths": {
12
+ "@/lib/*": ["./src/lib/*"],
13
+ "@/components/*": ["./src/components/*"],
14
+ "@workspace/ui-app-name/*": ["../../packages/ui/ui-apps/ui-app-name/src/*"],
15
+ "@workspace/ui-core/*": ["../../packages/ui/ui-core/src/*"]
16
+ }
17
+ },
18
+ "include": ["src"]
19
+ }
@@ -0,0 +1,13 @@
1
+ import { GlobalProvider } from '@/app/providers/GlobalProvider';
2
+ import { RootLayout } from '@/app/layouts/RootLayout';
3
+ import Home from './Home';
4
+
5
+ export default function App() {
6
+ return (
7
+ <GlobalProvider>
8
+ <RootLayout>
9
+ <Home />
10
+ </RootLayout>
11
+ </GlobalProvider>
12
+ );
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export function RootLayout({ children }: { children: ReactNode }) {
4
+ return <div className="min-h-screen bg-background text-foreground">{children}</div>;
5
+ }
@@ -0,0 +1,13 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { type ReactNode, useState } from 'react';
3
+ import { createQueryClient } from '@/shared/api/queryClient';
4
+ import { ThemeProvider } from '../theme/ThemeProvider';
5
+
6
+ export function GlobalProvider({ children }: { children: ReactNode }) {
7
+ const [queryClient] = useState(() => createQueryClient());
8
+ return (
9
+ <ThemeProvider>
10
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
11
+ </ThemeProvider>
12
+ );
13
+ }
@@ -0,0 +1,59 @@
1
+ import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+
3
+ export type Theme = 'light' | 'dark' | 'system';
4
+
5
+ type ThemeContextValue = {
6
+ theme: Theme;
7
+ resolvedTheme: 'light' | 'dark';
8
+ setTheme: (theme: Theme) => void;
9
+ };
10
+
11
+ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
+
13
+ const STORAGE_KEY = 'theme';
14
+
15
+ function getSystemTheme(): 'light' | 'dark' {
16
+ if (typeof window === 'undefined') return 'light';
17
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
18
+ }
19
+
20
+ function resolveTheme(theme: Theme): 'light' | 'dark' {
21
+ return theme === 'system' ? getSystemTheme() : theme;
22
+ }
23
+
24
+ export function ThemeProvider({ children }: { children: ReactNode }) {
25
+ const [theme, setThemeState] = useState<Theme>(() => {
26
+ if (typeof window === 'undefined') return 'system';
27
+ return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
+ });
29
+
30
+ const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
31
+
32
+ useEffect(() => {
33
+ const resolved = resolveTheme(theme);
34
+ setResolvedTheme(resolved);
35
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
36
+ }, [theme]);
37
+
38
+ useEffect(() => {
39
+ if (theme !== 'system') return;
40
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
41
+ const handler = () => {
42
+ const resolved = getSystemTheme();
43
+ setResolvedTheme(resolved);
44
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
45
+ };
46
+ mq.addEventListener('change', handler);
47
+ return () => mq.removeEventListener('change', handler);
48
+ }, [theme]);
49
+
50
+ const setTheme = useCallback((next: Theme) => {
51
+ setThemeState(next);
52
+ if (next === 'system') localStorage.removeItem(STORAGE_KEY);
53
+ else localStorage.setItem(STORAGE_KEY, next);
54
+ }, []);
55
+
56
+ const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
57
+
58
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import '@workspace/ui-app-name/globals.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,12 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ export function createQueryClient() {
4
+ return new QueryClient({
5
+ defaultOptions: {
6
+ queries: {
7
+ staleTime: 60 * 1000,
8
+ refetchOnWindowFocus: false,
9
+ },
10
+ },
11
+ });
12
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { ThemeContext } from '@/app/providers/theme/ThemeProvider';
3
+
4
+ export function useTheme() {
5
+ const ctx = useContext(ThemeContext);
6
+ if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
7
+ return ctx;
8
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "@workspace/typescript-config/react-library.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "incremental": true,
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "jsx": "react-jsx",
9
+ "noEmit": true,
10
+ "baseUrl": ".",
11
+ "paths": {
12
+ "@/*": ["./src/*"],
13
+ "@workspace/ui-app-name/*": ["../../packages/ui/ui-apps/ui-app-name/src/*"],
14
+ "@workspace/ui-core/*": ["../../packages/ui/ui-core/src/*"]
15
+ }
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,3 @@
1
+ import { config } from "@workspace/eslint-config/react-internal"
2
+
3
+ export default config
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ .DS_Store
4
+ *.log
5
+ .env.local
6
+ .env.*.local
7
+ .vite/
8
+ coverage/
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="ko" suppressHydrationWarning>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>sh-ui app</title>
8
+ <script>
9
+ // FOUC 차단 — ThemeProvider mount 전에 첫 paint 에 dark class 박기.
10
+ // matrix: 'dark' → .dark, 'light' → (none), 'system'/unset → system pref.
11
+ try {
12
+ var t = localStorage.getItem('theme');
13
+ var d = t === 'dark' || ((!t || t === 'system') && matchMedia('(prefers-color-scheme:dark)').matches);
14
+ if (d) document.documentElement.classList.add('dark');
15
+ } catch (e) {}
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div id="root"></div>
20
+ <script type="module" src="/src/main.tsx"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "app-name",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "typecheck": "tsc -b --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
16
+ "dependencies": {
17
+ "@tanstack/react-query": "^5.90.21",
18
+ "@workspace/ui-app-name": "workspace:*",
19
+ "@workspace/ui-core": "workspace:*",
20
+ "clsx": "^2.1.1",
21
+ "lucide-react": "^0.563.0",
22
+ "react": "^19.2.4",
23
+ "react-dom": "^19.2.4",
24
+ "sonner": "^2.0.7",
25
+ "tailwind-merge": "^3.5.0",
26
+ "zod": "^4.3.6"
27
+ },
28
+ "devDependencies": {
29
+ "@tailwindcss/vite": "^4.1.18",
30
+ "@tanstack/react-query-devtools": "^5.91.3",
31
+ "@testing-library/jest-dom": "^6.9.1",
32
+ "@testing-library/react": "^16",
33
+ "@testing-library/user-event": "^14",
34
+ "@types/node": "^25.1.0",
35
+ "@types/react": "^19.2.10",
36
+ "@types/react-dom": "^19.2.3",
37
+ "@vitejs/plugin-react": "^5.0.0",
38
+ "@workspace/eslint-config": "workspace:^",
39
+ "@workspace/typescript-config": "workspace:*",
40
+ "eslint": "^9.39.2",
41
+ "jsdom": "^29.0.0",
42
+ "tailwindcss": "^4.1.18",
43
+ "typescript": "^5.9.3",
44
+ "vite": "^5.4.0",
45
+ "vite-tsconfig-paths": "^5.1.4",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
@@ -0,0 +1,5 @@
1
+ import Home from './Home';
2
+
3
+ export default function App() {
4
+ return <Home />;
5
+ }
@@ -0,0 +1,7 @@
1
+ export default function Home() {
2
+ return (
3
+ <main className="flex min-h-screen flex-col items-center justify-center">
4
+ <h1 className="text-4xl font-bold">Hello World</h1>
5
+ </main>
6
+ );
7
+ }
@@ -0,0 +1,9 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ createRoot(document.getElementById('root')!).render(
6
+ <StrictMode>
7
+ <App />
8
+ </StrictMode>,
9
+ );
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true,
9
+ "types": ["node"]
10
+ },
11
+ "include": ["vite.config.ts", "vitest.config.ts", "vitest.setup.ts"]
12
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import tsconfigPaths from 'vite-tsconfig-paths';
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss(), tsconfigPaths()],
8
+ server: {
9
+ port: 3000,
10
+ },
11
+ });
@@ -0,0 +1,13 @@
1
+ import { defineConfig, mergeConfig } from 'vitest/config';
2
+ import viteConfig from './vite.config';
3
+
4
+ export default mergeConfig(
5
+ viteConfig,
6
+ defineConfig({
7
+ test: {
8
+ environment: 'jsdom',
9
+ globals: true,
10
+ setupFiles: ['./vitest.setup.ts'],
11
+ },
12
+ }),
13
+ );
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';