sh-ui-cli 0.96.2 → 0.96.3

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.
@@ -2,6 +2,19 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.96.3",
7
+ "date": "2026-05-16",
8
+ "title": "vite 스캐폴드 fix — vite 7 전환 + turbo outputs/env + tsconfig 누수 + ThemeProvider",
9
+ "type": "patch",
10
+ "highlights": [
11
+ "**vite `^5` → `^7` (+ `vite-plugin-static-copy` `^3.2.0`)** — vitest 4 와 vite 5 의 메이저 불일치로 `pnpm test` 가 테스트 0개에서도 startup 에서 죽던 문제 해소. i18n 이 끌어오는 static-copy peer 도 vite 7 라인으로 동반 상향.",
12
+ "**monorepo `turbo.json` 플랫폼 인지** — vite 앱은 `build.outputs` 가 `dist/**`, `globalEnv` 의 `API_URL` → `VITE_API_URL`. 그동안 `.next/**` 잔재로 turbo build 캐시가 전혀 안 잡히던 것 교정 (이제 FULL TURBO).",
13
+ "**`observability=sentry` + monorepo** — `MODE`·`SENTRY_ORG`·`SENTRY_PROJECT`·`SENTRY_AUTH_TOKEN` 를 turbo `globalEnv` 에 자동 선언해 깨끗한 scaffold 가 `turbo/no-undeclared-env-vars` 경고 없이 lint clean.",
14
+ "**tsconfig 누수 + ThemeProvider** — `tsc -b` 가 `vite.config.js`/`*.tsbuildinfo` 를 소스 옆에 흘리던 것을 `noEmit` + `node_modules/.tmp` 로 차단하고 `*.tsbuildinfo` gitignore. ThemeProvider 는 `useSyncExternalStore` 로 재작성해 React 19 set-state-in-effect 경고 제거."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.96.3"
17
+ },
5
18
  {
6
19
  "version": "0.96.2",
7
20
  "date": "2026-05-15",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.96.2",
3
+ "version": "0.96.3",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1144,7 +1144,7 @@ export function GlobalProvider({ children }: { children: ReactNode }) {
1144
1144
  pkg.devDependencies = pkg.devDependencies ?? {};
1145
1145
  // dev/build 양쪽에서 src/shared/i18n/locales/* (또는 src/lib/i18n/locales/*) 를
1146
1146
  // public/locales/* 로 자동 미러 → i18next-http-backend 의 /locales/{{lng}}/{{ns}}.json 이 동작.
1147
- pkg.devDependencies['vite-plugin-static-copy'] = '^2.2.0';
1147
+ pkg.devDependencies['vite-plugin-static-copy'] = '^3.2.0';
1148
1148
  pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
1149
1149
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });
1150
1150
 
@@ -1554,6 +1554,18 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
1554
1554
  turbo.globalEnv.push(...plugin.turboEnvVars);
1555
1555
  }
1556
1556
  }
1557
+ // 템플릿 turbo.json 은 .next/** 기준이라 vite 앱에선 turbo 가 산출물을
1558
+ // 못 찾아 build 캐시가 전혀 안 잡힌다 — 플랫폼별 outputs 로 교정.
1559
+ if (platform === 'vite') {
1560
+ turbo.tasks.build.outputs = ['dist/**'];
1561
+ // vite 는 클라이언트 노출 env 가 VITE_ 접두사 관례.
1562
+ turbo.globalEnv = turbo.globalEnv.map((e) => (e === 'API_URL' ? 'VITE_API_URL' : e));
1563
+ // sentry observability 는 플러그인 turboEnvVars 훅을 안 타므로 직접 선언.
1564
+ if (observability === 'sentry') {
1565
+ turbo.globalEnv.push('MODE', 'SENTRY_ORG', 'SENTRY_PROJECT', 'SENTRY_AUTH_TOKEN');
1566
+ }
1567
+ }
1568
+ turbo.globalEnv = [...new Set(turbo.globalEnv)];
1557
1569
  await fs.writeJson(turboPath, turbo, { spaces: 2 });
1558
1570
 
1559
1571
  // Create first app — `appName` 인자가 주어지면 그대로, 아니면 yes 모드 default 'web' / 대화모드 prompt.
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -7,6 +7,7 @@
7
7
  "moduleResolution": "Bundler",
8
8
  "jsx": "react-jsx",
9
9
  "noEmit": true,
10
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
10
11
  "baseUrl": ".",
11
12
  "paths": {
12
13
  "@/lib/*": ["./src/lib/*"],
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -7,6 +7,7 @@
7
7
  "moduleResolution": "Bundler",
8
8
  "jsx": "react-jsx",
9
9
  "noEmit": true,
10
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
10
11
  "baseUrl": ".",
11
12
  "paths": {
12
13
  "@/*": ["./src/*"],
@@ -6,3 +6,4 @@ dist/
6
6
  .env.*.local
7
7
  .vite/
8
8
  coverage/
9
+ *.tsbuildinfo
@@ -41,7 +41,7 @@
41
41
  "jsdom": "^29.0.0",
42
42
  "tailwindcss": "^4.1.18",
43
43
  "typescript": "^5.9.3",
44
- "vite": "^5.4.0",
44
+ "vite": "^7.0.0",
45
45
  "vite-tsconfig-paths": "^5.1.4",
46
46
  "vitest": "^4.1.0"
47
47
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "composite": true,
4
+ "noEmit": true,
5
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
6
  "skipLibCheck": true,
5
7
  "module": "ESNext",
6
8
  "moduleResolution": "Bundler",
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -13,6 +13,7 @@
13
13
  "resolveJsonModule": true,
14
14
  "allowSyntheticDefaultImports": true,
15
15
  "noEmit": true,
16
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
16
17
  "baseUrl": ".",
17
18
  "paths": {
18
19
  "@/lib/*": ["./src/lib/*"],
@@ -1,4 +1,12 @@
1
- import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
2
10
 
3
11
  export type Theme = 'light' | 'dark' | 'system';
4
12
 
@@ -12,13 +20,14 @@ export const ThemeContext = createContext<ThemeContextValue | null>(null);
12
20
 
13
21
  const STORAGE_KEY = 'theme';
14
22
 
15
- function getSystemTheme(): 'light' | 'dark' {
16
- if (typeof window === 'undefined') return 'light';
17
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ function subscribeSystemTheme(onChange: () => void): () => void {
24
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
18
27
  }
19
28
 
20
- function resolveTheme(theme: Theme): 'light' | 'dark' {
21
- return theme === 'system' ? getSystemTheme() : theme;
29
+ function getSystemTheme(): 'light' | 'dark' {
30
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
31
  }
23
32
 
24
33
  export function ThemeProvider({ children }: { children: ReactNode }) {
@@ -27,25 +36,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
27
36
  return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
28
37
  });
29
38
 
30
- const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
39
+ // 시스템 테마는 외부 스토어(matchMedia) useSyncExternalStore 로 구독해
40
+ // effect 안에서 setState 하지 않는다 (React 19 set-state-in-effect 회피).
41
+ const systemTheme = useSyncExternalStore(
42
+ subscribeSystemTheme,
43
+ getSystemTheme,
44
+ () => 'light' as const,
45
+ );
31
46
 
32
- useEffect(() => {
33
- const resolved = resolveTheme(theme);
34
- setResolvedTheme(resolved);
35
- document.documentElement.classList.toggle('dark', resolved === 'dark');
36
- }, [theme]);
47
+ const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : theme;
37
48
 
38
49
  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]);
50
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
51
+ }, [resolvedTheme]);
49
52
 
50
53
  const setTheme = useCallback((next: Theme) => {
51
54
  setThemeState(next);
@@ -53,7 +56,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
53
56
  else localStorage.setItem(STORAGE_KEY, next);
54
57
  }, []);
55
58
 
56
- const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
59
+ const value = useMemo(
60
+ () => ({ theme, resolvedTheme, setTheme }),
61
+ [theme, resolvedTheme, setTheme],
62
+ );
57
63
 
58
64
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
59
65
  }
@@ -13,6 +13,7 @@
13
13
  "resolveJsonModule": true,
14
14
  "allowSyntheticDefaultImports": true,
15
15
  "noEmit": true,
16
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
16
17
  "baseUrl": ".",
17
18
  "paths": {
18
19
  "@/*": ["./src/*"]
@@ -6,3 +6,4 @@ dist/
6
6
  .env.*.local
7
7
  .vite/
8
8
  coverage/
9
+ *.tsbuildinfo
@@ -53,7 +53,7 @@
53
53
  "tailwindcss": "^4.1.18",
54
54
  "typescript": "^5.9.3",
55
55
  "typescript-eslint": "^8.54.0",
56
- "vite": "^5.4.0",
56
+ "vite": "^7.0.0",
57
57
  "vite-tsconfig-paths": "^5.1.4",
58
58
  "vitest": "^4.1.0"
59
59
  },
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "composite": true,
4
+ "noEmit": true,
5
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
6
  "skipLibCheck": true,
5
7
  "module": "ESNext",
6
8
  "moduleResolution": "Bundler",