sh-ui-cli 0.97.0 → 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.
@@ -220,13 +220,6 @@ export async function createProject(options = {}) {
220
220
  );
221
221
  }
222
222
 
223
- // observability 옵션도 vite preset 전용. v0.93.0+.
224
- if (options.observability && options.observability !== 'none' && platform !== 'vite') {
225
- throw new Error(
226
- `observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
227
- );
228
- }
229
-
230
223
  // arch 결정 — platform 확정 후. 사용자가 --arch 미지정 시:
231
224
  // - next → DEFAULT_ARCH ('fsd')
232
225
  // - flutter → 현재 Flutter arch 디스크립터 없음 → null. 미래에 flutter arch 추가되면
@@ -350,7 +343,6 @@ export async function createProject(options = {}) {
350
343
  await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase, {
351
344
  i18n: options.i18n ?? 'none',
352
345
  locales: options.locales ?? 'ko,en',
353
- observability: options.observability ?? 'none',
354
346
  });
355
347
  } else {
356
348
  await generateMonorepo(targetDir, projectName, [], {
@@ -358,7 +350,6 @@ export async function createProject(options = {}) {
358
350
  platform: 'vite',
359
351
  i18n: options.i18n ?? 'none',
360
352
  locales: options.locales ?? 'ko,en',
361
- observability: options.observability ?? 'none',
362
353
  appName: options.appName ?? null,
363
354
  port: options.port ?? null,
364
355
  });
@@ -394,7 +385,7 @@ export async function createProject(options = {}) {
394
385
  });
395
386
 
396
387
  // plugins 는 미지정시 기본 빈 배열 — prompt 띄우지 않는다.
397
- // (플러그인을 쓰려면 명시적으로 --plugins sentry,next-intl,auth-jwt 사용)
388
+ // (플러그인을 쓰려면 명시적으로 --plugins next-intl 사용)
398
389
  const selectedPluginNames = options.plugins ?? [];
399
390
 
400
391
  const plugins = getPluginsByNames(selectedPluginNames);
@@ -518,11 +509,6 @@ export async function addApp(options = {}) {
518
509
  `i18n='${options.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}).`,
519
510
  );
520
511
  }
521
- if (options.observability && options.observability !== 'none' && platform !== 'vite') {
522
- throw new Error(
523
- `observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
524
- );
525
- }
526
512
 
527
513
  const appName = validateProjectName(
528
514
  options.name ?? await input({
@@ -576,7 +562,6 @@ export async function addApp(options = {}) {
576
562
  await generateViteApp(appsDir, appName, port, arch, css, {
577
563
  i18n: options.i18n ?? 'none',
578
564
  locales: options.locales ?? 'ko,en',
579
- observability: options.observability ?? 'none',
580
565
  });
581
566
  } else {
582
567
  await generateApp(appsDir, appName, port, plugins, arch, css);
@@ -833,7 +818,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
833
818
  await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
834
819
  }
835
820
 
836
- async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
821
+ async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { i18n = 'none', locales = 'ko,en' } = {}) {
837
822
  // 베이스 (arch-neutral) + arch 오버레이 — generateStandalone 과 같은 패턴.
838
823
  await fs.copy(path.join(TEMPLATES_DIR, 'vite-standalone'), targetDir, {
839
824
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
@@ -873,10 +858,6 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
873
858
  const localesArr = parseLocales(locales);
874
859
  await emitI18n(targetDir, { arch, locales: localesArr });
875
860
  }
876
-
877
- if (observability === 'sentry') {
878
- await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
879
- }
880
861
  }
881
862
 
882
863
  /**
@@ -1083,8 +1064,8 @@ async function patchLandingForI18n(targetDir, { arch }) {
1083
1064
  *
1084
1065
  * - balanced bracket 으로 outer `]` 를 찾으므로 `viteStaticCopy({ targets: [...] })`
1085
1066
  * 처럼 안쪽에 `[]` 가 있어도 안전.
1086
- * - 이상의 patch (i18n + sentry 등) 같은 vite.config.ts 순차로
1087
- * 건드릴 때 anchor 경합으로 entry 가 안쪽 배열에 inject 되던 v0.92~0.95 회귀의 원인을 제거.
1067
+ * - i18n viteStaticCopy 처럼 nested `[]` 가진 plugin append
1068
+ * anchor 경합으로 entry 가 안쪽 배열에 inject 되던 v0.92~0.95 회귀의 원인을 제거.
1088
1069
  *
1089
1070
  * 입력 src 가 예상 형태가 아니면 (`plugins: [` 못 찾음 / unbalanced bracket)
1090
1071
  * `null` 을 반환 → 호출부에서 patch 포기.
@@ -1221,182 +1202,7 @@ function parseLocales(input) {
1221
1202
  return cleaned.length > 0 ? cleaned : ['ko', 'en'];
1222
1203
  }
1223
1204
 
1224
- /**
1225
- * Sentry observability 셋업 emit (v0.93.0+). vite preset 전용 opt-in.
1226
- *
1227
- * - shared/observability/sentry.ts — Sentry.init (DSN 있을 때만)
1228
- * - shared/observability/index.ts — Sentry + ErrorBoundary re-export
1229
- * - app/providers/SentryProvider.tsx — ErrorBoundary wrapper
1230
- * - GlobalProvider 재작성 — Sentry > [I18n?] > Theme > Query 순서
1231
- * - package.json — @sentry/react + @sentry/vite-plugin 추가
1232
- * - vite.config.ts — sentryVitePlugin 삽입 + sourcemap: true
1233
- * - .env.example — Sentry 변수 안내 블록 추가
1234
- *
1235
- * @param {string} targetDir — 앱 디렉토리
1236
- * @param {object} opts
1237
- * @param {object} opts.arch — arch descriptor
1238
- * @param {boolean} [opts.i18nActive] — i18n 도 같이 켜져 있는지 (GlobalProvider wrapping 순서)
1239
- */
1240
- async function emitSentry(targetDir, { arch, i18nActive = false }) {
1241
- const isFsd = arch.name === 'fsd';
1242
- const obsDirRel = isFsd ? 'src/shared/observability' : 'src/lib/observability';
1243
- const obsAlias = isFsd ? '@/shared/observability' : '@/lib/observability';
1244
- const providersDirRel = isFsd ? 'src/app/providers' : 'src/components/providers';
1245
- const apiAlias = isFsd ? '@/shared/api/queryClient' : '@/lib/api/queryClient';
1246
-
1247
- const obsDir = path.join(targetDir, obsDirRel);
1248
- await fs.ensureDir(obsDir);
1249
-
1250
- const sentryTs = `/// <reference types="vite/client" />
1251
- import * as Sentry from '@sentry/react';
1252
-
1253
- // VITE_SENTRY_DSN 이 있을 때만 init — 로컬 dev 에선 자동 skip.
1254
- // GlitchTip self-hosted 도 같은 SDK — DSN 만 변경.
1255
- if (import.meta.env.VITE_SENTRY_DSN) {
1256
- Sentry.init({
1257
- dsn: import.meta.env.VITE_SENTRY_DSN,
1258
- release: import.meta.env.VITE_APP_VERSION,
1259
- environment: import.meta.env.MODE,
1260
- tracesSampleRate: 0.1,
1261
- replaysOnErrorSampleRate: 1.0,
1262
- replaysSessionSampleRate: 0,
1263
- integrations: [
1264
- Sentry.browserTracingIntegration(),
1265
- Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
1266
- ],
1267
- });
1268
- }
1269
-
1270
- export { Sentry };
1271
- `;
1272
- await fs.writeFile(path.join(obsDir, 'sentry.ts'), sentryTs);
1273
-
1274
- await fs.writeFile(
1275
- path.join(obsDir, 'index.ts'),
1276
- `export { Sentry } from './sentry';\nexport { ErrorBoundary } from '@sentry/react';\n`,
1277
- );
1278
-
1279
- const providersDir = path.join(targetDir, providersDirRel);
1280
- await fs.ensureDir(providersDir);
1281
- const sentryProvider = `import { type ReactNode } from 'react';
1282
- import { ErrorBoundary } from '${obsAlias}';
1283
-
1284
- function Fallback({ error }: { error: unknown }) {
1285
- return (
1286
- <div style={{ padding: '2rem', textAlign: 'center' }}>
1287
- <h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>오류가 발생했습니다</h1>
1288
- <p style={{ marginTop: '0.5rem', color: 'var(--foreground-muted)' }}>
1289
- {error instanceof Error ? error.message : '알 수 없는 오류'}
1290
- </p>
1291
- </div>
1292
- );
1293
- }
1294
-
1295
- export function SentryProvider({ children }: { children: ReactNode }) {
1296
- return (
1297
- <ErrorBoundary fallback={({ error }) => <Fallback error={error} />}>
1298
- {children}
1299
- </ErrorBoundary>
1300
- );
1301
- }
1302
- `;
1303
- await fs.writeFile(path.join(providersDir, 'SentryProvider.tsx'), sentryProvider);
1304
-
1305
- // GlobalProvider rewrite — Sentry outermost, then optional I18n, then Theme + Query.
1306
- const globalProviderPath = path.join(targetDir, providersDirRel, 'GlobalProvider', 'index.tsx');
1307
- const i18nImport = i18nActive ? `import { I18nProvider } from '../I18nProvider';\n` : '';
1308
- const innerOpen = i18nActive ? ' <I18nProvider>\n ' : ' ';
1309
- const innerClose = i18nActive ? '\n </I18nProvider>' : '';
1310
- const globalProvider = `import { QueryClientProvider } from '@tanstack/react-query';
1311
- import { type ReactNode, useState } from 'react';
1312
- import { createQueryClient } from '${apiAlias}';
1313
- import { ThemeProvider } from '../theme/ThemeProvider';
1314
- ${i18nImport}import { SentryProvider } from '../SentryProvider';
1315
-
1316
- export function GlobalProvider({ children }: { children: ReactNode }) {
1317
- const [queryClient] = useState(() => createQueryClient());
1318
- return (
1319
- <SentryProvider>
1320
- ${innerOpen}<ThemeProvider>
1321
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
1322
- </ThemeProvider>${innerClose}
1323
- </SentryProvider>
1324
- );
1325
- }
1326
- `;
1327
- await fs.writeFile(globalProviderPath, globalProvider);
1328
-
1329
- // package.json deps
1330
- const pkgPath = path.join(targetDir, 'package.json');
1331
- const pkg = await fs.readJson(pkgPath);
1332
- pkg.dependencies = pkg.dependencies ?? {};
1333
- pkg.devDependencies = pkg.devDependencies ?? {};
1334
- pkg.dependencies['@sentry/react'] = '^8.45.0';
1335
- pkg.devDependencies['@sentry/vite-plugin'] = '^2.22.7';
1336
- pkg.dependencies = sortObjectKeys(pkg.dependencies);
1337
- pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
1338
- await fs.writeJson(pkgPath, pkg, { spaces: 2 });
1339
-
1340
- await patchViteConfigForSentry(targetDir);
1341
-
1342
- // .env.example
1343
- const envExamplePath = path.join(targetDir, '.env.example');
1344
- const envBlock = `# Sentry observability (v0.93.0+) — 비워두면 init 자동 skip.
1345
- VITE_SENTRY_DSN=
1346
- VITE_APP_VERSION=
1347
- # Source map upload (vite build 시).
1348
- SENTRY_AUTH_TOKEN=
1349
- SENTRY_ORG=
1350
- SENTRY_PROJECT=
1351
- `;
1352
- if (await fs.pathExists(envExamplePath)) {
1353
- const existing = await fs.readFile(envExamplePath, 'utf-8');
1354
- if (!existing.includes('VITE_SENTRY_DSN')) {
1355
- await fs.writeFile(envExamplePath, existing + '\n' + envBlock);
1356
- }
1357
- } else {
1358
- await fs.writeFile(envExamplePath, envBlock);
1359
- }
1360
- }
1361
-
1362
- async function patchViteConfigForSentry(targetDir) {
1363
- const viteCfgPath = path.join(targetDir, 'vite.config.ts');
1364
- if (!(await fs.pathExists(viteCfgPath))) {
1365
- throw new Error(`vite.config.ts 가 ${targetDir} 에 없습니다.`);
1366
- }
1367
- let cfg = await fs.readFile(viteCfgPath, 'utf-8');
1368
-
1369
- if (!cfg.includes("@sentry/vite-plugin")) {
1370
- cfg = cfg.replace(
1371
- /import { defineConfig } from 'vite';/,
1372
- `import { defineConfig } from 'vite';\nimport { sentryVitePlugin } from '@sentry/vite-plugin';`,
1373
- );
1374
- }
1375
-
1376
- if (!cfg.includes("sentryVitePlugin(")) {
1377
- // i18n 의 viteStaticCopy 처럼 plugins 배열 안에 nested `[]` 가 있을 수 있으므로
1378
- // balanced-bracket helper 로 outer entry append.
1379
- const sentryCall = `sentryVitePlugin({
1380
- org: process.env.SENTRY_ORG,
1381
- project: process.env.SENTRY_PROJECT,
1382
- authToken: process.env.SENTRY_AUTH_TOKEN,
1383
- disable: !process.env.SENTRY_AUTH_TOKEN,
1384
- })`;
1385
- const patched = appendVitePluginEntry(cfg, sentryCall);
1386
- if (patched) cfg = patched;
1387
- }
1388
-
1389
- if (!cfg.includes("sourcemap:")) {
1390
- if (cfg.includes("server: {")) {
1391
- cfg = cfg.replace(/(\n\s*server:\s*\{)/, `\n build: { sourcemap: true },$1`);
1392
- }
1393
- // server 가 없는 변종은 build 삽입 위치가 모호 → 보수적으로 skip (사용자가 수동 추가).
1394
- }
1395
-
1396
- await fs.writeFile(viteCfgPath, cfg);
1397
- }
1398
-
1399
- async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', 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 } = {}) {
1400
1206
  await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
1401
1207
 
1402
1208
  // Update root package.json
@@ -1424,10 +1230,6 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
1424
1230
  turbo.tasks.build.outputs = ['dist/**'];
1425
1231
  // vite 는 클라이언트 노출 env 가 VITE_ 접두사 관례.
1426
1232
  turbo.globalEnv = turbo.globalEnv.map((e) => (e === 'API_URL' ? 'VITE_API_URL' : e));
1427
- // sentry observability 는 플러그인 turboEnvVars 훅을 안 타므로 직접 선언.
1428
- if (observability === 'sentry') {
1429
- turbo.globalEnv.push('MODE', 'SENTRY_ORG', 'SENTRY_PROJECT', 'SENTRY_AUTH_TOKEN');
1430
- }
1431
1233
  }
1432
1234
  turbo.globalEnv = [...new Set(turbo.globalEnv)];
1433
1235
  await fs.writeJson(turboPath, turbo, { spaces: 2 });
@@ -1448,7 +1250,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
1448
1250
 
1449
1251
  const appsDir = path.join(targetDir, 'apps', appName);
1450
1252
  if (platform === 'vite') {
1451
- await generateViteApp(appsDir, appName, port, arch, css, { i18n, locales, observability });
1253
+ await generateViteApp(appsDir, appName, port, arch, css, { i18n, locales });
1452
1254
  } else {
1453
1255
  await generateApp(appsDir, appName, port, plugins, arch, css);
1454
1256
  }
@@ -1549,7 +1351,7 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
1549
1351
  }
1550
1352
  }
1551
1353
 
1552
- async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
1354
+ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { i18n = 'none', locales = 'ko,en' } = {}) {
1553
1355
  // 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
1554
1356
  await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
1555
1357
  filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
@@ -1613,10 +1415,6 @@ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind',
1613
1415
  const localesArr = parseLocales(locales);
1614
1416
  await emitI18n(targetDir, { arch, locales: localesArr });
1615
1417
  }
1616
-
1617
- if (observability === 'sentry') {
1618
- await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
1619
- }
1620
1418
  }
1621
1419
 
1622
1420
  /**
@@ -1812,30 +1610,10 @@ export default function Home() {
1812
1610
  }
1813
1611
  }
1814
1612
 
1815
- // 6) sentry plugin 의 error.tsx 변환 Tailwind 클래스가 박혀있어서 plain/cssmodules 에서 작동 안 함.
1816
- // intl + sentry 면 nextIntl 이 [locale]/error.tsx 를 i18n-aware 로 replace 하므로 그것도 변환.
1817
- const sentryActive = plugins?.some((p) => p.name === 'sentry');
1818
- if (sentryActive) {
1819
- const errorCandidates = intlActive
1820
- ? [path.join(targetDir, 'app/[locale]/error.tsx')]
1821
- : [path.join(targetDir, 'app/error.tsx')];
1822
- for (const errPath of errorCandidates) {
1823
- if (!(await fs.pathExists(errPath))) continue;
1824
- const useI18n = intlActive;
1825
- await fs.writeFile(errPath, buildErrorTsxNonTailwind({ useI18n, cssFramework, arch }));
1826
- if (cssFramework === 'css-modules') {
1827
- await fs.writeFile(
1828
- path.join(path.dirname(errPath), 'error.module.css'),
1829
- buildErrorModuleCss(),
1830
- );
1831
- }
1832
- }
1833
- }
1834
-
1835
- // 7) .prettierrc — tailwind plugin 제거.
1613
+ // 6) .prettierrctailwind plugin 제거.
1836
1614
  await stripTailwindFromPrettier(path.join(targetDir, '.prettierrc'));
1837
1615
 
1838
- // 8) monorepo 인 경우 root .prettierrc 와 root package.json 도 정리 (root 의 prettier-plugin-tailwindcss).
1616
+ // 7) monorepo 인 경우 root .prettierrc 와 root package.json 도 정리 (root 의 prettier-plugin-tailwindcss).
1839
1617
  // applyCssFrameworkVariant 는 apps/web 마다 호출되지만 root 정리는 1회면 충분 — idempotent 라 OK.
1840
1618
  if (isMonorepo && !isUiPackage) {
1841
1619
  const monorepoRoot = path.resolve(targetDir, '..', '..');
@@ -1865,365 +1643,6 @@ async function stripTailwindFromPrettier(prettierPath) {
1865
1643
  await fs.writeJson(prettierPath, c, { spaces: 2 });
1866
1644
  }
1867
1645
 
1868
- /**
1869
- * sentry 의 error.tsx 를 plain/cssmodules 로 변환한 콘텐츠 생성.
1870
- * useI18n=true 면 next-intl 의 useTranslations + Link 사용.
1871
- */
1872
- function buildErrorTsxNonTailwind({ useI18n, cssFramework, arch }) {
1873
- const i18nImports = useI18n
1874
- ? `import { useTranslations } from 'next-intl';\n`
1875
- : '';
1876
- const configAlias = arch ? arch.aliases.config : '@/src/shared/config';
1877
- const linkImport = useI18n
1878
- ? `import { Link } from '${configAlias}/i18n/navigation';\n`
1879
- : `import Link from 'next/link';\n`;
1880
- const tHook = useI18n ? ` const t = useTranslations('error');\n` : '';
1881
- const titleText = useI18n ? `{t('title')}` : `오류가 발생했습니다`;
1882
- const descText = useI18n
1883
- ? `{t('description')}`
1884
- : `예상치 못한 오류가 발생했습니다. 다시 시도해주세요.`;
1885
- const fallback = useI18n ? `t('unexpectedError')` : `'알 수 없는 오류'`;
1886
- const tryAgain = useI18n ? `{t('button.tryAgain')}` : `다시 시도`;
1887
- const goHome = useI18n ? `{t('button.goHome')}` : `홈으로 이동`;
1888
-
1889
- if (cssFramework === 'css-modules') {
1890
- return `'use client';
1891
-
1892
- import * as Sentry from '@sentry/nextjs';
1893
- import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
1894
- ${i18nImports}${linkImport}import { useEffect } from 'react';
1895
-
1896
- import styles from './error.module.css';
1897
-
1898
- export default function Error({
1899
- error,
1900
- reset,
1901
- }: {
1902
- error: Error & { digest?: string };
1903
- reset: () => void;
1904
- }) {
1905
- ${tHook} useEffect(() => {
1906
- Sentry.captureException(error);
1907
- }, [error]);
1908
-
1909
- return (
1910
- <div className={styles.wrapper}>
1911
- <div className={styles.card}>
1912
- <div className={styles.iconRow}>
1913
- <div className={styles.iconCircle}>
1914
- <AlertTriangle className={styles.icon} />
1915
- </div>
1916
- </div>
1917
-
1918
- <h2 className={styles.title}>${titleText}</h2>
1919
- <p className={styles.description}>${descText}</p>
1920
-
1921
- <div className={styles.errorBox}>
1922
- <p className={styles.errorText}>{error.message || ${fallback}}</p>
1923
- </div>
1924
-
1925
- <div className={styles.actions}>
1926
- <button onClick={reset} className={styles.primaryButton}>
1927
- <RefreshCw className={styles.buttonIcon} />
1928
- ${tryAgain}
1929
- </button>
1930
-
1931
- <Link href='/' className={styles.secondaryButton}>
1932
- <Home className={styles.buttonIcon} />
1933
- ${goHome}
1934
- </Link>
1935
- </div>
1936
-
1937
- {process.env.NODE_ENV === 'development' && error.digest && (
1938
- <div className={styles.digest}>
1939
- <p className={styles.digestText}>Error ID: {error.digest}</p>
1940
- </div>
1941
- )}
1942
- </div>
1943
- </div>
1944
- );
1945
- }
1946
- `;
1947
- }
1948
-
1949
- // plain — inline style (토큰 var 활용)
1950
- return `'use client';
1951
-
1952
- import * as Sentry from '@sentry/nextjs';
1953
- import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
1954
- ${i18nImports}${linkImport}import { useEffect } from 'react';
1955
-
1956
- const wrapper: React.CSSProperties = {
1957
- display: 'flex',
1958
- minHeight: '100vh',
1959
- alignItems: 'center',
1960
- justifyContent: 'center',
1961
- padding: '0 16px',
1962
- };
1963
- const card: React.CSSProperties = {
1964
- width: '100%',
1965
- maxWidth: 448,
1966
- borderRadius: 8,
1967
- border: '1px solid var(--border)',
1968
- background: 'var(--background)',
1969
- padding: 24,
1970
- boxShadow: 'var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.15))',
1971
- };
1972
-
1973
- export default function Error({
1974
- error,
1975
- reset,
1976
- }: {
1977
- error: Error & { digest?: string };
1978
- reset: () => void;
1979
- }) {
1980
- ${tHook} useEffect(() => {
1981
- Sentry.captureException(error);
1982
- }, [error]);
1983
-
1984
- return (
1985
- <div style={wrapper}>
1986
- <div style={card}>
1987
- <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
1988
- <div
1989
- style={{
1990
- width: 64,
1991
- height: 64,
1992
- borderRadius: '50%',
1993
- background: 'color-mix(in srgb, var(--danger) 10%, transparent)',
1994
- display: 'flex',
1995
- alignItems: 'center',
1996
- justifyContent: 'center',
1997
- }}
1998
- >
1999
- <AlertTriangle style={{ width: 32, height: 32, color: 'var(--danger)' }} />
2000
- </div>
2001
- </div>
2002
-
2003
- <h2
2004
- style={{
2005
- fontSize: 24,
2006
- fontWeight: 700,
2007
- textAlign: 'center',
2008
- color: 'var(--foreground)',
2009
- margin: '0 0 8px',
2010
- }}
2011
- >
2012
- ${titleText}
2013
- </h2>
2014
- <p
2015
- style={{
2016
- fontSize: 14,
2017
- color: 'var(--foreground-muted)',
2018
- textAlign: 'center',
2019
- margin: '0 0 24px',
2020
- }}
2021
- >
2022
- ${descText}
2023
- </p>
2024
-
2025
- <div
2026
- style={{
2027
- borderRadius: 6,
2028
- border: '1px solid color-mix(in srgb, var(--danger) 30%, transparent)',
2029
- background: 'color-mix(in srgb, var(--danger) 5%, transparent)',
2030
- padding: 12,
2031
- }}
2032
- >
2033
- <p style={{ fontSize: 14, color: 'var(--danger)', margin: 0 }}>
2034
- {error.message || ${fallback}}
2035
- </p>
2036
- </div>
2037
-
2038
- <div style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
2039
- <button
2040
- onClick={reset}
2041
- style={{
2042
- width: '100%',
2043
- display: 'flex',
2044
- alignItems: 'center',
2045
- justifyContent: 'center',
2046
- gap: 8,
2047
- padding: '8px 16px',
2048
- borderRadius: 6,
2049
- border: 'none',
2050
- background: 'var(--primary)',
2051
- color: 'var(--primary-foreground)',
2052
- fontSize: 14,
2053
- fontWeight: 500,
2054
- cursor: 'pointer',
2055
- }}
2056
- >
2057
- <RefreshCw style={{ width: 16, height: 16 }} />
2058
- ${tryAgain}
2059
- </button>
2060
-
2061
- <Link
2062
- href='/'
2063
- style={{
2064
- width: '100%',
2065
- display: 'flex',
2066
- alignItems: 'center',
2067
- justifyContent: 'center',
2068
- gap: 8,
2069
- padding: '8px 16px',
2070
- borderRadius: 6,
2071
- border: '1px solid var(--border)',
2072
- color: 'var(--foreground)',
2073
- fontSize: 14,
2074
- fontWeight: 500,
2075
- textDecoration: 'none',
2076
- }}
2077
- >
2078
- <Home style={{ width: 16, height: 16 }} />
2079
- ${goHome}
2080
- </Link>
2081
- </div>
2082
-
2083
- {process.env.NODE_ENV === 'development' && error.digest && (
2084
- <div
2085
- style={{
2086
- marginTop: 16,
2087
- borderRadius: 6,
2088
- background: 'var(--background-subtle)',
2089
- padding: 12,
2090
- }}
2091
- >
2092
- <p style={{ fontSize: 12, color: 'var(--foreground-subtle)', margin: 0 }}>
2093
- Error ID: {error.digest}
2094
- </p>
2095
- </div>
2096
- )}
2097
- </div>
2098
- </div>
2099
- );
2100
- }
2101
- `;
2102
- }
2103
-
2104
- function buildErrorModuleCss() {
2105
- return `.wrapper {
2106
- display: flex;
2107
- min-height: 100vh;
2108
- align-items: center;
2109
- justify-content: center;
2110
- padding: 0 16px;
2111
- }
2112
-
2113
- .card {
2114
- width: 100%;
2115
- max-width: 448px;
2116
- border-radius: 8px;
2117
- border: 1px solid var(--border);
2118
- background: var(--background);
2119
- padding: 24px;
2120
- box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
2121
- }
2122
-
2123
- .iconRow {
2124
- display: flex;
2125
- justify-content: center;
2126
- margin-bottom: 16px;
2127
- }
2128
-
2129
- .iconCircle {
2130
- width: 64px;
2131
- height: 64px;
2132
- border-radius: 50%;
2133
- background: color-mix(in srgb, var(--danger) 10%, transparent);
2134
- display: flex;
2135
- align-items: center;
2136
- justify-content: center;
2137
- }
2138
-
2139
- .icon {
2140
- width: 32px;
2141
- height: 32px;
2142
- color: var(--danger);
2143
- }
2144
-
2145
- .title {
2146
- font-size: 24px;
2147
- font-weight: 700;
2148
- text-align: center;
2149
- color: var(--foreground);
2150
- margin: 0 0 8px;
2151
- }
2152
-
2153
- .description {
2154
- font-size: 14px;
2155
- color: var(--foreground-muted);
2156
- text-align: center;
2157
- margin: 0 0 24px;
2158
- }
2159
-
2160
- .errorBox {
2161
- border-radius: 6px;
2162
- border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
2163
- background: color-mix(in srgb, var(--danger) 5%, transparent);
2164
- padding: 12px;
2165
- }
2166
-
2167
- .errorText {
2168
- font-size: 14px;
2169
- color: var(--danger);
2170
- margin: 0;
2171
- }
2172
-
2173
- .actions {
2174
- margin-top: 24px;
2175
- display: flex;
2176
- flex-direction: column;
2177
- gap: 12px;
2178
- }
2179
-
2180
- .primaryButton,
2181
- .secondaryButton {
2182
- width: 100%;
2183
- display: flex;
2184
- align-items: center;
2185
- justify-content: center;
2186
- gap: 8px;
2187
- padding: 8px 16px;
2188
- border-radius: 6px;
2189
- font-size: 14px;
2190
- font-weight: 500;
2191
- cursor: pointer;
2192
- text-decoration: none;
2193
- }
2194
-
2195
- .primaryButton {
2196
- border: none;
2197
- background: var(--primary);
2198
- color: var(--primary-foreground);
2199
- }
2200
-
2201
- .secondaryButton {
2202
- border: 1px solid var(--border);
2203
- color: var(--foreground);
2204
- background: transparent;
2205
- }
2206
-
2207
- .buttonIcon {
2208
- width: 16px;
2209
- height: 16px;
2210
- }
2211
-
2212
- .digest {
2213
- margin-top: 16px;
2214
- border-radius: 6px;
2215
- background: var(--background-subtle);
2216
- padding: 12px;
2217
- }
2218
-
2219
- .digestText {
2220
- font-size: 12px;
2221
- color: var(--foreground-subtle);
2222
- margin: 0;
2223
- }
2224
- `;
2225
- }
2226
-
2227
1646
  /**
2228
1647
  * 스캐폴드 마무리 — `gitignore` 파일을 `.gitignore` 로 되돌리고 `git init` 실행.
2229
1648
  *
@@ -2380,81 +1799,6 @@ async function writePluginFiles(targetDir, plugins, arch) {
2380
1799
  }
2381
1800
  }
2382
1801
  }
2383
-
2384
- // auth-jwt + next-intl 동시 활성화 시 proxy.ts 병합
2385
- // (각 플러그인이 단독으로 깐 proxy.ts 를 합친 버전으로 덮어쓴다)
2386
- // i18n routing import 는 arch.aliases.config 기준 — FSD 면 @/src/shared/config,
2387
- // flat 이면 @/lib/config 로 해석.
2388
- const names = new Set(plugins.map((p) => p.name));
2389
- if (names.has('auth-jwt') && names.has('next-intl')) {
2390
- const configAlias = arch ? arch.aliases.config : '@/src/shared/config';
2391
- const mergedProxy = `import createIntlMiddleware from 'next-intl/middleware';
2392
- import { NextRequest, NextResponse } from 'next/server';
2393
-
2394
- import { routing } from '${configAlias}/i18n/routing';
2395
-
2396
- const AUTH_ROUTES = ['/sign-in', '/sign-up'];
2397
-
2398
- /**
2399
- * 홈(\`/\`, \`/{locale}\`) 진입 시 redirect 할 path. 빈 문자열이면
2400
- * \`app/[locale]/page.tsx\` 가 그대로 노출. 예: '/dashboard', '/projects'.
2401
- * 인증 가드 위에서 동작하므로 미인증이면 그대로 \`/sign-in\` 으로 빠진다.
2402
- */
2403
- const HOME_REDIRECT = '';
2404
-
2405
- const intl = createIntlMiddleware(routing);
2406
-
2407
- /**
2408
- * 로케일 prefix (/ko, /en) 를 벗겨 인증 라우트 매칭에 사용한다.
2409
- * 예: /ko/sign-in → /sign-in
2410
- */
2411
- const stripLocalePrefix = (pathname: string): string => {
2412
- const locales = routing.locales as readonly string[];
2413
- const segments = pathname.split('/').filter(Boolean);
2414
- if (segments[0] && locales.includes(segments[0])) {
2415
- const rest = segments.slice(1).join('/');
2416
- return \`/\${rest}\`.replace(/\\/$/, '') || '/';
2417
- }
2418
- return pathname;
2419
- };
2420
-
2421
- /**
2422
- * Next 16+ proxy.ts (구 middleware.ts).
2423
- * next-intl 라우팅 + auth-jwt 토큰 존재 체크 합성 버전.
2424
- *
2425
- * - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트 (인증 가드보다 먼저)
2426
- * - intl 이 로케일 prefix 처리 + NEXT_LOCALE 쿠키 set
2427
- * - 그 위에 인증 가드 — 토큰 없고 인증 라우트도 아니면 /sign-in 으로 redirect
2428
- * - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
2429
- * - AT 만료 검사나 refresh 는 하지 않는다 (BFF 가 처리)
2430
- */
2431
- const DEV_BYPASS =
2432
- process.env.NODE_ENV !== 'production' &&
2433
- process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
2434
-
2435
- export default function proxy(req: NextRequest) {
2436
- const intlRes = intl(req);
2437
- const pathname = stripLocalePrefix(req.nextUrl.pathname);
2438
- const hasToken = !!req.cookies.get('accessToken')?.value;
2439
- const isAuthRoute = AUTH_ROUTES.some((r) => pathname.startsWith(r));
2440
-
2441
- if (pathname === '/' && HOME_REDIRECT) {
2442
- return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
2443
- }
2444
-
2445
- if (DEV_BYPASS) return intlRes;
2446
- if (isAuthRoute) return intlRes;
2447
- if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
2448
-
2449
- return intlRes;
2450
- }
2451
-
2452
- export const config = {
2453
- matcher: '/((?!api|_next|_vercel|monitoring|.*\\\\..*).*)',
2454
- };
2455
- `;
2456
- await fs.writeFile(path.join(targetDir, 'proxy.ts'), mergedProxy);
2457
- }
2458
1802
  }
2459
1803
 
2460
1804
  async function composeProviders(targetDir, plugins, arch) {