sh-ui-cli 0.32.0 → 0.33.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 +28 -0
- package/package.json +7 -1
- package/src/api.d.ts +42 -0
- package/src/api.js +21 -0
- package/src/constants.js +31 -0
- package/src/create/cli-args.js +13 -6
- package/src/create/generator.js +94 -3
- package/src/create/index.mjs +11 -4
- package/src/create/plugins/authJwt.js +3 -1
- package/src/create/plugins/index.js +5 -0
- package/src/create/plugins/nextIntl.js +2 -0
- package/src/create/plugins/pluginSchema.js +81 -0
- package/src/create/plugins/sentry.js +2 -0
- package/src/init.mjs +13 -10
- package/src/mcp.mjs +18 -8
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npx sh-ui-cli create
|
|
|
22
22
|
|
|
23
23
|
# 비대화형 (에이전트 / CI)
|
|
24
24
|
npx sh-ui-cli create my-app --platform next --structure standalone --yes
|
|
25
|
-
npx sh-ui-cli create my-app --platform next --structure monorepo --plugins sentry,next-intl --yes
|
|
25
|
+
npx sh-ui-cli create my-app --platform next --structure monorepo --plugins sentry,next-intl,auth-jwt --yes
|
|
26
26
|
npx sh-ui-cli create my-app --platform flutter --yes
|
|
27
27
|
```
|
|
28
28
|
|
|
@@ -2,6 +2,34 @@
|
|
|
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.33.0",
|
|
7
|
+
"date": "2026-04-29",
|
|
8
|
+
"title": "중앙관리형 SSOT — sh-ui-cli/api 노출 + 플러그인 스키마 + --dry-run + CI 스캐폴드 게이트",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"신규 public API — `import { allPlugins, CREATE_PLATFORMS, ... } from 'sh-ui-cli/api'`. 다른 워크스페이스 (apps/docs 등) 가 플러그인 메타와 enum 을 동기화 없이 사용. CreateProjectDialog / 사이드바 / 플러그인 허브 가 모두 derive 하도록 변경 — 새 플러그인 추가 시 plugins/ 폴더 한 군데만 건드리면 됨",
|
|
12
|
+
"신규 --dry-run 플래그 — 실제 파일 쓰지 않고 스캐폴드 결과 파일 목록만 출력. 디버깅 + CI 검증 보조. tmpdir 에 생성 후 즉시 정리해 사용자 cwd 무영향",
|
|
13
|
+
"플러그인 manifest zod 스키마 검증 — 모듈 로드 시점에 모든 플러그인 형태 체크. 'src/proxy.ts' 같은 잘못된 경로는 빌드 타임 거부 (v0.32.0 에서 발생한 회귀 차단)",
|
|
14
|
+
"CI 스캐폴드 게이트 — publish 전에 5가지 플러그인 조합으로 실제 스캐폴드 → fresh install → tsc --noEmit 까지 돌리는 스모크 단계 추가. 사용자가 받게 될 결과물을 publish 직전에 검증",
|
|
15
|
+
"통합 테스트 7개 추가 — 각 플러그인 + 조합의 핵심 파일 위치 / 내용 / 합성 검증 (proxy.ts root 위치, 합성된 proxy.ts 의 intl + 인증 가드, observability 의 Sentry vs no-op, axios 부재 등)",
|
|
16
|
+
"내부 정리 — packages/cli/src/constants.js 신설로 PLATFORMS/STRUCTURES/THEME_* 단일화. cli-args / mcp / init / generator 도움말 모두 derive"
|
|
17
|
+
],
|
|
18
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.33.0"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.32.1",
|
|
22
|
+
"date": "2026-04-29",
|
|
23
|
+
"title": "auth-jwt 의 proxy.ts 경로 버그 수정 + next-intl 와 자동 합성",
|
|
24
|
+
"type": "patch",
|
|
25
|
+
"highlights": [
|
|
26
|
+
"auth-jwt 플러그인이 proxy.ts 를 src/proxy.ts 로 잘못 깔아 Next 16 이 인식 못 하던 문제 — 베이스 템플릿이 app/ 을 root 에 두므로 proxy.ts 도 root 로 가야 함. 결과적으로 v0.32.0 의 인증 라우트 가드가 동작하지 않았음, 이번 패치에서 해결",
|
|
27
|
+
"auth-jwt + next-intl 동시 활성화 시 generator 가 proxy.ts 를 자동 합성 — intl 미들웨어 + locale prefix 를 벗긴 경로 기반 인증 가드. 단독 사용 시에는 각 플러그인의 단일 proxy.ts 그대로 적용",
|
|
28
|
+
"/cli 페이지 플러그인 섹션 갱신 — Sentry 의 옛 axios 인터셉터/apiCore 설명 제거, observability 브릿지 정확화, auth-jwt 항목 신설. 기본 스택의 'Axios' 표기를 fetch 기반 isomorphic http() 로 정정",
|
|
29
|
+
"auth-jwt / next-intl 플러그인 페이지의 폴더 트리 + 파일 설명을 실제 manifest 와 동기화"
|
|
30
|
+
],
|
|
31
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.32.1"
|
|
32
|
+
},
|
|
5
33
|
{
|
|
6
34
|
"version": "0.32.0",
|
|
7
35
|
"date": "2026-04-29",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sh-ui-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
"bin": {
|
|
41
41
|
"sh-ui": "./bin/sh-ui.mjs"
|
|
42
42
|
},
|
|
43
|
+
"exports": {
|
|
44
|
+
"./api": {
|
|
45
|
+
"types": "./src/api.d.ts",
|
|
46
|
+
"default": "./src/api.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
43
49
|
"scripts": {
|
|
44
50
|
"bundle-data": "node scripts/copy-data.mjs",
|
|
45
51
|
"test": "vitest run",
|
package/src/api.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-ui-cli 외부 노출 API 의 타입 선언.
|
|
3
|
+
* apps/docs 등 TypeScript 사용자가 자동완성과 타입 안전을 받을 수 있게.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type CreatePlatform = 'next' | 'flutter';
|
|
7
|
+
export type CreateStructure = 'standalone' | 'monorepo';
|
|
8
|
+
export type InitPlatform = 'react' | 'flutter';
|
|
9
|
+
export type ThemeBase = 'neutral' | 'zinc' | 'slate';
|
|
10
|
+
export type ThemeRadius = 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
11
|
+
export type ThemeMode = 'light-dark' | 'light' | 'dark';
|
|
12
|
+
|
|
13
|
+
export const CREATE_PLATFORMS: readonly CreatePlatform[];
|
|
14
|
+
export const CREATE_STRUCTURES: readonly CreateStructure[];
|
|
15
|
+
export const INIT_PLATFORMS: readonly InitPlatform[];
|
|
16
|
+
export const THEME_BASES: readonly ThemeBase[];
|
|
17
|
+
export const THEME_RADII: readonly ThemeRadius[];
|
|
18
|
+
export const THEME_MODES: readonly ThemeMode[];
|
|
19
|
+
|
|
20
|
+
export const INIT_DEFAULTS: {
|
|
21
|
+
platform: InitPlatform;
|
|
22
|
+
base: ThemeBase;
|
|
23
|
+
radius: ThemeRadius;
|
|
24
|
+
mode: ThemeMode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PluginManifest = {
|
|
28
|
+
name: string;
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
priority: number;
|
|
32
|
+
dependencies?: Record<string, string>;
|
|
33
|
+
devDependencies?: Record<string, string>;
|
|
34
|
+
envVars?: string[];
|
|
35
|
+
turboEnvVars?: string[];
|
|
36
|
+
imports?: string[];
|
|
37
|
+
providerImports?: string[];
|
|
38
|
+
providerWrappers?: Array<{ open: string; close: string } | string>;
|
|
39
|
+
files?: Record<string, string>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const allPlugins: readonly PluginManifest[];
|
package/src/api.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-ui-cli 외부 노출 API.
|
|
3
|
+
*
|
|
4
|
+
* apps/docs 같은 다른 워크스페이스 패키지가 플러그인 메타데이터와 enum 을
|
|
5
|
+
* 동기화 없이 사용하도록. package.json 의 "exports": { "./api": "./src/api.js" }
|
|
6
|
+
* 로 노출되며, 사용자는 다음과 같이 import:
|
|
7
|
+
*
|
|
8
|
+
* import { allPlugins, CREATE_PLATFORMS } from 'sh-ui-cli/api';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
CREATE_PLATFORMS,
|
|
13
|
+
CREATE_STRUCTURES,
|
|
14
|
+
INIT_PLATFORMS,
|
|
15
|
+
THEME_BASES,
|
|
16
|
+
THEME_RADII,
|
|
17
|
+
THEME_MODES,
|
|
18
|
+
INIT_DEFAULTS,
|
|
19
|
+
} from './constants.js';
|
|
20
|
+
|
|
21
|
+
export { allPlugins } from './create/plugins/index.js';
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-ui-cli 의 모든 enum / 상수 단일 진실.
|
|
3
|
+
*
|
|
4
|
+
* 다른 모듈 (cli-args, mcp, init, generator) 과 외부 패키지 (apps/docs) 는
|
|
5
|
+
* 이 파일을 import 해서 사용한다. 새 값 추가 시 여기만 고치면 된다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── 프로젝트 생성 (sh-ui-cli create) ───
|
|
9
|
+
|
|
10
|
+
export const CREATE_PLATFORMS = ['next', 'flutter'];
|
|
11
|
+
|
|
12
|
+
export const CREATE_STRUCTURES = ['standalone', 'monorepo'];
|
|
13
|
+
|
|
14
|
+
// ─── 초기화 (sh-ui-cli init) — sh-ui 컴포넌트 설정 ───
|
|
15
|
+
|
|
16
|
+
export const INIT_PLATFORMS = ['react', 'flutter'];
|
|
17
|
+
|
|
18
|
+
export const THEME_BASES = ['neutral', 'zinc', 'slate'];
|
|
19
|
+
|
|
20
|
+
export const THEME_RADII = ['none', 'sm', 'md', 'lg', 'xl', 'full'];
|
|
21
|
+
|
|
22
|
+
export const THEME_MODES = ['light-dark', 'light', 'dark'];
|
|
23
|
+
|
|
24
|
+
// ─── 기본값 ───
|
|
25
|
+
|
|
26
|
+
export const INIT_DEFAULTS = {
|
|
27
|
+
platform: 'react',
|
|
28
|
+
base: 'neutral',
|
|
29
|
+
radius: 'md',
|
|
30
|
+
mode: 'light-dark',
|
|
31
|
+
};
|
package/src/create/cli-args.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { CREATE_PLATFORMS, CREATE_STRUCTURES } from '../constants.js';
|
|
2
|
+
import { allPlugins } from './plugins/index.js';
|
|
3
|
+
|
|
4
|
+
const VALID_PLATFORMS = CREATE_PLATFORMS;
|
|
5
|
+
const VALID_STRUCTURES = CREATE_STRUCTURES;
|
|
6
|
+
const VALID_PLUGINS = allPlugins.map((p) => p.name);
|
|
4
7
|
|
|
5
8
|
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app'];
|
|
6
|
-
const BOOL_FLAGS = ['yes', 'help'];
|
|
9
|
+
const BOOL_FLAGS = ['yes', 'help', 'dry-run'];
|
|
7
10
|
|
|
8
11
|
const SUBCOMMANDS = ['add-app', 'add-component'];
|
|
9
12
|
|
|
@@ -29,7 +32,9 @@ export const parseArgs = (argv) => {
|
|
|
29
32
|
}
|
|
30
33
|
const name = arg.slice(2);
|
|
31
34
|
if (BOOL_FLAGS.includes(name)) {
|
|
32
|
-
|
|
35
|
+
// dry-run 은 dryRun 으로 캐멀 케이스
|
|
36
|
+
const key = name === 'dry-run' ? 'dryRun' : name;
|
|
37
|
+
flags[key] = true;
|
|
33
38
|
continue;
|
|
34
39
|
}
|
|
35
40
|
if (!VALUE_FLAGS.includes(name)) {
|
|
@@ -44,7 +49,9 @@ export const parseArgs = (argv) => {
|
|
|
44
49
|
const list = value === '' ? [] : value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
45
50
|
for (const p of list) {
|
|
46
51
|
if (!VALID_PLUGINS.includes(p)) {
|
|
47
|
-
throw new Error(
|
|
52
|
+
throw new Error(
|
|
53
|
+
`알 수 없는 플러그인: ${p} (지원: ${VALID_PLUGINS.join(', ')})`,
|
|
54
|
+
);
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
flags.plugins = list;
|
package/src/create/generator.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { input, select, checkbox, confirm } from '@inquirer/prompts';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { getPluginChoices, getPluginsByNames } from './plugins/index.js';
|
|
6
7
|
import { decodeTheme } from './theme/decode.js';
|
|
@@ -53,9 +54,13 @@ export async function createProject(options = {}) {
|
|
|
53
54
|
|
|
54
55
|
const theme = options.theme ? decodeTheme(options.theme) : null;
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
// dry-run 은 tmpdir 에 그대로 생성한 뒤 파일 목록 출력 + 정리.
|
|
58
|
+
// 사용자 cwd 를 건드리지 않으면서 실제 generation 흐름을 그대로 검증한다.
|
|
59
|
+
const targetDir = options.dryRun
|
|
60
|
+
? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-dry-'))
|
|
61
|
+
: path.resolve(process.cwd(), projectName);
|
|
57
62
|
|
|
58
|
-
if (await fs.pathExists(targetDir)) {
|
|
63
|
+
if (!options.dryRun && await fs.pathExists(targetDir)) {
|
|
59
64
|
if (options.yes) {
|
|
60
65
|
await fs.remove(targetDir);
|
|
61
66
|
} else {
|
|
@@ -90,7 +95,7 @@ export async function createProject(options = {}) {
|
|
|
90
95
|
});
|
|
91
96
|
|
|
92
97
|
// plugins 는 미지정시 기본 빈 배열 — prompt 띄우지 않는다.
|
|
93
|
-
// (플러그인을 쓰려면 명시적으로 --plugins sentry,next-intl 사용)
|
|
98
|
+
// (플러그인을 쓰려면 명시적으로 --plugins sentry,next-intl,auth-jwt 등 사용)
|
|
94
99
|
const selectedPluginNames = options.plugins ?? [];
|
|
95
100
|
|
|
96
101
|
const plugins = getPluginsByNames(selectedPluginNames);
|
|
@@ -102,12 +107,44 @@ export async function createProject(options = {}) {
|
|
|
102
107
|
await generateMonorepo(targetDir, projectName, plugins, { yes: options.yes, theme });
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
if (options.dryRun) {
|
|
111
|
+
const files = await listAllFiles(targetDir);
|
|
112
|
+
console.log(`\n[DRY RUN] ${projectName} 스캐폴드 시 작성될 파일 (${files.length}개):\n`);
|
|
113
|
+
for (const f of files.sort()) {
|
|
114
|
+
console.log(` ${f}`);
|
|
115
|
+
}
|
|
116
|
+
await fs.remove(targetDir);
|
|
117
|
+
console.log(`\n실제 스캐폴드: --dry-run 제거 후 같은 명령 실행.`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
105
121
|
console.log(`\n✅ ${projectName} 프로젝트가 생성되었습니다!`);
|
|
106
122
|
console.log(`\n cd ${projectName}`);
|
|
107
123
|
console.log(' pnpm install');
|
|
108
124
|
console.log(' pnpm dev\n');
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
/**
|
|
128
|
+
* targetDir 아래 모든 파일을 상대 경로로 나열. node_modules 등은 자동 스킵
|
|
129
|
+
* (스캐폴드 직후라 install 안 한 상태기 때문).
|
|
130
|
+
*/
|
|
131
|
+
async function listAllFiles(targetDir) {
|
|
132
|
+
const collected = [];
|
|
133
|
+
async function walk(dir) {
|
|
134
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const full = path.join(dir, entry.name);
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
await walk(full);
|
|
139
|
+
} else if (entry.isFile()) {
|
|
140
|
+
collected.push(path.relative(targetDir, full));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await walk(targetDir);
|
|
145
|
+
return collected;
|
|
146
|
+
}
|
|
147
|
+
|
|
111
148
|
// ─── Add app to existing monorepo ───
|
|
112
149
|
|
|
113
150
|
export async function addApp() {
|
|
@@ -428,6 +465,60 @@ async function writePluginFiles(targetDir, plugins) {
|
|
|
428
465
|
}
|
|
429
466
|
}
|
|
430
467
|
}
|
|
468
|
+
|
|
469
|
+
// auth-jwt + next-intl 동시 활성화 시 proxy.ts 병합
|
|
470
|
+
// (각 플러그인이 단독으로 깐 proxy.ts 를 합친 버전으로 덮어쓴다)
|
|
471
|
+
const names = new Set(plugins.map((p) => p.name));
|
|
472
|
+
if (names.has('auth-jwt') && names.has('next-intl')) {
|
|
473
|
+
const mergedProxy = `import createIntlMiddleware from 'next-intl/middleware';
|
|
474
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
475
|
+
|
|
476
|
+
import { routing } from '@/src/shared/config/i18n/routing';
|
|
477
|
+
|
|
478
|
+
const AUTH_ROUTES = ['/sign-in', '/sign-up'];
|
|
479
|
+
|
|
480
|
+
const intl = createIntlMiddleware(routing);
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 로케일 prefix (/ko, /en) 를 벗겨 인증 라우트 매칭에 사용한다.
|
|
484
|
+
* 예: /ko/sign-in → /sign-in
|
|
485
|
+
*/
|
|
486
|
+
const stripLocalePrefix = (pathname: string): string => {
|
|
487
|
+
const locales = routing.locales as readonly string[];
|
|
488
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
489
|
+
if (segments[0] && locales.includes(segments[0])) {
|
|
490
|
+
const rest = segments.slice(1).join('/');
|
|
491
|
+
return \`/\${rest}\`.replace(/\\/$/, '') || '/';
|
|
492
|
+
}
|
|
493
|
+
return pathname;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Next 16+ proxy.ts (구 middleware.ts).
|
|
498
|
+
* next-intl 라우팅 + auth-jwt 토큰 존재 체크 합성 버전.
|
|
499
|
+
*
|
|
500
|
+
* - intl 이 먼저 로케일 prefix 처리 + NEXT_LOCALE 쿠키 set
|
|
501
|
+
* - 그 위에 인증 가드 — 토큰 없고 인증 라우트도 아니면 /sign-in 으로 redirect
|
|
502
|
+
* - AT 만료 검사나 refresh 는 하지 않는다 (BFF 가 처리)
|
|
503
|
+
*/
|
|
504
|
+
export default function proxy(req: NextRequest) {
|
|
505
|
+
const intlRes = intl(req);
|
|
506
|
+
const pathname = stripLocalePrefix(req.nextUrl.pathname);
|
|
507
|
+
const hasToken = !!req.cookies.get('accessToken')?.value;
|
|
508
|
+
const isAuthRoute = AUTH_ROUTES.some((r) => pathname.startsWith(r));
|
|
509
|
+
|
|
510
|
+
if (isAuthRoute) return intlRes;
|
|
511
|
+
if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
|
|
512
|
+
|
|
513
|
+
return intlRes;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export const config = {
|
|
517
|
+
matcher: '/((?!api|_next|_vercel|monitoring|.*\\\\..*).*)',
|
|
518
|
+
};
|
|
519
|
+
`;
|
|
520
|
+
await fs.writeFile(path.join(targetDir, 'proxy.ts'), mergedProxy);
|
|
521
|
+
}
|
|
431
522
|
}
|
|
432
523
|
|
|
433
524
|
async function composeProviders(targetDir, plugins) {
|
package/src/create/index.mjs
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { parseArgs } from './cli-args.js';
|
|
4
4
|
import { createProject, addApp, addComponent } from './generator.js';
|
|
5
|
+
import { allPlugins } from './plugins/index.js';
|
|
6
|
+
import { CREATE_PLATFORMS, CREATE_STRUCTURES } from '../constants.js';
|
|
7
|
+
|
|
8
|
+
const PLUGIN_NAMES = allPlugins.map((p) => p.name);
|
|
9
|
+
const PLUGINS_LIST = PLUGIN_NAMES.join(', ');
|
|
5
10
|
|
|
6
11
|
export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
|
|
7
12
|
|
|
@@ -11,11 +16,12 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
11
16
|
sh-ui create add-component <name> [--app <name>]
|
|
12
17
|
|
|
13
18
|
옵션:
|
|
14
|
-
--platform
|
|
15
|
-
--structure
|
|
16
|
-
--plugins <a,b> 플러그인 (
|
|
19
|
+
--platform <${CREATE_PLATFORMS.join('|')}> 타겟 플랫폼
|
|
20
|
+
--structure <${CREATE_STRUCTURES.join('|')}> Next.js 프로젝트 구조 (next 일 때)
|
|
21
|
+
--plugins <a,b> 플러그인 (${PLUGINS_LIST}). 미지정/"" → 없음
|
|
17
22
|
--theme <base64> 테마 JSON (base64). 선택
|
|
18
23
|
--yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
|
|
24
|
+
--dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
|
|
19
25
|
-h, --help 이 도움말
|
|
20
26
|
|
|
21
27
|
예 (대화형):
|
|
@@ -23,7 +29,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
23
29
|
|
|
24
30
|
예 (비대화형 / 에이전트 / CI):
|
|
25
31
|
sh-ui create my-app --platform next --structure standalone --yes
|
|
26
|
-
sh-ui create my-app --platform next --structure monorepo --plugins
|
|
32
|
+
sh-ui create my-app --platform next --structure monorepo --plugins ${PLUGIN_NAMES.slice(0, 3).join(',')} --yes
|
|
27
33
|
sh-ui create my-app --platform flutter --yes
|
|
28
34
|
|
|
29
35
|
비대화형 환경(TTY 없음)에서는 누락된 필수 인자가 있으면 prompt 대신 에러로 종료한다.
|
|
@@ -62,6 +68,7 @@ export async function runCreate(rest) {
|
|
|
62
68
|
plugins: flags.plugins,
|
|
63
69
|
theme: flags.theme,
|
|
64
70
|
yes: flags.yes,
|
|
71
|
+
dryRun: flags.dryRun,
|
|
65
72
|
});
|
|
66
73
|
}
|
|
67
74
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const authJwtPlugin = {
|
|
2
2
|
name: 'auth-jwt',
|
|
3
3
|
label: '쿠키 기반 JWT 인증 (refresh 자리표시자 포함)',
|
|
4
|
+
description:
|
|
5
|
+
'쿠키 기반 JWT 인증. Next 16 proxy.ts 미들웨어, refresh-aware BFF, withAuthRetry 헬퍼. refresh 본문은 placeholder — 백엔드 명세 확정 후 한 파일 채우면 자동 활성화.',
|
|
4
6
|
priority: 2,
|
|
5
7
|
|
|
6
8
|
// 의존성 추가 없음 — 베이스의 fetch + cookies + react-query 만 사용
|
|
@@ -25,7 +27,7 @@ export const authJwtPlugin = {
|
|
|
25
27
|
// BFF 와 withAuthRetry 가 자동 활용한다.
|
|
26
28
|
|
|
27
29
|
files: {
|
|
28
|
-
'
|
|
30
|
+
'proxy.ts': `import { NextRequest, NextResponse } from 'next/server';
|
|
29
31
|
|
|
30
32
|
const AUTH_ROUTES = ['/sign-in', '/sign-up'];
|
|
31
33
|
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { sentryPlugin } from './sentry.js';
|
|
2
2
|
import { nextIntlPlugin } from './nextIntl.js';
|
|
3
3
|
import { authJwtPlugin } from './authJwt.js';
|
|
4
|
+
import { validatePlugins } from './pluginSchema.js';
|
|
4
5
|
|
|
5
6
|
export const allPlugins = [sentryPlugin, nextIntlPlugin, authJwtPlugin];
|
|
6
7
|
|
|
8
|
+
// 모듈 로드 시점에 모든 플러그인 manifest 검증 — 잘못된 형태가 있으면 즉시 실패.
|
|
9
|
+
// 예: src/proxy.ts 같은 잘못된 경로, name 이 kebab-case 가 아닌 경우 등.
|
|
10
|
+
validatePlugins(allPlugins);
|
|
11
|
+
|
|
7
12
|
export function getPluginChoices() {
|
|
8
13
|
return allPlugins.map((p) => ({
|
|
9
14
|
name: p.label,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 플러그인 manifest 스키마.
|
|
5
|
+
* plugins/index.js 로딩 시 모든 플러그인을 이 스키마로 validate 한다.
|
|
6
|
+
*
|
|
7
|
+
* 디자인 가드:
|
|
8
|
+
* - file path 가 'src/proxy.ts' 면 거부 — Next 16 은 root proxy.ts 만 인식하므로
|
|
9
|
+
* (베이스 템플릿이 app/ 을 root 에 두는 한). 이런 종류의 사고를 빌드 타임에 차단.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const filePath = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.refine((p) => p !== 'src/proxy.ts', {
|
|
16
|
+
message:
|
|
17
|
+
"proxy.ts must be at root (Next 16 convention with app/ at root). " +
|
|
18
|
+
"Use 'proxy.ts' instead of 'src/proxy.ts'.",
|
|
19
|
+
})
|
|
20
|
+
.refine((p) => p !== 'src/middleware.ts', {
|
|
21
|
+
message:
|
|
22
|
+
"middleware.ts (deprecated, use proxy.ts in Next 16+) must be at root. " +
|
|
23
|
+
"Use 'middleware.ts' instead of 'src/middleware.ts'.",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const wrapperFn = z
|
|
27
|
+
.function()
|
|
28
|
+
.args(z.string())
|
|
29
|
+
.returns(z.string())
|
|
30
|
+
.optional();
|
|
31
|
+
|
|
32
|
+
export const PluginSchema = z.object({
|
|
33
|
+
name: z.string().regex(/^[a-z][a-z0-9-]*$/, {
|
|
34
|
+
message: 'Plugin name must be lowercase kebab-case (e.g., "auth-jwt")',
|
|
35
|
+
}),
|
|
36
|
+
label: z.string().min(1),
|
|
37
|
+
description: z.string().min(1).optional(),
|
|
38
|
+
priority: z.number().int().nonnegative(),
|
|
39
|
+
|
|
40
|
+
dependencies: z.record(z.string(), z.string()).optional(),
|
|
41
|
+
devDependencies: z.record(z.string(), z.string()).optional(),
|
|
42
|
+
|
|
43
|
+
imports: z.array(z.string()).optional(),
|
|
44
|
+
wrapExport: wrapperFn,
|
|
45
|
+
|
|
46
|
+
envVars: z.array(z.string()).optional(),
|
|
47
|
+
turboEnvVars: z.array(z.string()).optional(),
|
|
48
|
+
|
|
49
|
+
providerImports: z.array(z.string()).optional(),
|
|
50
|
+
providerWrappers: z
|
|
51
|
+
.array(
|
|
52
|
+
z.union([
|
|
53
|
+
z.object({ open: z.string(), close: z.string() }),
|
|
54
|
+
z.string(),
|
|
55
|
+
]),
|
|
56
|
+
)
|
|
57
|
+
.optional(),
|
|
58
|
+
|
|
59
|
+
files: z.record(filePath, z.string()).optional(),
|
|
60
|
+
|
|
61
|
+
// 향후 확장 — moves, transforms, etc 는 nextIntl.js 에서 사용하므로 허용
|
|
62
|
+
moves: z.array(z.any()).optional(),
|
|
63
|
+
transforms: z.array(z.any()).optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 모든 플러그인을 검증. 실패 시 첫 번째 에러로 throw.
|
|
68
|
+
*/
|
|
69
|
+
export function validatePlugins(plugins) {
|
|
70
|
+
for (const plugin of plugins) {
|
|
71
|
+
const result = PluginSchema.safeParse(plugin);
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
const issues = result.error.issues
|
|
74
|
+
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
|
75
|
+
.join('\n');
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid plugin manifest "${plugin?.name ?? '(unknown)'}":\n${issues}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -3,19 +3,22 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { stdin, stdout } from "node:process";
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
INIT_PLATFORMS,
|
|
8
|
+
THEME_BASES,
|
|
9
|
+
THEME_RADII,
|
|
10
|
+
THEME_MODES,
|
|
11
|
+
INIT_DEFAULTS,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
|
|
6
14
|
const CHOICES = {
|
|
7
|
-
platform:
|
|
8
|
-
base:
|
|
9
|
-
radius:
|
|
10
|
-
mode:
|
|
15
|
+
platform: INIT_PLATFORMS,
|
|
16
|
+
base: THEME_BASES,
|
|
17
|
+
radius: THEME_RADII,
|
|
18
|
+
mode: THEME_MODES,
|
|
11
19
|
};
|
|
12
20
|
|
|
13
|
-
const DEFAULTS =
|
|
14
|
-
platform: "react",
|
|
15
|
-
base: "neutral",
|
|
16
|
-
radius: "md",
|
|
17
|
-
mode: "light-dark",
|
|
18
|
-
};
|
|
21
|
+
const DEFAULTS = INIT_DEFAULTS;
|
|
19
22
|
|
|
20
23
|
const PATHS = {
|
|
21
24
|
react: {
|
package/src/mcp.mjs
CHANGED
|
@@ -29,11 +29,21 @@ import {
|
|
|
29
29
|
getSummariesPath,
|
|
30
30
|
getVersionsPath,
|
|
31
31
|
} from "./paths.mjs";
|
|
32
|
+
import {
|
|
33
|
+
CREATE_PLATFORMS,
|
|
34
|
+
CREATE_STRUCTURES,
|
|
35
|
+
INIT_PLATFORMS,
|
|
36
|
+
THEME_BASES,
|
|
37
|
+
THEME_RADII,
|
|
38
|
+
THEME_MODES,
|
|
39
|
+
} from "./constants.js";
|
|
40
|
+
import { allPlugins } from "./create/plugins/index.js";
|
|
32
41
|
|
|
33
|
-
const PLATFORMS =
|
|
34
|
-
const BASES =
|
|
35
|
-
const RADII =
|
|
36
|
-
const MODES =
|
|
42
|
+
const PLATFORMS = INIT_PLATFORMS;
|
|
43
|
+
const BASES = THEME_BASES;
|
|
44
|
+
const RADII = THEME_RADII;
|
|
45
|
+
const MODES = THEME_MODES;
|
|
46
|
+
const PLUGIN_NAMES = allPlugins.map((p) => p.name);
|
|
37
47
|
|
|
38
48
|
const INIT_DESCRIPTIONS = {
|
|
39
49
|
platform: {
|
|
@@ -161,12 +171,12 @@ export async function startMcpServer() {
|
|
|
161
171
|
inputSchema: {
|
|
162
172
|
name: z.string().min(1)
|
|
163
173
|
.describe("프로젝트 디렉토리 이름. 예: my-app"),
|
|
164
|
-
platform: z.enum(
|
|
174
|
+
platform: z.enum(CREATE_PLATFORMS)
|
|
165
175
|
.describe("타겟 플랫폼"),
|
|
166
|
-
structure: z.enum(
|
|
176
|
+
structure: z.enum(CREATE_STRUCTURES).optional()
|
|
167
177
|
.describe("Next.js 구조 — platform=next 일 때 필수. standalone(단독) | monorepo(Turborepo)"),
|
|
168
|
-
plugins: z.array(z.enum(
|
|
169
|
-
.describe(
|
|
178
|
+
plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
|
|
179
|
+
.describe(`Next.js 플러그인 (${PLUGIN_NAMES.join(', ')}). 미지정시 빈 배열`),
|
|
170
180
|
theme: z.string().optional()
|
|
171
181
|
.describe("base64 인코딩된 테마 JSON (선택)"),
|
|
172
182
|
cwd: z.string().optional()
|