sh-ui-cli 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/changelog/versions.json +29 -0
- package/package.json +1 -1
- package/src/api.d.ts +2 -0
- package/src/constants.js +5 -0
- package/src/create/architectures/index.js +15 -0
- package/src/create/cli-args.js +5 -1
- package/src/create/describeTemplate.js +25 -0
- package/src/create/generator.js +305 -15
- package/src/create/index.mjs +5 -2
- package/src/mcp.mjs +41 -5
- package/templates/monorepo/CLAUDE.md +14 -1
- package/templates/nextjs-standalone/CLAUDE.md +10 -0
|
@@ -2,6 +2,35 @@
|
|
|
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.94.0",
|
|
7
|
+
"date": "2026-05-15",
|
|
8
|
+
"title": "vite 스캐폴드 P0 fix 묶음 — i18n locale 자동 미러 / monorepo .gitignore / CLAUDE.md platform 분기",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`--i18n` 시 vite-plugin-static-copy 자동 셋업** — emitI18n 이 `vite-plugin-static-copy` (devDeps) + `vite.config.ts` 의 plugins 배열 패치를 자동 처리. 사용자가 손으로 plugin 추가하지 않아도 dev/build 양쪽에서 `src/shared/i18n/locales/*` (또는 `src/lib/i18n/locales/*`) 가 `public/locales/*` 로 미러되어 `/locales/{{lng}}/{{ns}}.json` 404 없음. Tauri prod 빌드도 dist 에 자동 포함.",
|
|
12
|
+
"**Locale seed 콘텐츠 언어별 정합화** — ko = `{greeting: '안녕하세요', app_title: 'sh-ui 앱'}`, en = `{greeting: 'Hello', app_title: 'sh-ui app'}`, 그 외 locale 도 영어 placeholder 시드를 받아 translator 가 채울 키가 즉시 보임. 이전엔 fallback (보통 ko) 에만 영어 시드 + 나머지 빈 객체였음.",
|
|
13
|
+
"**monorepo `apps/<name>/gitignore` 도 dot-prefix** — finalizeProject 가 root 만 처리하던 동작을 recursive 로 변경. vite-app 처럼 sub-app 에 자체 gitignore 가 있는 케이스에서 점 없는 파일이 남아 node_modules / dist 가 git staged 될 위험 제거. 회귀 가드 smoke 추가.",
|
|
14
|
+
"**CLAUDE.md `{{PLATFORM_APP_DESCRIPTION}}` 분기** — monorepo CLAUDE.md 가 `apps/<name>` 을 'Next.js 앱' 으로 hardcode 하던 문제 해결. `--platform vite` 면 'Vite SPA' + Tauri 옵션 시 추가 문장이 자동 치환되어 AI 에이전트가 잘못된 컨벤션 (App Router/RSC) 으로 코드를 작성하는 회귀 차단.",
|
|
15
|
+
"**mes arch 옵션 docs 명시** — monorepo / nextjs-standalone CLAUDE.md 에 `--arch` 옵션 (`fsd` / `flat` / `mes`) 섹션 추가. 외부 에이전트가 `packages/eslint-config/mes.js` 를 deprecated 잔재로 오해하던 문제 해결 — mes 는 MES(Backoffice) 전용 별도 arch, 모두 emit 되는 건 라이브러리 형태이기 때문."
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.94.0"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.93.0",
|
|
21
|
+
"date": "2026-05-14",
|
|
22
|
+
"title": "vite 프리셋 Sentry observability opt-in (--observability sentry)",
|
|
23
|
+
"type": "minor",
|
|
24
|
+
"highlights": [
|
|
25
|
+
"**`--observability sentry` 옵션** — vite preset 에 opt-in Sentry 셋업 추가. `@sentry/react` (deps) + `@sentry/vite-plugin` (devDeps) + `Sentry.init({...})` (DSN 가드, browserTracing + replay PII 보호 디폴트) + `SentryProvider` (ErrorBoundary wrap) + `.env.example` 자동 emit. DSN 빈 값이면 init 자동 skip — dev/CI 안전. ai-org 처럼 error tracking 부터 들어가는 워크로드 5분 boilerplate 제거.",
|
|
26
|
+
"**enum 디자인** — `observability: z.enum(['none', 'sentry'])` future-extensible. GlitchTip 등 Sentry-protocol 호환 서비스는 같은 SDK + DSN 만 변경하면 동작 (라이브러리 lock-in 아님). 추후 `glitchtip` / `bugsnag` 등 enum 확장 자연스럽게.",
|
|
27
|
+
"**Source map upload 자동 셋업** — `vite.config.ts` 에 `sentryVitePlugin({ org, project, authToken, disable: !SENTRY_AUTH_TOKEN })` 자동 추가 + `build.sourcemap: true`. SENTRY_AUTH_TOKEN 이 env 에 있으면 빌드 시 source map 자동 업로드, 없으면 plugin disable. CI 친화.",
|
|
28
|
+
"**i18n + Sentry 동시 시 wrapper 순서 보장** — SentryProvider 가 OUTERMOST (모든 children error 캐치) → I18nProvider → ThemeProvider → QueryClientProvider. Smoke V20 가 wrapper index 순서 단언으로 회귀 가드.",
|
|
29
|
+
"**Vite types 자동 reference** — emit 된 `sentry.ts` 가 `/// <reference types=\"vite/client\" />` triple-slash 로 `import.meta.env.VITE_SENTRY_DSN` 타입 정확. 사용자 tsconfig 손댈 필요 X.",
|
|
30
|
+
"**guards + 회귀** — `observability + platform !== 'vite'` 조합은 CLI + MCP 양쪽에서 명시적 Korean 에러. Smoke V18 (positive), V19 (default opt-out 회귀), V20 (i18n + sentry combo wrapper 순서). docs `/create` 페이지도 observability 토글 노출."
|
|
31
|
+
],
|
|
32
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.93.0"
|
|
33
|
+
},
|
|
5
34
|
{
|
|
6
35
|
"version": "0.92.0",
|
|
7
36
|
"date": "2026-05-14",
|
package/package.json
CHANGED
package/src/api.d.ts
CHANGED
|
@@ -145,6 +145,8 @@ export interface DescribeTemplateOptions {
|
|
|
145
145
|
i18n?: 'none' | 'react-i18next';
|
|
146
146
|
/** i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en' */
|
|
147
147
|
locales?: string;
|
|
148
|
+
/** vite 전용 — Sentry observability opt-in. v0.93.0+ */
|
|
149
|
+
observability?: 'none' | 'sentry';
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
export interface DescribeTemplateGroup {
|
package/src/constants.js
CHANGED
|
@@ -60,3 +60,8 @@ export const INIT_DEFAULTS = {
|
|
|
60
60
|
export const I18N_LIBRARIES = ['none', 'react-i18next'];
|
|
61
61
|
export const I18N_DEFAULT = 'none';
|
|
62
62
|
export const I18N_DEFAULT_LOCALES = 'ko,en';
|
|
63
|
+
|
|
64
|
+
// ─── observability (vite preset 전용 — v0.93.0+) ───
|
|
65
|
+
// 'none' 디폴트 — opt-in 으로 @sentry/react + @sentry/vite-plugin 설치 + 셋업.
|
|
66
|
+
// GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 향후 bugsnag 등 추가 시 enum 확장.
|
|
67
|
+
export const OBSERVABILITY_PROVIDERS = ['none', 'sentry'];
|
|
@@ -39,6 +39,21 @@ export function getArchesForPlatform(platform) {
|
|
|
39
39
|
return allArchitectures.filter((a) => a.platforms.includes(platform));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* MCP tool description / CLI --help / docs 어디서나 재사용 가능한 arch 설명 블록.
|
|
44
|
+
* "fsd (FSD) — ..., flat (Flat) — ..., mes (MES) — ..." 처럼 사람-읽기 좋은 한 줄로 직렬화.
|
|
45
|
+
*
|
|
46
|
+
* 외부 AI 에이전트 (Cursor / Codex / Copilot 등) 는 CLAUDE.md 를 읽지 않으므로
|
|
47
|
+
* MCP schema description 에 각 arch 의 의미를 노출해야 mes 같은 도메인-특화 arch
|
|
48
|
+
* 를 deprecated 잔재로 오해하지 않는다 (v0.94.0+).
|
|
49
|
+
*/
|
|
50
|
+
export function describeArchOptions(platformFilter) {
|
|
51
|
+
const arches = platformFilter ? getArchesForPlatform(platformFilter) : allArchitectures;
|
|
52
|
+
return arches
|
|
53
|
+
.map((a) => `${a.name} (${a.label}) — ${a.description}`)
|
|
54
|
+
.join(' | ');
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
/**
|
|
43
58
|
* 주어진 arch 가 platform 과 호환되는지 검증. 호환 안 되면 친절한 에러.
|
|
44
59
|
* generator/cli-args 양쪽에서 호출.
|
package/src/create/cli-args.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
CSS_FRAMEWORKS_SUPPORTED,
|
|
5
5
|
CSS_FRAMEWORKS_PLANNED,
|
|
6
6
|
I18N_LIBRARIES,
|
|
7
|
+
OBSERVABILITY_PROVIDERS,
|
|
7
8
|
} from '../constants.js';
|
|
8
9
|
import { allPlugins } from './plugins/index.js';
|
|
9
10
|
import { allArchitectures } from './architectures/index.js';
|
|
@@ -13,7 +14,7 @@ const VALID_STRUCTURES = CREATE_STRUCTURES;
|
|
|
13
14
|
const VALID_PLUGINS = allPlugins.map((p) => p.name);
|
|
14
15
|
const VALID_ARCHES = allArchitectures.map((a) => a.name);
|
|
15
16
|
|
|
16
|
-
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port', 'i18n', 'locales'];
|
|
17
|
+
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port', 'i18n', 'locales', 'observability'];
|
|
17
18
|
const BOOL_FLAGS = ['yes', 'help', 'dry-run', 'tauri'];
|
|
18
19
|
|
|
19
20
|
const SUBCOMMANDS = ['add-app', 'add-component'];
|
|
@@ -79,6 +80,9 @@ export const parseArgs = (argv) => {
|
|
|
79
80
|
if (name === 'i18n' && !I18N_LIBRARIES.includes(value)) {
|
|
80
81
|
throw new Error(`--i18n 은 ${I18N_LIBRARIES.join('/')} 중 하나여야 함 (받은 값: ${value})`);
|
|
81
82
|
}
|
|
83
|
+
if (name === 'observability' && !OBSERVABILITY_PROVIDERS.includes(value)) {
|
|
84
|
+
throw new Error(`--observability 는 ${OBSERVABILITY_PROVIDERS.join('/')} 중 하나여야 함 (받은 값: ${value})`);
|
|
85
|
+
}
|
|
82
86
|
if (name === 'css' && !CSS_FRAMEWORKS_SUPPORTED.includes(value)) {
|
|
83
87
|
// planned 값은 '곧 옵니다' 신호로 분기 — 사용자 의도가 더 명확히 전달.
|
|
84
88
|
if (CSS_FRAMEWORKS_PLANNED.includes(value)) {
|
|
@@ -38,6 +38,7 @@ import { CSS_FRAMEWORK_DEFAULT } from '../constants.js';
|
|
|
38
38
|
* @property {boolean} [tauri] platform=vite (standalone/monorepo 둘 다) 일 때 Tauri 2.x 셸 같이 emit
|
|
39
39
|
* @property {'none'|'react-i18next'} [i18n] vite 전용 — react-i18next 셋업
|
|
40
40
|
* @property {string} [locales] i18n 활성화 시 생성할 locale 코드 (comma-separated, default 'ko,en')
|
|
41
|
+
* @property {'none'|'sentry'} [observability] vite 전용 — Sentry 셋업 (v0.93.0+)
|
|
41
42
|
*/
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -68,6 +69,7 @@ export function describeTemplate(opts = {}) {
|
|
|
68
69
|
tauri = false,
|
|
69
70
|
i18n = 'none',
|
|
70
71
|
locales = 'ko,en',
|
|
72
|
+
observability = 'none',
|
|
71
73
|
} = opts;
|
|
72
74
|
|
|
73
75
|
if (platform === 'flutter') {
|
|
@@ -116,6 +118,17 @@ export function describeTemplate(opts = {}) {
|
|
|
116
118
|
];
|
|
117
119
|
groups.push(makeGroup('i18n', `i18n (${i18n})`, i18nFiles));
|
|
118
120
|
}
|
|
121
|
+
if (observability === 'sentry') {
|
|
122
|
+
const isFsd = safeArchName === 'fsd';
|
|
123
|
+
const obsBase = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
124
|
+
const providersBase = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
125
|
+
groups.push(makeGroup('sentry', `Sentry observability`, [
|
|
126
|
+
`${obsBase}/sentry.ts`,
|
|
127
|
+
`${obsBase}/index.ts`,
|
|
128
|
+
`${providersBase}/SentryProvider.tsx`,
|
|
129
|
+
'.env.example',
|
|
130
|
+
]));
|
|
131
|
+
}
|
|
119
132
|
return finalize(groups);
|
|
120
133
|
}
|
|
121
134
|
|
|
@@ -180,6 +193,18 @@ export function describeTemplate(opts = {}) {
|
|
|
180
193
|
groups.push(makeGroup('i18n', `i18n (${i18n}, apps/${appName}/)`, i18nFiles));
|
|
181
194
|
}
|
|
182
195
|
|
|
196
|
+
if (observability === 'sentry') {
|
|
197
|
+
const isFsd = safeArchName === 'fsd';
|
|
198
|
+
const obsBase = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
199
|
+
const providersBase = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
200
|
+
groups.push(makeGroup('sentry', `Sentry observability (apps/${appName}/)`, [
|
|
201
|
+
`apps/${appName}/${obsBase}/sentry.ts`,
|
|
202
|
+
`apps/${appName}/${obsBase}/index.ts`,
|
|
203
|
+
`apps/${appName}/${providersBase}/SentryProvider.tsx`,
|
|
204
|
+
`apps/${appName}/.env.example`,
|
|
205
|
+
]));
|
|
206
|
+
}
|
|
207
|
+
|
|
183
208
|
return finalize(groups);
|
|
184
209
|
}
|
|
185
210
|
|
package/src/create/generator.js
CHANGED
|
@@ -231,6 +231,13 @@ export async function createProject(options = {}) {
|
|
|
231
231
|
);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// observability 옵션도 vite preset 전용. v0.93.0+.
|
|
235
|
+
if (options.observability && options.observability !== 'none' && platform !== 'vite') {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
234
241
|
// arch 결정 — platform 확정 후. 사용자가 --arch 미지정 시:
|
|
235
242
|
// - next → DEFAULT_ARCH ('fsd')
|
|
236
243
|
// - flutter → 현재 Flutter arch 디스크립터 없음 → null. 미래에 flutter arch 추가되면
|
|
@@ -355,6 +362,7 @@ export async function createProject(options = {}) {
|
|
|
355
362
|
tauri: !!options.tauri,
|
|
356
363
|
i18n: options.i18n ?? 'none',
|
|
357
364
|
locales: options.locales ?? 'ko,en',
|
|
365
|
+
observability: options.observability ?? 'none',
|
|
358
366
|
});
|
|
359
367
|
} else {
|
|
360
368
|
await generateMonorepo(targetDir, projectName, [], {
|
|
@@ -363,6 +371,7 @@ export async function createProject(options = {}) {
|
|
|
363
371
|
tauri: options.tauri,
|
|
364
372
|
i18n: options.i18n ?? 'none',
|
|
365
373
|
locales: options.locales ?? 'ko,en',
|
|
374
|
+
observability: options.observability ?? 'none',
|
|
366
375
|
});
|
|
367
376
|
}
|
|
368
377
|
|
|
@@ -529,6 +538,11 @@ export async function addApp(options = {}) {
|
|
|
529
538
|
`i18n='${options.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}).`,
|
|
530
539
|
);
|
|
531
540
|
}
|
|
541
|
+
if (options.observability && options.observability !== 'none' && platform !== 'vite') {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
532
546
|
|
|
533
547
|
const appName = validateProjectName(
|
|
534
548
|
options.name ?? await input({
|
|
@@ -583,6 +597,7 @@ export async function addApp(options = {}) {
|
|
|
583
597
|
tauri: !!options.tauri,
|
|
584
598
|
i18n: options.i18n ?? 'none',
|
|
585
599
|
locales: options.locales ?? 'ko,en',
|
|
600
|
+
observability: options.observability ?? 'none',
|
|
586
601
|
});
|
|
587
602
|
} else {
|
|
588
603
|
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
@@ -846,7 +861,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
|
|
|
846
861
|
await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
|
|
847
862
|
}
|
|
848
863
|
|
|
849
|
-
async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { tauri = false, i18n = 'none', locales = 'ko,en' } = {}) {
|
|
864
|
+
async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
850
865
|
// 베이스 (arch-neutral) + arch 오버레이 — generateStandalone 과 같은 패턴.
|
|
851
866
|
await fs.copy(path.join(TEMPLATES_DIR, 'vite-standalone'), targetDir, {
|
|
852
867
|
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
@@ -892,6 +907,10 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
|
|
|
892
907
|
const localesArr = parseLocales(locales);
|
|
893
908
|
await emitI18n(targetDir, { arch, locales: localesArr });
|
|
894
909
|
}
|
|
910
|
+
|
|
911
|
+
if (observability === 'sentry') {
|
|
912
|
+
await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
|
|
913
|
+
}
|
|
895
914
|
}
|
|
896
915
|
|
|
897
916
|
/**
|
|
@@ -1027,9 +1046,9 @@ import HttpBackend from 'i18next-http-backend';
|
|
|
1027
1046
|
import { initReactI18next } from 'react-i18next';
|
|
1028
1047
|
|
|
1029
1048
|
// 클라이언트 측 lazy-load — 빌드 산출물에서 public/locales/{lng}/{ns}.json 경로로 fetch.
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1049
|
+
// 원본 locale 파일은 ${i18nDirRel}/locales/* 에 두고, vite-plugin-static-copy 가
|
|
1050
|
+
// dev/build 양쪽에서 public/locales/* 로 자동 미러링한다 (vite.config.ts 참고).
|
|
1051
|
+
// Tauri 빌드의 경우도 dist/locales 에 그대로 포함된다.
|
|
1033
1052
|
|
|
1034
1053
|
i18n
|
|
1035
1054
|
.use(HttpBackend)
|
|
@@ -1059,12 +1078,17 @@ export default i18n;
|
|
|
1059
1078
|
`export { default } from './config';\n`,
|
|
1060
1079
|
);
|
|
1061
1080
|
|
|
1081
|
+
// locale 별 시드 — 같은 key 를 모든 locale 에 emit (translator 가 무엇을 채워야 하는지 즉시 보임).
|
|
1082
|
+
// 핵심 locale (ko, en) 만 사람-언어 값, 그 외 locale 은 영어 placeholder.
|
|
1083
|
+
const seedByLocale = {
|
|
1084
|
+
ko: { greeting: '안녕하세요', app_title: 'sh-ui 앱' },
|
|
1085
|
+
en: { greeting: 'Hello', app_title: 'sh-ui app' },
|
|
1086
|
+
};
|
|
1087
|
+
const fallbackSeed = { greeting: 'Hello', app_title: 'sh-ui app' };
|
|
1062
1088
|
for (const lng of locales) {
|
|
1063
1089
|
const localeDir = path.join(i18nDir, 'locales', lng);
|
|
1064
1090
|
await fs.ensureDir(localeDir);
|
|
1065
|
-
const seed = lng
|
|
1066
|
-
? { greeting: 'Hello World', app_title: 'sh-ui app' }
|
|
1067
|
-
: {};
|
|
1091
|
+
const seed = seedByLocale[lng] ?? fallbackSeed;
|
|
1068
1092
|
await fs.writeFile(
|
|
1069
1093
|
path.join(localeDir, 'common.json'),
|
|
1070
1094
|
JSON.stringify(seed, null, 2) + '\n',
|
|
@@ -1111,7 +1135,56 @@ export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
|
1111
1135
|
pkg.dependencies['i18next-http-backend'] = '^2.7.1';
|
|
1112
1136
|
pkg.dependencies['react-i18next'] = '^15.1.0';
|
|
1113
1137
|
pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
1138
|
+
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
1139
|
+
// dev/build 양쪽에서 src/shared/i18n/locales/* (또는 src/lib/i18n/locales/*) 를
|
|
1140
|
+
// public/locales/* 로 자동 미러 → i18next-http-backend 의 /locales/{{lng}}/{{ns}}.json 이 동작.
|
|
1141
|
+
pkg.devDependencies['vite-plugin-static-copy'] = '^2.2.0';
|
|
1142
|
+
pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
|
|
1114
1143
|
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1144
|
+
|
|
1145
|
+
// vite.config.ts 에 vite-plugin-static-copy 삽입.
|
|
1146
|
+
await patchViteConfigForI18n(targetDir, { i18nDirRel });
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* vite.config.ts 의 plugins 배열에 vite-plugin-static-copy 호출을 삽입한다.
|
|
1151
|
+
* i18n 의 locale 파일 (src/shared/i18n/locales/* 등) 을 public/locales/* 로 자동 미러.
|
|
1152
|
+
*
|
|
1153
|
+
* 이미 같은 호출이 있으면 no-op. parsing 실패하면 사용자에게 수동 작업을 알리고 abort 하지 않는다.
|
|
1154
|
+
*/
|
|
1155
|
+
async function patchViteConfigForI18n(targetDir, { i18nDirRel }) {
|
|
1156
|
+
const viteConfigPath = path.join(targetDir, 'vite.config.ts');
|
|
1157
|
+
if (!(await fs.pathExists(viteConfigPath))) return;
|
|
1158
|
+
let src = await fs.readFile(viteConfigPath, 'utf-8');
|
|
1159
|
+
|
|
1160
|
+
if (src.includes('vite-plugin-static-copy')) {
|
|
1161
|
+
return; // 이미 셋업됨 (재진입 안전).
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const importLine = `import { viteStaticCopy } from 'vite-plugin-static-copy';`;
|
|
1165
|
+
// 다른 import 블록 뒤에 삽입.
|
|
1166
|
+
const lastImportIdx = src.lastIndexOf('import ');
|
|
1167
|
+
const insertImportAt = src.indexOf('\n', lastImportIdx) + 1;
|
|
1168
|
+
src = src.slice(0, insertImportAt) + importLine + '\n' + src.slice(insertImportAt);
|
|
1169
|
+
|
|
1170
|
+
const pluginCall = ` viteStaticCopy({
|
|
1171
|
+
// i18n locale 파일을 public/locales 로 미러 — i18next-http-backend 의 loadPath 와 매칭.
|
|
1172
|
+
targets: [
|
|
1173
|
+
{ src: '${i18nDirRel}/locales/*', dest: 'locales' },
|
|
1174
|
+
],
|
|
1175
|
+
}),`;
|
|
1176
|
+
|
|
1177
|
+
// plugins: [ ... ] 의 시작 직후에 새 plugin 삽입.
|
|
1178
|
+
const pluginsMatch = src.match(/plugins:\s*\[/);
|
|
1179
|
+
if (pluginsMatch) {
|
|
1180
|
+
const insertAt = pluginsMatch.index + pluginsMatch[0].length;
|
|
1181
|
+
src = src.slice(0, insertAt) + '\n' + pluginCall + src.slice(insertAt);
|
|
1182
|
+
} else {
|
|
1183
|
+
// 형태가 예상과 다르면 패치 포기 — config 가 깨지지 않도록.
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
await fs.writeFile(viteConfigPath, src);
|
|
1115
1188
|
}
|
|
1116
1189
|
|
|
1117
1190
|
/**
|
|
@@ -1127,7 +1200,183 @@ function parseLocales(input) {
|
|
|
1127
1200
|
return cleaned.length > 0 ? cleaned : ['ko', 'en'];
|
|
1128
1201
|
}
|
|
1129
1202
|
|
|
1130
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* Sentry observability 셋업 emit (v0.93.0+). vite preset 전용 opt-in.
|
|
1205
|
+
*
|
|
1206
|
+
* - shared/observability/sentry.ts — Sentry.init (DSN 있을 때만)
|
|
1207
|
+
* - shared/observability/index.ts — Sentry + ErrorBoundary re-export
|
|
1208
|
+
* - app/providers/SentryProvider.tsx — ErrorBoundary wrapper
|
|
1209
|
+
* - GlobalProvider 재작성 — Sentry > [I18n?] > Theme > Query 순서
|
|
1210
|
+
* - package.json — @sentry/react + @sentry/vite-plugin 추가
|
|
1211
|
+
* - vite.config.ts — sentryVitePlugin 삽입 + sourcemap: true
|
|
1212
|
+
* - .env.example — Sentry 변수 안내 블록 추가
|
|
1213
|
+
*
|
|
1214
|
+
* @param {string} targetDir — 앱 디렉토리
|
|
1215
|
+
* @param {object} opts
|
|
1216
|
+
* @param {object} opts.arch — arch descriptor
|
|
1217
|
+
* @param {boolean} [opts.i18nActive] — i18n 도 같이 켜져 있는지 (GlobalProvider wrapping 순서)
|
|
1218
|
+
*/
|
|
1219
|
+
async function emitSentry(targetDir, { arch, i18nActive = false }) {
|
|
1220
|
+
const isFsd = arch.name === 'fsd';
|
|
1221
|
+
const obsDirRel = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
1222
|
+
const obsAlias = isFsd ? '@/shared/observability' : '@/lib/observability';
|
|
1223
|
+
const providersDirRel = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
1224
|
+
const apiAlias = isFsd ? '@/shared/api/queryClient' : '@/lib/api/queryClient';
|
|
1225
|
+
|
|
1226
|
+
const obsDir = path.join(targetDir, obsDirRel);
|
|
1227
|
+
await fs.ensureDir(obsDir);
|
|
1228
|
+
|
|
1229
|
+
const sentryTs = `/// <reference types="vite/client" />
|
|
1230
|
+
import * as Sentry from '@sentry/react';
|
|
1231
|
+
|
|
1232
|
+
// VITE_SENTRY_DSN 이 있을 때만 init — 로컬 dev 에선 자동 skip.
|
|
1233
|
+
// GlitchTip self-hosted 도 같은 SDK — DSN 만 변경.
|
|
1234
|
+
if (import.meta.env.VITE_SENTRY_DSN) {
|
|
1235
|
+
Sentry.init({
|
|
1236
|
+
dsn: import.meta.env.VITE_SENTRY_DSN,
|
|
1237
|
+
release: import.meta.env.VITE_APP_VERSION,
|
|
1238
|
+
environment: import.meta.env.MODE,
|
|
1239
|
+
tracesSampleRate: 0.1,
|
|
1240
|
+
replaysOnErrorSampleRate: 1.0,
|
|
1241
|
+
replaysSessionSampleRate: 0,
|
|
1242
|
+
integrations: [
|
|
1243
|
+
Sentry.browserTracingIntegration(),
|
|
1244
|
+
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
|
1245
|
+
],
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export { Sentry };
|
|
1250
|
+
`;
|
|
1251
|
+
await fs.writeFile(path.join(obsDir, 'sentry.ts'), sentryTs);
|
|
1252
|
+
|
|
1253
|
+
await fs.writeFile(
|
|
1254
|
+
path.join(obsDir, 'index.ts'),
|
|
1255
|
+
`export { Sentry } from './sentry';\nexport { ErrorBoundary } from '@sentry/react';\n`,
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
const providersDir = path.join(targetDir, providersDirRel);
|
|
1259
|
+
await fs.ensureDir(providersDir);
|
|
1260
|
+
const sentryProvider = `import { type ReactNode } from 'react';
|
|
1261
|
+
import { ErrorBoundary } from '${obsAlias}';
|
|
1262
|
+
|
|
1263
|
+
function Fallback({ error }: { error: unknown }) {
|
|
1264
|
+
return (
|
|
1265
|
+
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
1266
|
+
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>오류가 발생했습니다</h1>
|
|
1267
|
+
<p style={{ marginTop: '0.5rem', color: 'var(--foreground-muted)' }}>
|
|
1268
|
+
{error instanceof Error ? error.message : '알 수 없는 오류'}
|
|
1269
|
+
</p>
|
|
1270
|
+
</div>
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
export function SentryProvider({ children }: { children: ReactNode }) {
|
|
1275
|
+
return (
|
|
1276
|
+
<ErrorBoundary fallback={({ error }) => <Fallback error={error} />}>
|
|
1277
|
+
{children}
|
|
1278
|
+
</ErrorBoundary>
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
`;
|
|
1282
|
+
await fs.writeFile(path.join(providersDir, 'SentryProvider.tsx'), sentryProvider);
|
|
1283
|
+
|
|
1284
|
+
// GlobalProvider rewrite — Sentry outermost, then optional I18n, then Theme + Query.
|
|
1285
|
+
const globalProviderPath = path.join(targetDir, providersDirRel, 'GlobalProvider', 'index.tsx');
|
|
1286
|
+
const i18nImport = i18nActive ? `import { I18nProvider } from '../I18nProvider';\n` : '';
|
|
1287
|
+
const innerOpen = i18nActive ? ' <I18nProvider>\n ' : ' ';
|
|
1288
|
+
const innerClose = i18nActive ? '\n </I18nProvider>' : '';
|
|
1289
|
+
const globalProvider = `import { QueryClientProvider } from '@tanstack/react-query';
|
|
1290
|
+
import { type ReactNode, useState } from 'react';
|
|
1291
|
+
import { createQueryClient } from '${apiAlias}';
|
|
1292
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
1293
|
+
${i18nImport}import { SentryProvider } from '../SentryProvider';
|
|
1294
|
+
|
|
1295
|
+
export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
1296
|
+
const [queryClient] = useState(() => createQueryClient());
|
|
1297
|
+
return (
|
|
1298
|
+
<SentryProvider>
|
|
1299
|
+
${innerOpen}<ThemeProvider>
|
|
1300
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1301
|
+
</ThemeProvider>${innerClose}
|
|
1302
|
+
</SentryProvider>
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
`;
|
|
1306
|
+
await fs.writeFile(globalProviderPath, globalProvider);
|
|
1307
|
+
|
|
1308
|
+
// package.json deps
|
|
1309
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
1310
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1311
|
+
pkg.dependencies = pkg.dependencies ?? {};
|
|
1312
|
+
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
1313
|
+
pkg.dependencies['@sentry/react'] = '^8.45.0';
|
|
1314
|
+
pkg.devDependencies['@sentry/vite-plugin'] = '^2.22.7';
|
|
1315
|
+
pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
1316
|
+
pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
|
|
1317
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1318
|
+
|
|
1319
|
+
await patchViteConfigForSentry(targetDir);
|
|
1320
|
+
|
|
1321
|
+
// .env.example
|
|
1322
|
+
const envExamplePath = path.join(targetDir, '.env.example');
|
|
1323
|
+
const envBlock = `# Sentry observability (v0.93.0+) — 비워두면 init 자동 skip.
|
|
1324
|
+
VITE_SENTRY_DSN=
|
|
1325
|
+
VITE_APP_VERSION=
|
|
1326
|
+
# Source map upload (vite build 시).
|
|
1327
|
+
SENTRY_AUTH_TOKEN=
|
|
1328
|
+
SENTRY_ORG=
|
|
1329
|
+
SENTRY_PROJECT=
|
|
1330
|
+
`;
|
|
1331
|
+
if (await fs.pathExists(envExamplePath)) {
|
|
1332
|
+
const existing = await fs.readFile(envExamplePath, 'utf-8');
|
|
1333
|
+
if (!existing.includes('VITE_SENTRY_DSN')) {
|
|
1334
|
+
await fs.writeFile(envExamplePath, existing + '\n' + envBlock);
|
|
1335
|
+
}
|
|
1336
|
+
} else {
|
|
1337
|
+
await fs.writeFile(envExamplePath, envBlock);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
async function patchViteConfigForSentry(targetDir) {
|
|
1342
|
+
const viteCfgPath = path.join(targetDir, 'vite.config.ts');
|
|
1343
|
+
if (!(await fs.pathExists(viteCfgPath))) {
|
|
1344
|
+
throw new Error(`vite.config.ts 가 ${targetDir} 에 없습니다.`);
|
|
1345
|
+
}
|
|
1346
|
+
let cfg = await fs.readFile(viteCfgPath, 'utf-8');
|
|
1347
|
+
|
|
1348
|
+
if (!cfg.includes("@sentry/vite-plugin")) {
|
|
1349
|
+
cfg = cfg.replace(
|
|
1350
|
+
/import { defineConfig } from 'vite';/,
|
|
1351
|
+
`import { defineConfig } from 'vite';\nimport { sentryVitePlugin } from '@sentry/vite-plugin';`,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (!cfg.includes("sentryVitePlugin(")) {
|
|
1356
|
+
cfg = cfg.replace(
|
|
1357
|
+
/(plugins:\s*\[[^\]]*?)(\s*\])/s,
|
|
1358
|
+
(_, before, close) => {
|
|
1359
|
+
const sep = /,\s*$/.test(before) ? ' ' : ', ';
|
|
1360
|
+
return `${before}${sep}sentryVitePlugin({\n org: process.env.SENTRY_ORG,\n project: process.env.SENTRY_PROJECT,\n authToken: process.env.SENTRY_AUTH_TOKEN,\n disable: !process.env.SENTRY_AUTH_TOKEN,\n })${close}`;
|
|
1361
|
+
},
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (!cfg.includes("sourcemap:")) {
|
|
1366
|
+
if (cfg.includes("server: {")) {
|
|
1367
|
+
cfg = cfg.replace(/(\n\s*server:\s*\{)/, `\n build: { sourcemap: true },$1`);
|
|
1368
|
+
} else if (cfg.includes("plugins: [")) {
|
|
1369
|
+
cfg = cfg.replace(
|
|
1370
|
+
/(plugins:\s*\[[^\]]*?\][^,\n]*[,;]?\n)/s,
|
|
1371
|
+
`$1 build: { sourcemap: true },\n`,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
await fs.writeFile(viteCfgPath, cfg);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
1131
1380
|
await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
|
|
1132
1381
|
|
|
1133
1382
|
// Update root package.json
|
|
@@ -1136,6 +1385,11 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
1136
1385
|
rootPkg.name = projectName;
|
|
1137
1386
|
await fs.writeJson(rootPkgPath, rootPkg, { spaces: 2 });
|
|
1138
1387
|
|
|
1388
|
+
// CLAUDE.md 의 platform 분기 placeholder 치환. AI 에이전트가 Next.js 가정으로
|
|
1389
|
+
// 잘못된 컨벤션을 적용하지 않도록 (v0.94.0+).
|
|
1390
|
+
const platformAppDesc = describeAppPlatform(platform, { tauri });
|
|
1391
|
+
await replaceInAllFiles(targetDir, '{{PLATFORM_APP_DESCRIPTION}}', platformAppDesc);
|
|
1392
|
+
|
|
1139
1393
|
// Update turbo.json
|
|
1140
1394
|
const turboPath = path.join(targetDir, 'turbo.json');
|
|
1141
1395
|
const turbo = await fs.readJson(turboPath);
|
|
@@ -1159,7 +1413,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
1159
1413
|
|
|
1160
1414
|
const appsDir = path.join(targetDir, 'apps', appName);
|
|
1161
1415
|
if (platform === 'vite') {
|
|
1162
|
-
await generateViteApp(appsDir, appName, port, arch, css, { tauri, i18n, locales });
|
|
1416
|
+
await generateViteApp(appsDir, appName, port, arch, css, { tauri, i18n, locales, observability });
|
|
1163
1417
|
} else {
|
|
1164
1418
|
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
1165
1419
|
}
|
|
@@ -1260,7 +1514,7 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
|
|
|
1260
1514
|
}
|
|
1261
1515
|
}
|
|
1262
1516
|
|
|
1263
|
-
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { tauri = false, i18n = 'none', locales = 'ko,en' } = {}) {
|
|
1517
|
+
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
1264
1518
|
// 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
|
|
1265
1519
|
await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
|
|
1266
1520
|
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
@@ -1333,6 +1587,10 @@ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind',
|
|
|
1333
1587
|
const localesArr = parseLocales(locales);
|
|
1334
1588
|
await emitI18n(targetDir, { arch, locales: localesArr });
|
|
1335
1589
|
}
|
|
1590
|
+
|
|
1591
|
+
if (observability === 'sentry') {
|
|
1592
|
+
await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
|
|
1593
|
+
}
|
|
1336
1594
|
}
|
|
1337
1595
|
|
|
1338
1596
|
/**
|
|
@@ -1950,11 +2208,9 @@ function buildErrorModuleCss() {
|
|
|
1950
2208
|
* git init 은 dry-run 에서는 스킵하고, 실패해도(git 미설치 등) 조용히 넘어간다.
|
|
1951
2209
|
*/
|
|
1952
2210
|
async function finalizeProject(targetDir, { dryRun = false } = {}) {
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
await fs.move(noDot, withDot, { overwrite: true });
|
|
1957
|
-
}
|
|
2211
|
+
// 모노레포 / sub-app 까지 모든 `gitignore` 를 `.gitignore` 로 rename.
|
|
2212
|
+
// root 만 처리하면 apps/<name>/gitignore 가 그대로 남아 node_modules/dist 가 staged 된다 (v0.93.0 버그).
|
|
2213
|
+
await renameAllGitignoreRecursive(targetDir);
|
|
1958
2214
|
|
|
1959
2215
|
if (dryRun) return;
|
|
1960
2216
|
|
|
@@ -1965,6 +2221,40 @@ async function finalizeProject(targetDir, { dryRun = false } = {}) {
|
|
|
1965
2221
|
}
|
|
1966
2222
|
}
|
|
1967
2223
|
|
|
2224
|
+
/**
|
|
2225
|
+
* CLAUDE.md 의 `{{PLATFORM_APP_DESCRIPTION}}` 치환용 문장 — AI 에이전트에게 어떤 플랫폼인지
|
|
2226
|
+
* 정확히 전달해서 잘못된 컨벤션 (예: vite 프로젝트에 App Router 가정) 적용 방지.
|
|
2227
|
+
*/
|
|
2228
|
+
function describeAppPlatform(platform, { tauri = false } = {}) {
|
|
2229
|
+
if (platform === 'vite') {
|
|
2230
|
+
const tauriSuffix = tauri
|
|
2231
|
+
? ' Tauri 데스크탑 셸이 동봉되어 있어 `src-tauri/` 가 native 진입점이다.'
|
|
2232
|
+
: '';
|
|
2233
|
+
return `Vite SPA (React + TypeScript). 라우트 + 비즈니스 로직. RSC / App Router 없음 — 모든 코드가 클라이언트 사이드 실행이다.${tauriSuffix}`;
|
|
2234
|
+
}
|
|
2235
|
+
// 디폴트: Next.js. 향후 platform 추가 시 분기 늘릴 것.
|
|
2236
|
+
return 'Next.js 앱 (App Router + Server Components). 라우트 + 비즈니스 로직.';
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
async function renameAllGitignoreRecursive(dir) {
|
|
2240
|
+
let entries;
|
|
2241
|
+
try {
|
|
2242
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
2243
|
+
} catch {
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
for (const entry of entries) {
|
|
2247
|
+
const fullPath = path.join(dir, entry.name);
|
|
2248
|
+
if (entry.isDirectory()) {
|
|
2249
|
+
// 스캐폴드 직후엔 node_modules / .git 가 없지만 방어적으로.
|
|
2250
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
2251
|
+
await renameAllGitignoreRecursive(fullPath);
|
|
2252
|
+
} else if (entry.name === 'gitignore') {
|
|
2253
|
+
await fs.move(fullPath, path.join(dir, '.gitignore'), { overwrite: true });
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1968
2258
|
async function replaceInAllFiles(dir, search, replace) {
|
|
1969
2259
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1970
2260
|
for (const entry of entries) {
|
package/src/create/index.mjs
CHANGED
|
@@ -17,8 +17,8 @@ const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join('|');
|
|
|
17
17
|
export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
|
|
18
18
|
|
|
19
19
|
사용법:
|
|
20
|
-
sh-ui create [name] [options]
|
|
21
|
-
sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--tauri] [--i18n <react-i18next|none>] [--locales ko,en]
|
|
20
|
+
sh-ui create [name] [options] [--observability <none|sentry>]
|
|
21
|
+
sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--tauri] [--i18n <react-i18next|none>] [--locales ko,en] [--observability <none|sentry>]
|
|
22
22
|
sh-ui create add-component <name> [--app <name>]
|
|
23
23
|
|
|
24
24
|
옵션:
|
|
@@ -30,6 +30,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
30
30
|
--css <${CSS_FRAMEWORKS_SUPPORTED.join('|')}> CSS 프레임워크. base 파일까지 분기 emit (tailwind/plain/css-modules)
|
|
31
31
|
--i18n <react-i18next|none> vite 전용 — react-i18next 셋업 emit (i18n config + I18nProvider). 기본 none (v0.92.0+)
|
|
32
32
|
--locales <ko,en> i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en'
|
|
33
|
+
--observability <none|sentry> vite 전용 — Sentry 셋업 emit (@sentry/react + vite-plugin + SentryProvider). 기본 none (v0.93.0+)
|
|
33
34
|
--yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
|
|
34
35
|
--dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
|
|
35
36
|
-h, --help 이 도움말
|
|
@@ -79,6 +80,7 @@ export async function runCreate(rest) {
|
|
|
79
80
|
tauri: flags.tauri,
|
|
80
81
|
i18n: flags.i18n,
|
|
81
82
|
locales: flags.locales,
|
|
83
|
+
observability: flags.observability,
|
|
82
84
|
});
|
|
83
85
|
} else if (command === 'add-component') {
|
|
84
86
|
// 호환 별칭 — 신규 진입점은 `sh-ui add <name>` (bin/sh-ui.mjs 가 walk-up 으로 라우팅).
|
|
@@ -98,6 +100,7 @@ export async function runCreate(rest) {
|
|
|
98
100
|
tauri: flags.tauri,
|
|
99
101
|
i18n: flags.i18n,
|
|
100
102
|
locales: flags.locales,
|
|
103
|
+
observability: flags.observability,
|
|
101
104
|
yes: flags.yes,
|
|
102
105
|
dryRun: flags.dryRun,
|
|
103
106
|
});
|
package/src/mcp.mjs
CHANGED
|
@@ -48,9 +48,10 @@ import {
|
|
|
48
48
|
CSS_FRAMEWORKS_SUPPORTED,
|
|
49
49
|
I18N_LIBRARIES,
|
|
50
50
|
I18N_DEFAULT_LOCALES,
|
|
51
|
+
OBSERVABILITY_PROVIDERS,
|
|
51
52
|
} from "./constants.js";
|
|
52
53
|
import { allPlugins } from "./create/plugins/index.js";
|
|
53
|
-
import { allArchitectures } from "./create/architectures/index.js";
|
|
54
|
+
import { allArchitectures, describeArchOptions } from "./create/architectures/index.js";
|
|
54
55
|
import { describeTemplate } from "./create/describeTemplate.js";
|
|
55
56
|
import { THEME_PRESET_NAMES } from "./create/theme/presets.js";
|
|
56
57
|
import { decodeTheme } from "./create/theme/decode.js";
|
|
@@ -385,9 +386,10 @@ export async function startMcpServer() {
|
|
|
385
386
|
arch: z.enum(ARCH_NAMES).optional()
|
|
386
387
|
.describe(
|
|
387
388
|
`프로젝트 아키텍처 — 플랫폼별로 사용 가능한 값이 다름. ` +
|
|
388
|
-
|
|
389
|
-
`flutter 는 현재 arch 디스크립터 없음 (
|
|
390
|
-
`arch 와 플러그인은 별개 — arch 는 폴더 구조/import alias 컨벤션, 플러그인은
|
|
389
|
+
`사용 가능한 아키텍처 (의미 포함): ${describeArchOptions()}. ` +
|
|
390
|
+
`next 기본: fsd · vite 지원: fsd/flat · flutter 는 현재 arch 디스크립터 없음 (host 자체 default). ` +
|
|
391
|
+
`arch 와 플러그인은 별개 — arch 는 폴더 구조/import alias 컨벤션, 플러그인은 기능. ` +
|
|
392
|
+
`mes 는 폐기된 옵션이 아니라 별도 도메인-특화 아키텍처 (관리자/MES 류).`,
|
|
391
393
|
),
|
|
392
394
|
theme: z.string().optional()
|
|
393
395
|
.describe(`프리셋 이름 (${THEME_PRESETS_LIST}) 또는 base64 테마 코드. 사용자가 톤을 직접 손본 결과를 영구 보관하려면 sh_ui_encode_theme 으로 base64 를 만들어 여기에 넘긴다.`),
|
|
@@ -414,6 +416,11 @@ export async function startMcpServer() {
|
|
|
414
416
|
`i18n 활성화 시 생성할 locale 코드 (comma-separated, 2글자 또는 'ko-KR' 류). 기본 '${I18N_DEFAULT_LOCALES}'. 첫 locale 이 fallbackLng. ` +
|
|
415
417
|
"i18n='none' 이면 무시.",
|
|
416
418
|
),
|
|
419
|
+
observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
|
|
420
|
+
.describe(
|
|
421
|
+
"observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
|
|
422
|
+
"@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
|
|
423
|
+
),
|
|
417
424
|
},
|
|
418
425
|
},
|
|
419
426
|
async (input) => {
|
|
@@ -453,6 +460,15 @@ export async function startMcpServer() {
|
|
|
453
460
|
}],
|
|
454
461
|
};
|
|
455
462
|
}
|
|
463
|
+
if (input.observability && input.observability !== "none" && input.platform !== "vite") {
|
|
464
|
+
return {
|
|
465
|
+
isError: true,
|
|
466
|
+
content: [{
|
|
467
|
+
type: "text",
|
|
468
|
+
text: `observability='${input.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
|
|
469
|
+
}],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
456
472
|
const targetParent = resolveCwd(input);
|
|
457
473
|
const targetDir = resolve(targetParent, input.name);
|
|
458
474
|
if (existsSync(targetDir) && !input.force) {
|
|
@@ -481,6 +497,7 @@ export async function startMcpServer() {
|
|
|
481
497
|
tauri: input.tauri,
|
|
482
498
|
i18n: input.i18n,
|
|
483
499
|
locales: input.locales,
|
|
500
|
+
observability: input.observability,
|
|
484
501
|
yes: true, // 사전 검사를 마쳤으니 generator 의 confirm 프롬프트 우회
|
|
485
502
|
}),
|
|
486
503
|
);
|
|
@@ -524,6 +541,11 @@ export async function startMcpServer() {
|
|
|
524
541
|
.describe(
|
|
525
542
|
`i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 '${I18N_DEFAULT_LOCALES}'. 첫 locale 이 fallbackLng. i18n='none' 이면 무시.`,
|
|
526
543
|
),
|
|
544
|
+
observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
|
|
545
|
+
.describe(
|
|
546
|
+
"observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
|
|
547
|
+
"@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
|
|
548
|
+
),
|
|
527
549
|
cwd: z.string().optional()
|
|
528
550
|
.describe("모노레포 루트 (pnpm-workspace.yaml 있는 곳). 기본 process.cwd()"),
|
|
529
551
|
},
|
|
@@ -552,6 +574,15 @@ export async function startMcpServer() {
|
|
|
552
574
|
}],
|
|
553
575
|
};
|
|
554
576
|
}
|
|
577
|
+
if (input.observability && input.observability !== "none" && input.platform && input.platform !== "vite") {
|
|
578
|
+
return {
|
|
579
|
+
isError: true,
|
|
580
|
+
content: [{
|
|
581
|
+
type: "text",
|
|
582
|
+
text: `observability='${input.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
|
|
583
|
+
}],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
555
586
|
const text = await captureConsole(() =>
|
|
556
587
|
addApp({
|
|
557
588
|
name: input.name,
|
|
@@ -563,6 +594,7 @@ export async function startMcpServer() {
|
|
|
563
594
|
tauri: input.tauri,
|
|
564
595
|
i18n: input.i18n,
|
|
565
596
|
locales: input.locales,
|
|
597
|
+
observability: input.observability,
|
|
566
598
|
cwd: resolveCwd(input),
|
|
567
599
|
}),
|
|
568
600
|
);
|
|
@@ -899,7 +931,11 @@ export async function startMcpServer() {
|
|
|
899
931
|
structure: z.enum(CREATE_STRUCTURES).optional()
|
|
900
932
|
.describe("Next.js 구조. platform=next | vite 일 때 의미. 기본 standalone"),
|
|
901
933
|
arch: z.enum(ARCH_NAMES).optional()
|
|
902
|
-
.describe(
|
|
934
|
+
.describe(
|
|
935
|
+
`아키텍처. 기본 fsd. ` +
|
|
936
|
+
`옵션 (의미 포함): ${describeArchOptions()}. ` +
|
|
937
|
+
`mes 는 deprecated 가 아니라 도메인-특화 옵션.`,
|
|
938
|
+
),
|
|
903
939
|
plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
|
|
904
940
|
.describe(`Next.js 플러그인 배열 (${PLUGIN_NAMES.join(', ')}). 미지정 빈 배열`),
|
|
905
941
|
cssFramework: z.enum(CSS_FRAMEWORKS).optional()
|
|
@@ -5,13 +5,26 @@ sh-ui CLI 가 스캐폴드한 monorepo (Turborepo + pnpm workspace). AI 에이
|
|
|
5
5
|
|
|
6
6
|
## 구조
|
|
7
7
|
|
|
8
|
-
- `apps/<name>/` —
|
|
8
|
+
- `apps/<name>/` — {{PLATFORM_APP_DESCRIPTION}}
|
|
9
9
|
- `packages/ui/ui-core/` — 모든 앱이 공유하는 sh-ui 컴포넌트 / 훅 / 유틸 SoT.
|
|
10
10
|
컴포넌트 추가는 여기에 한 번만.
|
|
11
11
|
- `packages/ui/ui-apps/ui-<name>/` — 앱별 토큰 (color/spacing/font) 만 보관.
|
|
12
12
|
컴포넌트는 두지 않음 (v0.65+ `tokens-only` 마커).
|
|
13
13
|
- `packages/eslint-config/` · `packages/typescript-config/` — 공용 설정.
|
|
14
14
|
|
|
15
|
+
## 아키텍처 옵션 (`--arch`)
|
|
16
|
+
|
|
17
|
+
- **`fsd`** (default) — Feature-Sliced Design. `src/{app,pages,widgets,features,entities,shared}`
|
|
18
|
+
레이어로 단방향 의존(상위→하위). 일반적 SPA / 서비스에 적합.
|
|
19
|
+
- **`flat`** — `src/{components,hooks,lib,pages}` 단순 구조. 작은 앱 / 학습용.
|
|
20
|
+
- **`mes`** — MES (Backoffice) 전용. 페이지 격리 + 단방향 의존 강제. ERP/내부 관리도구
|
|
21
|
+
처럼 페이지 간 분리도가 중요한 도메인. Next.js 만 지원 (vite-app 은 fsd/flat 만).
|
|
22
|
+
|
|
23
|
+
세 arch 모두 `packages/eslint-config/` 에 별도 ruleset 으로 들어가 있다 — 라이브러리
|
|
24
|
+
이므로 모두 emit 되지만, 본인 앱의 `eslint.config.js` 에서 선택한 arch 의 config 만
|
|
25
|
+
import 한다. 다른 arch 의 `.js` 파일이 보여도 deprecated 가 아니라 다른 앱이
|
|
26
|
+
쓸 수 있는 옵션이다.
|
|
27
|
+
|
|
15
28
|
## 날짜 / 숫자 포맷
|
|
16
29
|
|
|
17
30
|
- raw `Date.prototype.toLocaleDateString()` / `toLocaleString()` / `toLocaleTimeString()`
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
sh-ui CLI 가 스캐폴드한 Next.js standalone 프로젝트. AI 에이전트 (Claude / Cursor /
|
|
4
4
|
Codex 등) 가 이 파일을 컨텍스트로 읽고 아래 규칙을 따른다.
|
|
5
5
|
|
|
6
|
+
## 아키텍처 옵션 (`--arch`)
|
|
7
|
+
|
|
8
|
+
- **`fsd`** (default) — Feature-Sliced Design. `src/{app,pages,widgets,features,entities,shared}`
|
|
9
|
+
레이어로 단방향 의존(상위→하위). 일반적 SPA / 서비스에 적합.
|
|
10
|
+
- **`flat`** — `src/{components,hooks,lib}` 단순 구조. 작은 앱 / 학습용.
|
|
11
|
+
- **`mes`** — MES (Backoffice) 전용. 페이지 격리 + 단방향 의존 강제. ERP/내부 관리도구
|
|
12
|
+
처럼 페이지 간 분리도가 중요한 도메인.
|
|
13
|
+
|
|
14
|
+
선택한 arch 에 따라 `eslint.config.js` 가 다르게 emit 된다.
|
|
15
|
+
|
|
6
16
|
## 날짜 / 숫자 포맷
|
|
7
17
|
|
|
8
18
|
- raw `Date.prototype.toLocaleDateString()` / `toLocaleString()` / `toLocaleTimeString()`
|