sh-ui-cli 0.96.3 → 0.98.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/README.md +1 -1
- package/data/changelog/versions.json +26 -0
- package/package.json +1 -1
- package/src/api.d.ts +0 -4
- package/src/constants.js +0 -5
- package/src/create/cli-args.js +2 -6
- package/src/create/describeTemplate.js +3 -51
- package/src/create/generator.js +12 -816
- package/src/create/index.mjs +2 -7
- package/src/create/plugins/index.js +1 -3
- package/src/create/plugins/nextIntl.js +0 -4
- package/src/create/plugins/pluginSchema.js +1 -1
- package/src/create/templateManifest.js +0 -12
- package/src/mcp.mjs +1 -68
- package/templates/vite-standalone/_arch/flat/index.html +2 -2
- package/templates/vite-standalone/_arch/flat/src/lib/styles/globals.css +2 -2
- package/templates/vite-standalone/_arch/fsd/index.html +2 -2
- package/templates/vite-standalone/_arch/fsd/src/shared/styles/globals.css +2 -2
- package/src/create/plugins/authJwt.js +0 -420
- package/src/create/plugins/sentry.js +0 -467
- package/templates/tauri-shell/Cargo.toml +0 -21
- package/templates/tauri-shell/README.md +0 -49
- package/templates/tauri-shell/build.rs +0 -3
- package/templates/tauri-shell/capabilities/default.json +0 -12
- package/templates/tauri-shell/src/lib.rs +0 -8
- package/templates/tauri-shell/src/main.rs +0 -6
- package/templates/tauri-shell/tauri.conf.json +0 -29
package/src/create/generator.js
CHANGED
|
@@ -213,17 +213,6 @@ 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
|
-
}
|
|
226
|
-
|
|
227
216
|
// i18n 옵션도 vite preset 전용. v0.92.0+.
|
|
228
217
|
if (options.i18n && options.i18n !== 'none' && platform !== 'vite') {
|
|
229
218
|
throw new Error(
|
|
@@ -231,13 +220,6 @@ export async function createProject(options = {}) {
|
|
|
231
220
|
);
|
|
232
221
|
}
|
|
233
222
|
|
|
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
|
-
|
|
241
223
|
// arch 결정 — platform 확정 후. 사용자가 --arch 미지정 시:
|
|
242
224
|
// - next → DEFAULT_ARCH ('fsd')
|
|
243
225
|
// - flutter → 현재 Flutter arch 디스크립터 없음 → null. 미래에 flutter arch 추가되면
|
|
@@ -359,19 +341,15 @@ export async function createProject(options = {}) {
|
|
|
359
341
|
|
|
360
342
|
if (projectType === 'standalone') {
|
|
361
343
|
await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase, {
|
|
362
|
-
tauri: !!options.tauri,
|
|
363
344
|
i18n: options.i18n ?? 'none',
|
|
364
345
|
locales: options.locales ?? 'ko,en',
|
|
365
|
-
observability: options.observability ?? 'none',
|
|
366
346
|
});
|
|
367
347
|
} else {
|
|
368
348
|
await generateMonorepo(targetDir, projectName, [], {
|
|
369
349
|
yes: options.yes, theme, css: cssFramework, arch, themeBase,
|
|
370
350
|
platform: 'vite',
|
|
371
|
-
tauri: options.tauri,
|
|
372
351
|
i18n: options.i18n ?? 'none',
|
|
373
352
|
locales: options.locales ?? 'ko,en',
|
|
374
|
-
observability: options.observability ?? 'none',
|
|
375
353
|
appName: options.appName ?? null,
|
|
376
354
|
port: options.port ?? null,
|
|
377
355
|
});
|
|
@@ -392,14 +370,6 @@ export async function createProject(options = {}) {
|
|
|
392
370
|
console.log(`\n cd ${projectName}`);
|
|
393
371
|
console.log(' pnpm install');
|
|
394
372
|
console.log(' pnpm dev\n');
|
|
395
|
-
if (options.tauri) {
|
|
396
|
-
console.log('Tauri 데스크탑 셸:');
|
|
397
|
-
if (projectType === 'monorepo') {
|
|
398
|
-
console.log(' cd apps/web # 또는 첫 앱 이름');
|
|
399
|
-
}
|
|
400
|
-
console.log(' pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시 후 5~10초');
|
|
401
|
-
console.log(' (Rust 미설치 시 https://rustup.rs/ 참고)\n');
|
|
402
|
-
}
|
|
403
373
|
console.log('다음 단계 — 베이스 컴포넌트 추가 (예시):');
|
|
404
374
|
console.log(' npx sh-ui-cli add button card input dialog\n');
|
|
405
375
|
return;
|
|
@@ -415,7 +385,7 @@ export async function createProject(options = {}) {
|
|
|
415
385
|
});
|
|
416
386
|
|
|
417
387
|
// plugins 는 미지정시 기본 빈 배열 — prompt 띄우지 않는다.
|
|
418
|
-
// (플러그인을 쓰려면 명시적으로 --plugins
|
|
388
|
+
// (플러그인을 쓰려면 명시적으로 --plugins next-intl 사용)
|
|
419
389
|
const selectedPluginNames = options.plugins ?? [];
|
|
420
390
|
|
|
421
391
|
const plugins = getPluginsByNames(selectedPluginNames);
|
|
@@ -534,21 +504,11 @@ export async function addApp(options = {}) {
|
|
|
534
504
|
`add-app 는 platform=next 또는 vite 만 지원 (받은 값: ${platform}). flutter 는 standalone 만 지원.`,
|
|
535
505
|
);
|
|
536
506
|
}
|
|
537
|
-
if (options.tauri && platform !== 'vite') {
|
|
538
|
-
throw new Error(
|
|
539
|
-
`tauri 는 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --platform vite 사용 또는 tauri 옵션 제거.`,
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
507
|
if (options.i18n && options.i18n !== 'none' && platform !== 'vite') {
|
|
543
508
|
throw new Error(
|
|
544
509
|
`i18n='${options.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}).`,
|
|
545
510
|
);
|
|
546
511
|
}
|
|
547
|
-
if (options.observability && options.observability !== 'none' && platform !== 'vite') {
|
|
548
|
-
throw new Error(
|
|
549
|
-
`observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
512
|
|
|
553
513
|
const appName = validateProjectName(
|
|
554
514
|
options.name ?? await input({
|
|
@@ -600,10 +560,8 @@ export async function addApp(options = {}) {
|
|
|
600
560
|
|
|
601
561
|
if (platform === 'vite') {
|
|
602
562
|
await generateViteApp(appsDir, appName, port, arch, css, {
|
|
603
|
-
tauri: !!options.tauri,
|
|
604
563
|
i18n: options.i18n ?? 'none',
|
|
605
564
|
locales: options.locales ?? 'ko,en',
|
|
606
|
-
observability: options.observability ?? 'none',
|
|
607
565
|
});
|
|
608
566
|
} else {
|
|
609
567
|
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
@@ -620,13 +578,6 @@ export async function addApp(options = {}) {
|
|
|
620
578
|
console.log(`\n✅ apps/${appName} 이 추가되었습니다!`);
|
|
621
579
|
console.log('\n pnpm install');
|
|
622
580
|
console.log(` pnpm --filter ${appName} dev\n`);
|
|
623
|
-
|
|
624
|
-
if (platform === 'vite' && options.tauri) {
|
|
625
|
-
console.log('Tauri 데스크탑 셸:');
|
|
626
|
-
console.log(` cd apps/${appName}`);
|
|
627
|
-
console.log(' pnpm tauri dev # Rust 처음 빌드는 5~10분 — 캐시 후 5~10초');
|
|
628
|
-
console.log(' (Rust 미설치 시 https://rustup.rs/ 참고)\n');
|
|
629
|
-
}
|
|
630
581
|
}
|
|
631
582
|
|
|
632
583
|
/**
|
|
@@ -867,7 +818,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
|
|
|
867
818
|
await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
|
|
868
819
|
}
|
|
869
820
|
|
|
870
|
-
async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, {
|
|
821
|
+
async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { i18n = 'none', locales = 'ko,en' } = {}) {
|
|
871
822
|
// 베이스 (arch-neutral) + arch 오버레이 — generateStandalone 과 같은 패턴.
|
|
872
823
|
await fs.copy(path.join(TEMPLATES_DIR, 'vite-standalone'), targetDir, {
|
|
873
824
|
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
@@ -903,115 +854,10 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
|
|
|
903
854
|
await injectCssTheme(targetDir, theme);
|
|
904
855
|
await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
|
|
905
856
|
|
|
906
|
-
// Tauri 셸 emit (옵션) — vite SPA + native window. standalone 만 v1 지원.
|
|
907
|
-
if (tauri) {
|
|
908
|
-
await emitTauri(targetDir, projectName, { devPort: 5173 });
|
|
909
|
-
await patchViteForTauri(targetDir, { port: 5173 });
|
|
910
|
-
}
|
|
911
|
-
|
|
912
857
|
if (i18n === 'react-i18next') {
|
|
913
858
|
const localesArr = parseLocales(locales);
|
|
914
859
|
await emitI18n(targetDir, { arch, locales: localesArr });
|
|
915
860
|
}
|
|
916
|
-
|
|
917
|
-
if (observability === 'sentry') {
|
|
918
|
-
await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* tauri-shell 템플릿을 `<targetDir>/src-tauri/` 로 복사하고 placeholder 치환.
|
|
924
|
-
*
|
|
925
|
-
* - `{{project_name}}` → projectName (kebab-case 유지, npm 패키지명과 동일)
|
|
926
|
-
* - `{{tauri_crate_name}}` → snake_case 변환 (Rust crate name 규칙: 영숫자+언더스코어).
|
|
927
|
-
* 하이픈/점/대문자가 들어 있으면 모두 안전한 형태로 정규화.
|
|
928
|
-
*
|
|
929
|
-
* generateViteStandalone 에서 tauri: true 인 경우 호출. monorepo+tauri 는 v0.89 후속.
|
|
930
|
-
*/
|
|
931
|
-
async function emitTauri(targetDir, projectName, { devPort = 5173 } = {}) {
|
|
932
|
-
const srcTauriDir = path.join(targetDir, 'src-tauri');
|
|
933
|
-
await fs.copy(path.join(TEMPLATES_DIR, 'tauri-shell'), srcTauriDir);
|
|
934
|
-
|
|
935
|
-
// crate name: snake_case 강제 — Rust 식별자는 영숫자+'_' 만 허용
|
|
936
|
-
const tauriCrateName = projectName
|
|
937
|
-
.toLowerCase()
|
|
938
|
-
.replace(/[^a-z0-9]+/g, '_')
|
|
939
|
-
.replace(/^_+|_+$/g, '');
|
|
940
|
-
|
|
941
|
-
await replaceInAllFiles(srcTauriDir, '{{tauri_crate_name}}', tauriCrateName);
|
|
942
|
-
await replaceInAllFiles(srcTauriDir, '{{project_name}}', projectName);
|
|
943
|
-
await replaceInAllFiles(srcTauriDir, '{{tauri_dev_url}}', `http://localhost:${devPort}`);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/**
|
|
947
|
-
* vite 앱의 package.json + vite.config.ts 를 Tauri 친화적으로 패치.
|
|
948
|
-
*
|
|
949
|
-
* - package.json: `@tauri-apps/cli` (devDep), `@tauri-apps/api` (dep), `tauri`/`tauri:dev`/`tauri:build` scripts 추가
|
|
950
|
-
* - vite.config.ts: Tauri 공식 권장값 추가 — `clearScreen: false`, `server.strictPort: true`,
|
|
951
|
-
* `server.host: false`, `server.port: 5173`. 그래야 Tauri 가 dev server 를 안정적으로 wrap.
|
|
952
|
-
*
|
|
953
|
-
* NOTE: vite.config.ts 를 전부 재작성한다. 현재 base template 의 vite.config.ts 는 arch-neutral
|
|
954
|
-
* 이라 안전. 후속 task 에서 arch-specific vite.config.ts overlay 가 생기면 이 자리에서 머지 전략
|
|
955
|
-
* 필요 (현재는 단순 overwrite).
|
|
956
|
-
*/
|
|
957
|
-
async function patchViteForTauri(targetDir, { port = 5173 } = {}) {
|
|
958
|
-
const pkgPath = path.join(targetDir, 'package.json');
|
|
959
|
-
const pkg = await fs.readJson(pkgPath);
|
|
960
|
-
|
|
961
|
-
pkg.dependencies = pkg.dependencies ?? {};
|
|
962
|
-
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
963
|
-
pkg.scripts = pkg.scripts ?? {};
|
|
964
|
-
|
|
965
|
-
pkg.dependencies['@tauri-apps/api'] = '^2.0.0';
|
|
966
|
-
pkg.dependencies['@tauri-apps/plugin-opener'] = '^2.0.0';
|
|
967
|
-
pkg.devDependencies['@tauri-apps/cli'] = '^2.0.0';
|
|
968
|
-
|
|
969
|
-
pkg.scripts.tauri = 'tauri';
|
|
970
|
-
pkg.scripts['tauri:dev'] = 'tauri dev';
|
|
971
|
-
pkg.scripts['tauri:build'] = 'tauri build';
|
|
972
|
-
|
|
973
|
-
pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
974
|
-
pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
|
|
975
|
-
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
976
|
-
|
|
977
|
-
// vite.config.ts 재작성 — Tauri 공식 권장 설정 추가.
|
|
978
|
-
const viteCfgPath = path.join(targetDir, 'vite.config.ts');
|
|
979
|
-
const viteCfg = `import { defineConfig } from 'vite';
|
|
980
|
-
import react from '@vitejs/plugin-react';
|
|
981
|
-
import tailwindcss from '@tailwindcss/vite';
|
|
982
|
-
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
983
|
-
|
|
984
|
-
// Tauri 권장 설정 (https://v2.tauri.app/start/frontend/vite/)
|
|
985
|
-
// - clearScreen: false — Rust 컴파일 에러가 터미널을 가리지 않게
|
|
986
|
-
// - server.strictPort — Tauri 가 사용할 포트를 고정 (충돌 시 에러)
|
|
987
|
-
// - server.host: false — Tauri dev 가 host network 안 열어도 됨
|
|
988
|
-
export default defineConfig({
|
|
989
|
-
plugins: [react(), tailwindcss(), tsconfigPaths()],
|
|
990
|
-
clearScreen: false,
|
|
991
|
-
server: {
|
|
992
|
-
port: ${port},
|
|
993
|
-
strictPort: true,
|
|
994
|
-
host: false,
|
|
995
|
-
},
|
|
996
|
-
});
|
|
997
|
-
`;
|
|
998
|
-
await fs.writeFile(viteCfgPath, viteCfg);
|
|
999
|
-
|
|
1000
|
-
// .gitignore 에 src-tauri/target 추가 — Rust 빌드 산출물 (수 GB 가능).
|
|
1001
|
-
// 스캐폴드 단계에서는 파일명이 `gitignore` (점 없음); finalizeProject 가 나중에 `.gitignore` 로 rename.
|
|
1002
|
-
// 양쪽 이름 모두 시도해서 호출 순서가 달라져도 안전하게 적용.
|
|
1003
|
-
const gitignoreCandidates = ['.gitignore', 'gitignore'];
|
|
1004
|
-
for (const name of gitignoreCandidates) {
|
|
1005
|
-
const p = path.join(targetDir, name);
|
|
1006
|
-
if (await fs.pathExists(p)) {
|
|
1007
|
-
let ignore = await fs.readFile(p, 'utf-8');
|
|
1008
|
-
if (!ignore.includes('src-tauri/target')) {
|
|
1009
|
-
ignore += `\n# Tauri build artifacts\nsrc-tauri/target/\n`;
|
|
1010
|
-
await fs.writeFile(p, ignore);
|
|
1011
|
-
}
|
|
1012
|
-
break;
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
861
|
}
|
|
1016
862
|
|
|
1017
863
|
/**
|
|
@@ -1054,7 +900,6 @@ import { initReactI18next } from 'react-i18next';
|
|
|
1054
900
|
// 클라이언트 측 lazy-load — 빌드 산출물에서 public/locales/{lng}/{ns}.json 경로로 fetch.
|
|
1055
901
|
// 원본 locale 파일은 ${i18nDirRel}/locales/* 에 두고, vite-plugin-static-copy 가
|
|
1056
902
|
// dev/build 양쪽에서 public/locales/* 로 자동 미러링한다 (vite.config.ts 참고).
|
|
1057
|
-
// Tauri 빌드의 경우도 dist/locales 에 그대로 포함된다.
|
|
1058
903
|
|
|
1059
904
|
i18n
|
|
1060
905
|
.use(HttpBackend)
|
|
@@ -1219,8 +1064,8 @@ async function patchLandingForI18n(targetDir, { arch }) {
|
|
|
1219
1064
|
*
|
|
1220
1065
|
* - balanced bracket 으로 outer `]` 를 찾으므로 `viteStaticCopy({ targets: [...] })`
|
|
1221
1066
|
* 처럼 안쪽에 `[]` 가 있어도 안전.
|
|
1222
|
-
* -
|
|
1223
|
-
*
|
|
1067
|
+
* - i18n 의 viteStaticCopy 처럼 nested `[]` 를 가진 plugin 을 append 할 때
|
|
1068
|
+
* anchor 경합으로 entry 가 안쪽 배열에 inject 되던 v0.92~0.95 회귀의 원인을 제거.
|
|
1224
1069
|
*
|
|
1225
1070
|
* 입력 src 가 예상 형태가 아니면 (`plugins: [` 못 찾음 / unbalanced bracket)
|
|
1226
1071
|
* `null` 을 반환 → 호출부에서 patch 포기.
|
|
@@ -1357,182 +1202,7 @@ function parseLocales(input) {
|
|
|
1357
1202
|
return cleaned.length > 0 ? cleaned : ['ko', 'en'];
|
|
1358
1203
|
}
|
|
1359
1204
|
|
|
1360
|
-
|
|
1361
|
-
* Sentry observability 셋업 emit (v0.93.0+). vite preset 전용 opt-in.
|
|
1362
|
-
*
|
|
1363
|
-
* - shared/observability/sentry.ts — Sentry.init (DSN 있을 때만)
|
|
1364
|
-
* - shared/observability/index.ts — Sentry + ErrorBoundary re-export
|
|
1365
|
-
* - app/providers/SentryProvider.tsx — ErrorBoundary wrapper
|
|
1366
|
-
* - GlobalProvider 재작성 — Sentry > [I18n?] > Theme > Query 순서
|
|
1367
|
-
* - package.json — @sentry/react + @sentry/vite-plugin 추가
|
|
1368
|
-
* - vite.config.ts — sentryVitePlugin 삽입 + sourcemap: true
|
|
1369
|
-
* - .env.example — Sentry 변수 안내 블록 추가
|
|
1370
|
-
*
|
|
1371
|
-
* @param {string} targetDir — 앱 디렉토리
|
|
1372
|
-
* @param {object} opts
|
|
1373
|
-
* @param {object} opts.arch — arch descriptor
|
|
1374
|
-
* @param {boolean} [opts.i18nActive] — i18n 도 같이 켜져 있는지 (GlobalProvider wrapping 순서)
|
|
1375
|
-
*/
|
|
1376
|
-
async function emitSentry(targetDir, { arch, i18nActive = false }) {
|
|
1377
|
-
const isFsd = arch.name === 'fsd';
|
|
1378
|
-
const obsDirRel = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
1379
|
-
const obsAlias = isFsd ? '@/shared/observability' : '@/lib/observability';
|
|
1380
|
-
const providersDirRel = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
1381
|
-
const apiAlias = isFsd ? '@/shared/api/queryClient' : '@/lib/api/queryClient';
|
|
1382
|
-
|
|
1383
|
-
const obsDir = path.join(targetDir, obsDirRel);
|
|
1384
|
-
await fs.ensureDir(obsDir);
|
|
1385
|
-
|
|
1386
|
-
const sentryTs = `/// <reference types="vite/client" />
|
|
1387
|
-
import * as Sentry from '@sentry/react';
|
|
1388
|
-
|
|
1389
|
-
// VITE_SENTRY_DSN 이 있을 때만 init — 로컬 dev 에선 자동 skip.
|
|
1390
|
-
// GlitchTip self-hosted 도 같은 SDK — DSN 만 변경.
|
|
1391
|
-
if (import.meta.env.VITE_SENTRY_DSN) {
|
|
1392
|
-
Sentry.init({
|
|
1393
|
-
dsn: import.meta.env.VITE_SENTRY_DSN,
|
|
1394
|
-
release: import.meta.env.VITE_APP_VERSION,
|
|
1395
|
-
environment: import.meta.env.MODE,
|
|
1396
|
-
tracesSampleRate: 0.1,
|
|
1397
|
-
replaysOnErrorSampleRate: 1.0,
|
|
1398
|
-
replaysSessionSampleRate: 0,
|
|
1399
|
-
integrations: [
|
|
1400
|
-
Sentry.browserTracingIntegration(),
|
|
1401
|
-
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
|
1402
|
-
],
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
export { Sentry };
|
|
1407
|
-
`;
|
|
1408
|
-
await fs.writeFile(path.join(obsDir, 'sentry.ts'), sentryTs);
|
|
1409
|
-
|
|
1410
|
-
await fs.writeFile(
|
|
1411
|
-
path.join(obsDir, 'index.ts'),
|
|
1412
|
-
`export { Sentry } from './sentry';\nexport { ErrorBoundary } from '@sentry/react';\n`,
|
|
1413
|
-
);
|
|
1414
|
-
|
|
1415
|
-
const providersDir = path.join(targetDir, providersDirRel);
|
|
1416
|
-
await fs.ensureDir(providersDir);
|
|
1417
|
-
const sentryProvider = `import { type ReactNode } from 'react';
|
|
1418
|
-
import { ErrorBoundary } from '${obsAlias}';
|
|
1419
|
-
|
|
1420
|
-
function Fallback({ error }: { error: unknown }) {
|
|
1421
|
-
return (
|
|
1422
|
-
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
1423
|
-
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>오류가 발생했습니다</h1>
|
|
1424
|
-
<p style={{ marginTop: '0.5rem', color: 'var(--foreground-muted)' }}>
|
|
1425
|
-
{error instanceof Error ? error.message : '알 수 없는 오류'}
|
|
1426
|
-
</p>
|
|
1427
|
-
</div>
|
|
1428
|
-
);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
export function SentryProvider({ children }: { children: ReactNode }) {
|
|
1432
|
-
return (
|
|
1433
|
-
<ErrorBoundary fallback={({ error }) => <Fallback error={error} />}>
|
|
1434
|
-
{children}
|
|
1435
|
-
</ErrorBoundary>
|
|
1436
|
-
);
|
|
1437
|
-
}
|
|
1438
|
-
`;
|
|
1439
|
-
await fs.writeFile(path.join(providersDir, 'SentryProvider.tsx'), sentryProvider);
|
|
1440
|
-
|
|
1441
|
-
// GlobalProvider rewrite — Sentry outermost, then optional I18n, then Theme + Query.
|
|
1442
|
-
const globalProviderPath = path.join(targetDir, providersDirRel, 'GlobalProvider', 'index.tsx');
|
|
1443
|
-
const i18nImport = i18nActive ? `import { I18nProvider } from '../I18nProvider';\n` : '';
|
|
1444
|
-
const innerOpen = i18nActive ? ' <I18nProvider>\n ' : ' ';
|
|
1445
|
-
const innerClose = i18nActive ? '\n </I18nProvider>' : '';
|
|
1446
|
-
const globalProvider = `import { QueryClientProvider } from '@tanstack/react-query';
|
|
1447
|
-
import { type ReactNode, useState } from 'react';
|
|
1448
|
-
import { createQueryClient } from '${apiAlias}';
|
|
1449
|
-
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
1450
|
-
${i18nImport}import { SentryProvider } from '../SentryProvider';
|
|
1451
|
-
|
|
1452
|
-
export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
1453
|
-
const [queryClient] = useState(() => createQueryClient());
|
|
1454
|
-
return (
|
|
1455
|
-
<SentryProvider>
|
|
1456
|
-
${innerOpen}<ThemeProvider>
|
|
1457
|
-
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1458
|
-
</ThemeProvider>${innerClose}
|
|
1459
|
-
</SentryProvider>
|
|
1460
|
-
);
|
|
1461
|
-
}
|
|
1462
|
-
`;
|
|
1463
|
-
await fs.writeFile(globalProviderPath, globalProvider);
|
|
1464
|
-
|
|
1465
|
-
// package.json deps
|
|
1466
|
-
const pkgPath = path.join(targetDir, 'package.json');
|
|
1467
|
-
const pkg = await fs.readJson(pkgPath);
|
|
1468
|
-
pkg.dependencies = pkg.dependencies ?? {};
|
|
1469
|
-
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
1470
|
-
pkg.dependencies['@sentry/react'] = '^8.45.0';
|
|
1471
|
-
pkg.devDependencies['@sentry/vite-plugin'] = '^2.22.7';
|
|
1472
|
-
pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
1473
|
-
pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
|
|
1474
|
-
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1475
|
-
|
|
1476
|
-
await patchViteConfigForSentry(targetDir);
|
|
1477
|
-
|
|
1478
|
-
// .env.example
|
|
1479
|
-
const envExamplePath = path.join(targetDir, '.env.example');
|
|
1480
|
-
const envBlock = `# Sentry observability (v0.93.0+) — 비워두면 init 자동 skip.
|
|
1481
|
-
VITE_SENTRY_DSN=
|
|
1482
|
-
VITE_APP_VERSION=
|
|
1483
|
-
# Source map upload (vite build 시).
|
|
1484
|
-
SENTRY_AUTH_TOKEN=
|
|
1485
|
-
SENTRY_ORG=
|
|
1486
|
-
SENTRY_PROJECT=
|
|
1487
|
-
`;
|
|
1488
|
-
if (await fs.pathExists(envExamplePath)) {
|
|
1489
|
-
const existing = await fs.readFile(envExamplePath, 'utf-8');
|
|
1490
|
-
if (!existing.includes('VITE_SENTRY_DSN')) {
|
|
1491
|
-
await fs.writeFile(envExamplePath, existing + '\n' + envBlock);
|
|
1492
|
-
}
|
|
1493
|
-
} else {
|
|
1494
|
-
await fs.writeFile(envExamplePath, envBlock);
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
async function patchViteConfigForSentry(targetDir) {
|
|
1499
|
-
const viteCfgPath = path.join(targetDir, 'vite.config.ts');
|
|
1500
|
-
if (!(await fs.pathExists(viteCfgPath))) {
|
|
1501
|
-
throw new Error(`vite.config.ts 가 ${targetDir} 에 없습니다.`);
|
|
1502
|
-
}
|
|
1503
|
-
let cfg = await fs.readFile(viteCfgPath, 'utf-8');
|
|
1504
|
-
|
|
1505
|
-
if (!cfg.includes("@sentry/vite-plugin")) {
|
|
1506
|
-
cfg = cfg.replace(
|
|
1507
|
-
/import { defineConfig } from 'vite';/,
|
|
1508
|
-
`import { defineConfig } from 'vite';\nimport { sentryVitePlugin } from '@sentry/vite-plugin';`,
|
|
1509
|
-
);
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
if (!cfg.includes("sentryVitePlugin(")) {
|
|
1513
|
-
// i18n 의 viteStaticCopy 처럼 plugins 배열 안에 nested `[]` 가 있을 수 있으므로
|
|
1514
|
-
// balanced-bracket helper 로 outer entry append.
|
|
1515
|
-
const sentryCall = `sentryVitePlugin({
|
|
1516
|
-
org: process.env.SENTRY_ORG,
|
|
1517
|
-
project: process.env.SENTRY_PROJECT,
|
|
1518
|
-
authToken: process.env.SENTRY_AUTH_TOKEN,
|
|
1519
|
-
disable: !process.env.SENTRY_AUTH_TOKEN,
|
|
1520
|
-
})`;
|
|
1521
|
-
const patched = appendVitePluginEntry(cfg, sentryCall);
|
|
1522
|
-
if (patched) cfg = patched;
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
if (!cfg.includes("sourcemap:")) {
|
|
1526
|
-
if (cfg.includes("server: {")) {
|
|
1527
|
-
cfg = cfg.replace(/(\n\s*server:\s*\{)/, `\n build: { sourcemap: true },$1`);
|
|
1528
|
-
}
|
|
1529
|
-
// server 가 없는 변종은 build 삽입 위치가 모호 → 보수적으로 skip (사용자가 수동 추가).
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
await fs.writeFile(viteCfgPath, cfg);
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none', appName: appNameOpt = null, port: portOpt = null } = {}) {
|
|
1205
|
+
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', i18n = 'none', locales = 'ko,en', appName: appNameOpt = null, port: portOpt = null } = {}) {
|
|
1536
1206
|
await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
|
|
1537
1207
|
|
|
1538
1208
|
// Update root package.json
|
|
@@ -1543,7 +1213,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
1543
1213
|
|
|
1544
1214
|
// CLAUDE.md 의 platform 분기 placeholder 치환. AI 에이전트가 Next.js 가정으로
|
|
1545
1215
|
// 잘못된 컨벤션을 적용하지 않도록 (v0.94.0+).
|
|
1546
|
-
const platformAppDesc = describeAppPlatform(platform
|
|
1216
|
+
const platformAppDesc = describeAppPlatform(platform);
|
|
1547
1217
|
await replaceInAllFiles(targetDir, '{{PLATFORM_APP_DESCRIPTION}}', platformAppDesc);
|
|
1548
1218
|
|
|
1549
1219
|
// Update turbo.json
|
|
@@ -1560,10 +1230,6 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
1560
1230
|
turbo.tasks.build.outputs = ['dist/**'];
|
|
1561
1231
|
// vite 는 클라이언트 노출 env 가 VITE_ 접두사 관례.
|
|
1562
1232
|
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
1233
|
}
|
|
1568
1234
|
turbo.globalEnv = [...new Set(turbo.globalEnv)];
|
|
1569
1235
|
await fs.writeJson(turboPath, turbo, { spaces: 2 });
|
|
@@ -1584,7 +1250,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
1584
1250
|
|
|
1585
1251
|
const appsDir = path.join(targetDir, 'apps', appName);
|
|
1586
1252
|
if (platform === 'vite') {
|
|
1587
|
-
await generateViteApp(appsDir, appName, port, arch, css, {
|
|
1253
|
+
await generateViteApp(appsDir, appName, port, arch, css, { i18n, locales });
|
|
1588
1254
|
} else {
|
|
1589
1255
|
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
1590
1256
|
}
|
|
@@ -1685,7 +1351,7 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
|
|
|
1685
1351
|
}
|
|
1686
1352
|
}
|
|
1687
1353
|
|
|
1688
|
-
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', {
|
|
1354
|
+
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { i18n = 'none', locales = 'ko,en' } = {}) {
|
|
1689
1355
|
// 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
|
|
1690
1356
|
await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
|
|
1691
1357
|
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
@@ -1745,23 +1411,10 @@ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind',
|
|
|
1745
1411
|
await applyCssFrameworkVariant(uiPkgDir, css, { isMonorepo: true, plugins: [], arch, isUiPackage: true });
|
|
1746
1412
|
}
|
|
1747
1413
|
|
|
1748
|
-
// tauri 가 켜져 있으면 이 app 안에 src-tauri/ shell 을 떨어뜨린다 (v0.90.0+).
|
|
1749
|
-
// standalone 과 달리 monorepo 에서는 app 단위로 별도 dev port (default 3000) 가 있고,
|
|
1750
|
-
// tauri 의 devUrl 이 그 port 와 일치해야 한다. frontendDist 는 src-tauri 기준 `../dist`.
|
|
1751
|
-
if (tauri) {
|
|
1752
|
-
const devPort = Number(port) || 3000;
|
|
1753
|
-
await emitTauri(targetDir, appName, { devPort });
|
|
1754
|
-
await patchViteForTauri(targetDir, { port: devPort });
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
1414
|
if (i18n === 'react-i18next') {
|
|
1758
1415
|
const localesArr = parseLocales(locales);
|
|
1759
1416
|
await emitI18n(targetDir, { arch, locales: localesArr });
|
|
1760
1417
|
}
|
|
1761
|
-
|
|
1762
|
-
if (observability === 'sentry') {
|
|
1763
|
-
await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
|
|
1764
|
-
}
|
|
1765
1418
|
}
|
|
1766
1419
|
|
|
1767
1420
|
/**
|
|
@@ -1957,30 +1610,10 @@ export default function Home() {
|
|
|
1957
1610
|
}
|
|
1958
1611
|
}
|
|
1959
1612
|
|
|
1960
|
-
// 6)
|
|
1961
|
-
// intl + sentry 면 nextIntl 이 [locale]/error.tsx 를 i18n-aware 로 replace 하므로 그것도 변환.
|
|
1962
|
-
const sentryActive = plugins?.some((p) => p.name === 'sentry');
|
|
1963
|
-
if (sentryActive) {
|
|
1964
|
-
const errorCandidates = intlActive
|
|
1965
|
-
? [path.join(targetDir, 'app/[locale]/error.tsx')]
|
|
1966
|
-
: [path.join(targetDir, 'app/error.tsx')];
|
|
1967
|
-
for (const errPath of errorCandidates) {
|
|
1968
|
-
if (!(await fs.pathExists(errPath))) continue;
|
|
1969
|
-
const useI18n = intlActive;
|
|
1970
|
-
await fs.writeFile(errPath, buildErrorTsxNonTailwind({ useI18n, cssFramework, arch }));
|
|
1971
|
-
if (cssFramework === 'css-modules') {
|
|
1972
|
-
await fs.writeFile(
|
|
1973
|
-
path.join(path.dirname(errPath), 'error.module.css'),
|
|
1974
|
-
buildErrorModuleCss(),
|
|
1975
|
-
);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
// 7) .prettierrc — tailwind plugin 제거.
|
|
1613
|
+
// 6) .prettierrc — tailwind plugin 제거.
|
|
1981
1614
|
await stripTailwindFromPrettier(path.join(targetDir, '.prettierrc'));
|
|
1982
1615
|
|
|
1983
|
-
//
|
|
1616
|
+
// 7) monorepo 인 경우 root .prettierrc 와 root package.json 도 정리 (root 의 prettier-plugin-tailwindcss).
|
|
1984
1617
|
// applyCssFrameworkVariant 는 apps/web 마다 호출되지만 root 정리는 1회면 충분 — idempotent 라 OK.
|
|
1985
1618
|
if (isMonorepo && !isUiPackage) {
|
|
1986
1619
|
const monorepoRoot = path.resolve(targetDir, '..', '..');
|
|
@@ -2010,365 +1643,6 @@ async function stripTailwindFromPrettier(prettierPath) {
|
|
|
2010
1643
|
await fs.writeJson(prettierPath, c, { spaces: 2 });
|
|
2011
1644
|
}
|
|
2012
1645
|
|
|
2013
|
-
/**
|
|
2014
|
-
* sentry 의 error.tsx 를 plain/cssmodules 로 변환한 콘텐츠 생성.
|
|
2015
|
-
* useI18n=true 면 next-intl 의 useTranslations + Link 사용.
|
|
2016
|
-
*/
|
|
2017
|
-
function buildErrorTsxNonTailwind({ useI18n, cssFramework, arch }) {
|
|
2018
|
-
const i18nImports = useI18n
|
|
2019
|
-
? `import { useTranslations } from 'next-intl';\n`
|
|
2020
|
-
: '';
|
|
2021
|
-
const configAlias = arch ? arch.aliases.config : '@/src/shared/config';
|
|
2022
|
-
const linkImport = useI18n
|
|
2023
|
-
? `import { Link } from '${configAlias}/i18n/navigation';\n`
|
|
2024
|
-
: `import Link from 'next/link';\n`;
|
|
2025
|
-
const tHook = useI18n ? ` const t = useTranslations('error');\n` : '';
|
|
2026
|
-
const titleText = useI18n ? `{t('title')}` : `오류가 발생했습니다`;
|
|
2027
|
-
const descText = useI18n
|
|
2028
|
-
? `{t('description')}`
|
|
2029
|
-
: `예상치 못한 오류가 발생했습니다. 다시 시도해주세요.`;
|
|
2030
|
-
const fallback = useI18n ? `t('unexpectedError')` : `'알 수 없는 오류'`;
|
|
2031
|
-
const tryAgain = useI18n ? `{t('button.tryAgain')}` : `다시 시도`;
|
|
2032
|
-
const goHome = useI18n ? `{t('button.goHome')}` : `홈으로 이동`;
|
|
2033
|
-
|
|
2034
|
-
if (cssFramework === 'css-modules') {
|
|
2035
|
-
return `'use client';
|
|
2036
|
-
|
|
2037
|
-
import * as Sentry from '@sentry/nextjs';
|
|
2038
|
-
import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
|
|
2039
|
-
${i18nImports}${linkImport}import { useEffect } from 'react';
|
|
2040
|
-
|
|
2041
|
-
import styles from './error.module.css';
|
|
2042
|
-
|
|
2043
|
-
export default function Error({
|
|
2044
|
-
error,
|
|
2045
|
-
reset,
|
|
2046
|
-
}: {
|
|
2047
|
-
error: Error & { digest?: string };
|
|
2048
|
-
reset: () => void;
|
|
2049
|
-
}) {
|
|
2050
|
-
${tHook} useEffect(() => {
|
|
2051
|
-
Sentry.captureException(error);
|
|
2052
|
-
}, [error]);
|
|
2053
|
-
|
|
2054
|
-
return (
|
|
2055
|
-
<div className={styles.wrapper}>
|
|
2056
|
-
<div className={styles.card}>
|
|
2057
|
-
<div className={styles.iconRow}>
|
|
2058
|
-
<div className={styles.iconCircle}>
|
|
2059
|
-
<AlertTriangle className={styles.icon} />
|
|
2060
|
-
</div>
|
|
2061
|
-
</div>
|
|
2062
|
-
|
|
2063
|
-
<h2 className={styles.title}>${titleText}</h2>
|
|
2064
|
-
<p className={styles.description}>${descText}</p>
|
|
2065
|
-
|
|
2066
|
-
<div className={styles.errorBox}>
|
|
2067
|
-
<p className={styles.errorText}>{error.message || ${fallback}}</p>
|
|
2068
|
-
</div>
|
|
2069
|
-
|
|
2070
|
-
<div className={styles.actions}>
|
|
2071
|
-
<button onClick={reset} className={styles.primaryButton}>
|
|
2072
|
-
<RefreshCw className={styles.buttonIcon} />
|
|
2073
|
-
${tryAgain}
|
|
2074
|
-
</button>
|
|
2075
|
-
|
|
2076
|
-
<Link href='/' className={styles.secondaryButton}>
|
|
2077
|
-
<Home className={styles.buttonIcon} />
|
|
2078
|
-
${goHome}
|
|
2079
|
-
</Link>
|
|
2080
|
-
</div>
|
|
2081
|
-
|
|
2082
|
-
{process.env.NODE_ENV === 'development' && error.digest && (
|
|
2083
|
-
<div className={styles.digest}>
|
|
2084
|
-
<p className={styles.digestText}>Error ID: {error.digest}</p>
|
|
2085
|
-
</div>
|
|
2086
|
-
)}
|
|
2087
|
-
</div>
|
|
2088
|
-
</div>
|
|
2089
|
-
);
|
|
2090
|
-
}
|
|
2091
|
-
`;
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
// plain — inline style (토큰 var 활용)
|
|
2095
|
-
return `'use client';
|
|
2096
|
-
|
|
2097
|
-
import * as Sentry from '@sentry/nextjs';
|
|
2098
|
-
import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
|
|
2099
|
-
${i18nImports}${linkImport}import { useEffect } from 'react';
|
|
2100
|
-
|
|
2101
|
-
const wrapper: React.CSSProperties = {
|
|
2102
|
-
display: 'flex',
|
|
2103
|
-
minHeight: '100vh',
|
|
2104
|
-
alignItems: 'center',
|
|
2105
|
-
justifyContent: 'center',
|
|
2106
|
-
padding: '0 16px',
|
|
2107
|
-
};
|
|
2108
|
-
const card: React.CSSProperties = {
|
|
2109
|
-
width: '100%',
|
|
2110
|
-
maxWidth: 448,
|
|
2111
|
-
borderRadius: 8,
|
|
2112
|
-
border: '1px solid var(--border)',
|
|
2113
|
-
background: 'var(--background)',
|
|
2114
|
-
padding: 24,
|
|
2115
|
-
boxShadow: 'var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.15))',
|
|
2116
|
-
};
|
|
2117
|
-
|
|
2118
|
-
export default function Error({
|
|
2119
|
-
error,
|
|
2120
|
-
reset,
|
|
2121
|
-
}: {
|
|
2122
|
-
error: Error & { digest?: string };
|
|
2123
|
-
reset: () => void;
|
|
2124
|
-
}) {
|
|
2125
|
-
${tHook} useEffect(() => {
|
|
2126
|
-
Sentry.captureException(error);
|
|
2127
|
-
}, [error]);
|
|
2128
|
-
|
|
2129
|
-
return (
|
|
2130
|
-
<div style={wrapper}>
|
|
2131
|
-
<div style={card}>
|
|
2132
|
-
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
|
|
2133
|
-
<div
|
|
2134
|
-
style={{
|
|
2135
|
-
width: 64,
|
|
2136
|
-
height: 64,
|
|
2137
|
-
borderRadius: '50%',
|
|
2138
|
-
background: 'color-mix(in srgb, var(--danger) 10%, transparent)',
|
|
2139
|
-
display: 'flex',
|
|
2140
|
-
alignItems: 'center',
|
|
2141
|
-
justifyContent: 'center',
|
|
2142
|
-
}}
|
|
2143
|
-
>
|
|
2144
|
-
<AlertTriangle style={{ width: 32, height: 32, color: 'var(--danger)' }} />
|
|
2145
|
-
</div>
|
|
2146
|
-
</div>
|
|
2147
|
-
|
|
2148
|
-
<h2
|
|
2149
|
-
style={{
|
|
2150
|
-
fontSize: 24,
|
|
2151
|
-
fontWeight: 700,
|
|
2152
|
-
textAlign: 'center',
|
|
2153
|
-
color: 'var(--foreground)',
|
|
2154
|
-
margin: '0 0 8px',
|
|
2155
|
-
}}
|
|
2156
|
-
>
|
|
2157
|
-
${titleText}
|
|
2158
|
-
</h2>
|
|
2159
|
-
<p
|
|
2160
|
-
style={{
|
|
2161
|
-
fontSize: 14,
|
|
2162
|
-
color: 'var(--foreground-muted)',
|
|
2163
|
-
textAlign: 'center',
|
|
2164
|
-
margin: '0 0 24px',
|
|
2165
|
-
}}
|
|
2166
|
-
>
|
|
2167
|
-
${descText}
|
|
2168
|
-
</p>
|
|
2169
|
-
|
|
2170
|
-
<div
|
|
2171
|
-
style={{
|
|
2172
|
-
borderRadius: 6,
|
|
2173
|
-
border: '1px solid color-mix(in srgb, var(--danger) 30%, transparent)',
|
|
2174
|
-
background: 'color-mix(in srgb, var(--danger) 5%, transparent)',
|
|
2175
|
-
padding: 12,
|
|
2176
|
-
}}
|
|
2177
|
-
>
|
|
2178
|
-
<p style={{ fontSize: 14, color: 'var(--danger)', margin: 0 }}>
|
|
2179
|
-
{error.message || ${fallback}}
|
|
2180
|
-
</p>
|
|
2181
|
-
</div>
|
|
2182
|
-
|
|
2183
|
-
<div style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
2184
|
-
<button
|
|
2185
|
-
onClick={reset}
|
|
2186
|
-
style={{
|
|
2187
|
-
width: '100%',
|
|
2188
|
-
display: 'flex',
|
|
2189
|
-
alignItems: 'center',
|
|
2190
|
-
justifyContent: 'center',
|
|
2191
|
-
gap: 8,
|
|
2192
|
-
padding: '8px 16px',
|
|
2193
|
-
borderRadius: 6,
|
|
2194
|
-
border: 'none',
|
|
2195
|
-
background: 'var(--primary)',
|
|
2196
|
-
color: 'var(--primary-foreground)',
|
|
2197
|
-
fontSize: 14,
|
|
2198
|
-
fontWeight: 500,
|
|
2199
|
-
cursor: 'pointer',
|
|
2200
|
-
}}
|
|
2201
|
-
>
|
|
2202
|
-
<RefreshCw style={{ width: 16, height: 16 }} />
|
|
2203
|
-
${tryAgain}
|
|
2204
|
-
</button>
|
|
2205
|
-
|
|
2206
|
-
<Link
|
|
2207
|
-
href='/'
|
|
2208
|
-
style={{
|
|
2209
|
-
width: '100%',
|
|
2210
|
-
display: 'flex',
|
|
2211
|
-
alignItems: 'center',
|
|
2212
|
-
justifyContent: 'center',
|
|
2213
|
-
gap: 8,
|
|
2214
|
-
padding: '8px 16px',
|
|
2215
|
-
borderRadius: 6,
|
|
2216
|
-
border: '1px solid var(--border)',
|
|
2217
|
-
color: 'var(--foreground)',
|
|
2218
|
-
fontSize: 14,
|
|
2219
|
-
fontWeight: 500,
|
|
2220
|
-
textDecoration: 'none',
|
|
2221
|
-
}}
|
|
2222
|
-
>
|
|
2223
|
-
<Home style={{ width: 16, height: 16 }} />
|
|
2224
|
-
${goHome}
|
|
2225
|
-
</Link>
|
|
2226
|
-
</div>
|
|
2227
|
-
|
|
2228
|
-
{process.env.NODE_ENV === 'development' && error.digest && (
|
|
2229
|
-
<div
|
|
2230
|
-
style={{
|
|
2231
|
-
marginTop: 16,
|
|
2232
|
-
borderRadius: 6,
|
|
2233
|
-
background: 'var(--background-subtle)',
|
|
2234
|
-
padding: 12,
|
|
2235
|
-
}}
|
|
2236
|
-
>
|
|
2237
|
-
<p style={{ fontSize: 12, color: 'var(--foreground-subtle)', margin: 0 }}>
|
|
2238
|
-
Error ID: {error.digest}
|
|
2239
|
-
</p>
|
|
2240
|
-
</div>
|
|
2241
|
-
)}
|
|
2242
|
-
</div>
|
|
2243
|
-
</div>
|
|
2244
|
-
);
|
|
2245
|
-
}
|
|
2246
|
-
`;
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
function buildErrorModuleCss() {
|
|
2250
|
-
return `.wrapper {
|
|
2251
|
-
display: flex;
|
|
2252
|
-
min-height: 100vh;
|
|
2253
|
-
align-items: center;
|
|
2254
|
-
justify-content: center;
|
|
2255
|
-
padding: 0 16px;
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
.card {
|
|
2259
|
-
width: 100%;
|
|
2260
|
-
max-width: 448px;
|
|
2261
|
-
border-radius: 8px;
|
|
2262
|
-
border: 1px solid var(--border);
|
|
2263
|
-
background: var(--background);
|
|
2264
|
-
padding: 24px;
|
|
2265
|
-
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
.iconRow {
|
|
2269
|
-
display: flex;
|
|
2270
|
-
justify-content: center;
|
|
2271
|
-
margin-bottom: 16px;
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
.iconCircle {
|
|
2275
|
-
width: 64px;
|
|
2276
|
-
height: 64px;
|
|
2277
|
-
border-radius: 50%;
|
|
2278
|
-
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
|
2279
|
-
display: flex;
|
|
2280
|
-
align-items: center;
|
|
2281
|
-
justify-content: center;
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
.icon {
|
|
2285
|
-
width: 32px;
|
|
2286
|
-
height: 32px;
|
|
2287
|
-
color: var(--danger);
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
.title {
|
|
2291
|
-
font-size: 24px;
|
|
2292
|
-
font-weight: 700;
|
|
2293
|
-
text-align: center;
|
|
2294
|
-
color: var(--foreground);
|
|
2295
|
-
margin: 0 0 8px;
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
.description {
|
|
2299
|
-
font-size: 14px;
|
|
2300
|
-
color: var(--foreground-muted);
|
|
2301
|
-
text-align: center;
|
|
2302
|
-
margin: 0 0 24px;
|
|
2303
|
-
}
|
|
2304
|
-
|
|
2305
|
-
.errorBox {
|
|
2306
|
-
border-radius: 6px;
|
|
2307
|
-
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
|
|
2308
|
-
background: color-mix(in srgb, var(--danger) 5%, transparent);
|
|
2309
|
-
padding: 12px;
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
.errorText {
|
|
2313
|
-
font-size: 14px;
|
|
2314
|
-
color: var(--danger);
|
|
2315
|
-
margin: 0;
|
|
2316
|
-
}
|
|
2317
|
-
|
|
2318
|
-
.actions {
|
|
2319
|
-
margin-top: 24px;
|
|
2320
|
-
display: flex;
|
|
2321
|
-
flex-direction: column;
|
|
2322
|
-
gap: 12px;
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
.primaryButton,
|
|
2326
|
-
.secondaryButton {
|
|
2327
|
-
width: 100%;
|
|
2328
|
-
display: flex;
|
|
2329
|
-
align-items: center;
|
|
2330
|
-
justify-content: center;
|
|
2331
|
-
gap: 8px;
|
|
2332
|
-
padding: 8px 16px;
|
|
2333
|
-
border-radius: 6px;
|
|
2334
|
-
font-size: 14px;
|
|
2335
|
-
font-weight: 500;
|
|
2336
|
-
cursor: pointer;
|
|
2337
|
-
text-decoration: none;
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
.primaryButton {
|
|
2341
|
-
border: none;
|
|
2342
|
-
background: var(--primary);
|
|
2343
|
-
color: var(--primary-foreground);
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
|
-
.secondaryButton {
|
|
2347
|
-
border: 1px solid var(--border);
|
|
2348
|
-
color: var(--foreground);
|
|
2349
|
-
background: transparent;
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
.buttonIcon {
|
|
2353
|
-
width: 16px;
|
|
2354
|
-
height: 16px;
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
|
-
.digest {
|
|
2358
|
-
margin-top: 16px;
|
|
2359
|
-
border-radius: 6px;
|
|
2360
|
-
background: var(--background-subtle);
|
|
2361
|
-
padding: 12px;
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
|
-
.digestText {
|
|
2365
|
-
font-size: 12px;
|
|
2366
|
-
color: var(--foreground-subtle);
|
|
2367
|
-
margin: 0;
|
|
2368
|
-
}
|
|
2369
|
-
`;
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
1646
|
/**
|
|
2373
1647
|
* 스캐폴드 마무리 — `gitignore` 파일을 `.gitignore` 로 되돌리고 `git init` 실행.
|
|
2374
1648
|
*
|
|
@@ -2396,12 +1670,9 @@ async function finalizeProject(targetDir, { dryRun = false } = {}) {
|
|
|
2396
1670
|
* CLAUDE.md 의 `{{PLATFORM_APP_DESCRIPTION}}` 치환용 문장 — AI 에이전트에게 어떤 플랫폼인지
|
|
2397
1671
|
* 정확히 전달해서 잘못된 컨벤션 (예: vite 프로젝트에 App Router 가정) 적용 방지.
|
|
2398
1672
|
*/
|
|
2399
|
-
function describeAppPlatform(platform
|
|
1673
|
+
function describeAppPlatform(platform) {
|
|
2400
1674
|
if (platform === 'vite') {
|
|
2401
|
-
|
|
2402
|
-
? ' Tauri 데스크탑 셸이 동봉되어 있어 `src-tauri/` 가 native 진입점이다.'
|
|
2403
|
-
: '';
|
|
2404
|
-
return `Vite SPA (React + TypeScript). 라우트 + 비즈니스 로직. RSC / App Router 없음 — 모든 코드가 클라이언트 사이드 실행이다.${tauriSuffix}`;
|
|
1675
|
+
return `Vite SPA (React + TypeScript). 라우트 + 비즈니스 로직. RSC / App Router 없음 — 모든 코드가 클라이언트 사이드 실행이다.`;
|
|
2405
1676
|
}
|
|
2406
1677
|
// 디폴트: Next.js. 향후 platform 추가 시 분기 늘릴 것.
|
|
2407
1678
|
return 'Next.js 앱 (App Router + Server Components). 라우트 + 비즈니스 로직.';
|
|
@@ -2528,81 +1799,6 @@ async function writePluginFiles(targetDir, plugins, arch) {
|
|
|
2528
1799
|
}
|
|
2529
1800
|
}
|
|
2530
1801
|
}
|
|
2531
|
-
|
|
2532
|
-
// auth-jwt + next-intl 동시 활성화 시 proxy.ts 병합
|
|
2533
|
-
// (각 플러그인이 단독으로 깐 proxy.ts 를 합친 버전으로 덮어쓴다)
|
|
2534
|
-
// i18n routing import 는 arch.aliases.config 기준 — FSD 면 @/src/shared/config,
|
|
2535
|
-
// flat 이면 @/lib/config 로 해석.
|
|
2536
|
-
const names = new Set(plugins.map((p) => p.name));
|
|
2537
|
-
if (names.has('auth-jwt') && names.has('next-intl')) {
|
|
2538
|
-
const configAlias = arch ? arch.aliases.config : '@/src/shared/config';
|
|
2539
|
-
const mergedProxy = `import createIntlMiddleware from 'next-intl/middleware';
|
|
2540
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
2541
|
-
|
|
2542
|
-
import { routing } from '${configAlias}/i18n/routing';
|
|
2543
|
-
|
|
2544
|
-
const AUTH_ROUTES = ['/sign-in', '/sign-up'];
|
|
2545
|
-
|
|
2546
|
-
/**
|
|
2547
|
-
* 홈(\`/\`, \`/{locale}\`) 진입 시 redirect 할 path. 빈 문자열이면
|
|
2548
|
-
* \`app/[locale]/page.tsx\` 가 그대로 노출. 예: '/dashboard', '/projects'.
|
|
2549
|
-
* 인증 가드 위에서 동작하므로 미인증이면 그대로 \`/sign-in\` 으로 빠진다.
|
|
2550
|
-
*/
|
|
2551
|
-
const HOME_REDIRECT = '';
|
|
2552
|
-
|
|
2553
|
-
const intl = createIntlMiddleware(routing);
|
|
2554
|
-
|
|
2555
|
-
/**
|
|
2556
|
-
* 로케일 prefix (/ko, /en) 를 벗겨 인증 라우트 매칭에 사용한다.
|
|
2557
|
-
* 예: /ko/sign-in → /sign-in
|
|
2558
|
-
*/
|
|
2559
|
-
const stripLocalePrefix = (pathname: string): string => {
|
|
2560
|
-
const locales = routing.locales as readonly string[];
|
|
2561
|
-
const segments = pathname.split('/').filter(Boolean);
|
|
2562
|
-
if (segments[0] && locales.includes(segments[0])) {
|
|
2563
|
-
const rest = segments.slice(1).join('/');
|
|
2564
|
-
return \`/\${rest}\`.replace(/\\/$/, '') || '/';
|
|
2565
|
-
}
|
|
2566
|
-
return pathname;
|
|
2567
|
-
};
|
|
2568
|
-
|
|
2569
|
-
/**
|
|
2570
|
-
* Next 16+ proxy.ts (구 middleware.ts).
|
|
2571
|
-
* next-intl 라우팅 + auth-jwt 토큰 존재 체크 합성 버전.
|
|
2572
|
-
*
|
|
2573
|
-
* - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트 (인증 가드보다 먼저)
|
|
2574
|
-
* - intl 이 로케일 prefix 처리 + NEXT_LOCALE 쿠키 set
|
|
2575
|
-
* - 그 위에 인증 가드 — 토큰 없고 인증 라우트도 아니면 /sign-in 으로 redirect
|
|
2576
|
-
* - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
|
|
2577
|
-
* - AT 만료 검사나 refresh 는 하지 않는다 (BFF 가 처리)
|
|
2578
|
-
*/
|
|
2579
|
-
const DEV_BYPASS =
|
|
2580
|
-
process.env.NODE_ENV !== 'production' &&
|
|
2581
|
-
process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
|
|
2582
|
-
|
|
2583
|
-
export default function proxy(req: NextRequest) {
|
|
2584
|
-
const intlRes = intl(req);
|
|
2585
|
-
const pathname = stripLocalePrefix(req.nextUrl.pathname);
|
|
2586
|
-
const hasToken = !!req.cookies.get('accessToken')?.value;
|
|
2587
|
-
const isAuthRoute = AUTH_ROUTES.some((r) => pathname.startsWith(r));
|
|
2588
|
-
|
|
2589
|
-
if (pathname === '/' && HOME_REDIRECT) {
|
|
2590
|
-
return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
|
|
2591
|
-
}
|
|
2592
|
-
|
|
2593
|
-
if (DEV_BYPASS) return intlRes;
|
|
2594
|
-
if (isAuthRoute) return intlRes;
|
|
2595
|
-
if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
|
|
2596
|
-
|
|
2597
|
-
return intlRes;
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
|
-
export const config = {
|
|
2601
|
-
matcher: '/((?!api|_next|_vercel|monitoring|.*\\\\..*).*)',
|
|
2602
|
-
};
|
|
2603
|
-
`;
|
|
2604
|
-
await fs.writeFile(path.join(targetDir, 'proxy.ts'), mergedProxy);
|
|
2605
|
-
}
|
|
2606
1802
|
}
|
|
2607
1803
|
|
|
2608
1804
|
async function composeProviders(targetDir, plugins, arch) {
|