sh-ui-cli 0.94.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/changelog/versions.json +25 -0
- package/package.json +1 -1
- package/src/create/describeTemplate.js +24 -3
- package/src/create/generator.js +191 -32
- package/src/create/index.mjs +4 -0
- package/src/mcp.mjs +89 -2
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.96.0",
|
|
7
|
+
"date": "2026-05-15",
|
|
8
|
+
"title": "vite 스캐폴드 P0 fix — i18n + sentry plugins 패치 충돌 + create_project appName 인자",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**P0 — vite.config.ts plugins 배열 패치 anchor 경합 fix** — `--i18n react-i18next` 와 `--observability sentry` 를 동시에 켜면 sentry plugin 호출이 viteStaticCopy 의 `targets: [...]` 배열 안에 inject 되던 회귀 (피드백 #3). 두 patch 가 같은 정규식 anchor 를 두고 경합한 결과. balanced-bracket walker + top-level entry splitter 로 통합 (`appendVitePluginEntry` helper) — 두 옵션 모두 plugins 의 top-level entry 로 안전하게 append. all-options 동시 ON smoke 회귀 가드 추가.",
|
|
12
|
+
"**`sh_ui_create_project` 의 `appName` 인자** — monorepo 첫 앱 이름을 호출 시점에 지정. describe_template 의 동일 인자와 1:1 시그니처 일치. 기존엔 'web' 고정이라 사용자가 12+ 파일을 손 rename 해야 했음. CLI `--app` 플래그도 같이 노출. 미지정 시 기본 'web' (호환 유지).",
|
|
13
|
+
"**i18n 켜면 Home.tsx / App.tsx 자동 patch** — `useTranslation` import + `{t('greeting')}` 사용으로 변환. locale 시드 (ko='안녕하세요', en='Hello') 덕분에 첫 화면이 사용자 언어로 즉시 표시 — i18n 셋업 동작을 한눈에 확인 (피드백 #2/#3). fsd/flat × standalone/monorepo 4 조합 모두 처리.",
|
|
14
|
+
"**`sh_ui_describe_template` file plan 에 `.gitignore` mock-rename** — finalizeProject 가 fs 단계에서 `gitignore` → `.gitignore` 로 바꾸지만, describe_template 출력엔 그 변환이 반영 안 돼 점 없는 raw 파일명이 노출되던 buglet (피드백 #3) 수정. file plan ↔ 실제 emit 1:1 정합성 유지."
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.96.0"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.95.0",
|
|
20
|
+
"date": "2026-05-15",
|
|
21
|
+
"title": "MCP describe_template 옵션 누락 fix + staleness 경고 — 외부 AI 가 stale MCP 알아채게",
|
|
22
|
+
"type": "minor",
|
|
23
|
+
"highlights": [
|
|
24
|
+
"**`sh_ui_describe_template` 가 tauri/i18n/locales/observability 받음** — describeTemplate 자체는 옵션을 처리하지만 MCP 핸들러의 inputSchema 에 누락되어 외부 AI 가 옵션을 명시해도 무시됐다. preview 가 항상 기본 파일만 나열되던 회귀 (#3) 해결. create_project ↔ describe_template 파일 plan 1:1 일치 보장.",
|
|
25
|
+
"**MCP staleness 경고 자동 prepend** — 서버 start 시 https://registry.npmjs.org/sh-ui-cli/latest 비동기 조회 (3s timeout). 현재 버전보다 latest 가 높으면 모든 tool 응답 상단에 `⚠ sh-ui MCP X.Y.Z (latest: …)` 한 줄 prepend. 외부 AI 가 stale MCP 를 정상 출력으로 오해하던 회귀 (#8) 차단. 오프라인 / DNS 실패는 조용히 skip — best-effort.",
|
|
26
|
+
"**`SH_UI_SKIP_STALENESS_CHECK=1` 옵트아웃** — CI / 오프라인 / 사용자가 경고 끄고 싶을 때 env var 한 줄. test runner 가 외부 fetch 없이 결정적으로 돌게 함."
|
|
27
|
+
],
|
|
28
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.95.0"
|
|
29
|
+
},
|
|
5
30
|
{
|
|
6
31
|
"version": "0.94.0",
|
|
7
32
|
"date": "2026-05-15",
|
package/package.json
CHANGED
|
@@ -400,9 +400,10 @@ function finalize(groups) {
|
|
|
400
400
|
for (let i = groups.length - 1; i >= 0; i--) {
|
|
401
401
|
const kept = [];
|
|
402
402
|
for (const p of groups[i].paths) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
403
|
+
const finalPath = applyFinalizeRenames(p);
|
|
404
|
+
if (!seen.has(finalPath)) {
|
|
405
|
+
seen.add(finalPath);
|
|
406
|
+
kept.push(finalPath);
|
|
406
407
|
}
|
|
407
408
|
}
|
|
408
409
|
groups[i] = {
|
|
@@ -417,3 +418,23 @@ function finalize(groups) {
|
|
|
417
418
|
files.sort();
|
|
418
419
|
return { files, groups: cleaned };
|
|
419
420
|
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* generator.js 의 `finalizeProject` 가 실제 fs 단계에서 적용하는 rename 을
|
|
424
|
+
* file plan 텍스트 레벨에서 mock-apply (v0.96.0+ — 피드백 #3 buglet).
|
|
425
|
+
*
|
|
426
|
+
* 현재 규칙:
|
|
427
|
+
* - basename 이 정확히 'gitignore' 인 경로 → '.gitignore' 로 prefix dot 추가.
|
|
428
|
+
* (npm publish 가 .gitignore 를 strip 하므로 템플릿엔 점 없이 두고 emit 후 dot-prefix.)
|
|
429
|
+
* - 이미 '.gitignore' 인 경로 (예: tauri-shell 의 src-tauri/.gitignore) 는 그대로.
|
|
430
|
+
*
|
|
431
|
+
* 미래에 다른 fs-level rename 이 추가되면 여기에 같이 등록 (describeTemplate 의
|
|
432
|
+
* file-plan ↔ create_project 의 실제 emit 1:1 정합성 유지).
|
|
433
|
+
*/
|
|
434
|
+
function applyFinalizeRenames(p) {
|
|
435
|
+
const slash = p.lastIndexOf('/');
|
|
436
|
+
const dir = slash === -1 ? '' : p.slice(0, slash + 1);
|
|
437
|
+
const base = slash === -1 ? p : p.slice(slash + 1);
|
|
438
|
+
if (base === 'gitignore') return dir + '.gitignore';
|
|
439
|
+
return p;
|
|
440
|
+
}
|
package/src/create/generator.js
CHANGED
|
@@ -372,6 +372,8 @@ export async function createProject(options = {}) {
|
|
|
372
372
|
i18n: options.i18n ?? 'none',
|
|
373
373
|
locales: options.locales ?? 'ko,en',
|
|
374
374
|
observability: options.observability ?? 'none',
|
|
375
|
+
appName: options.appName ?? null,
|
|
376
|
+
port: options.port ?? null,
|
|
375
377
|
});
|
|
376
378
|
}
|
|
377
379
|
|
|
@@ -422,7 +424,11 @@ export async function createProject(options = {}) {
|
|
|
422
424
|
if (projectType === 'standalone') {
|
|
423
425
|
await generateStandalone(targetDir, projectName, plugins, theme, cssFramework, arch, themeBase);
|
|
424
426
|
} else {
|
|
425
|
-
await generateMonorepo(targetDir, projectName, plugins, {
|
|
427
|
+
await generateMonorepo(targetDir, projectName, plugins, {
|
|
428
|
+
yes: options.yes, theme, css: cssFramework, arch, themeBase,
|
|
429
|
+
appName: options.appName ?? null,
|
|
430
|
+
port: options.port ?? null,
|
|
431
|
+
});
|
|
426
432
|
}
|
|
427
433
|
|
|
428
434
|
await finalizeProject(targetDir, { dryRun: options.dryRun });
|
|
@@ -1144,6 +1150,162 @@ export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
|
1144
1150
|
|
|
1145
1151
|
// vite.config.ts 에 vite-plugin-static-copy 삽입.
|
|
1146
1152
|
await patchViteConfigForI18n(targetDir, { i18nDirRel });
|
|
1153
|
+
|
|
1154
|
+
// 랜딩 컴포넌트 (Home.tsx 또는 fsd 의 src/app/App.tsx) 가 i18n 을 즉시 사용하도록 패치.
|
|
1155
|
+
// v0.94 에서 ko 시드가 '안녕하세요' 로 들어가니 첫 화면이 한국어로 떠 사용자가 i18n 동작을 즉시 인지 (피드백 #2/#3).
|
|
1156
|
+
await patchLandingForI18n(targetDir, { arch });
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* i18n 활성화 시 첫 화면을 `useTranslation` 으로 바꾸어 'greeting' 키를 사용한다.
|
|
1161
|
+
* 이 patch 가 없으면 사용자가 i18n 셋업이 동작하는지 즉시 확인할 길이 없음.
|
|
1162
|
+
*
|
|
1163
|
+
* 대상 파일 (존재하는 첫 번째):
|
|
1164
|
+
* - fsd + vite-standalone: src/app/App.tsx (inline `<h1>Hello World</h1>`)
|
|
1165
|
+
* - fsd + vite-app(monorepo): src/Home.tsx (App.tsx 가 <Home /> import)
|
|
1166
|
+
* - flat: src/Home.tsx
|
|
1167
|
+
*
|
|
1168
|
+
* 이미 useTranslation 을 쓰고 있으면 no-op (재진입 안전).
|
|
1169
|
+
* 형태가 예상과 다르면 silent skip — 사용자 코드를 깨뜨리지 않는 게 우선.
|
|
1170
|
+
*/
|
|
1171
|
+
async function patchLandingForI18n(targetDir, { arch }) {
|
|
1172
|
+
const candidates =
|
|
1173
|
+
arch.name === 'fsd'
|
|
1174
|
+
? [
|
|
1175
|
+
path.join(targetDir, 'src/Home.tsx'), // vite-app monorepo
|
|
1176
|
+
path.join(targetDir, 'src/app/App.tsx'), // vite-standalone fsd
|
|
1177
|
+
]
|
|
1178
|
+
: [
|
|
1179
|
+
path.join(targetDir, 'src/Home.tsx'), // flat (vite-app + vite-standalone 공통)
|
|
1180
|
+
];
|
|
1181
|
+
|
|
1182
|
+
for (const filePath of candidates) {
|
|
1183
|
+
if (!(await fs.pathExists(filePath))) continue;
|
|
1184
|
+
let src = await fs.readFile(filePath, 'utf-8');
|
|
1185
|
+
if (src.includes('useTranslation')) continue; // 이미 적용됨
|
|
1186
|
+
|
|
1187
|
+
// 형태가 예상과 다르면 (Hello World 가 없으면) skip.
|
|
1188
|
+
if (!src.includes('Hello World')) continue;
|
|
1189
|
+
|
|
1190
|
+
// 1) import { useTranslation } from 'react-i18next' 를 import 블록 뒤에 (없으면 파일 맨 앞에) 추가.
|
|
1191
|
+
const i18nImport = `import { useTranslation } from 'react-i18next';\n`;
|
|
1192
|
+
const lastImportIdx = src.lastIndexOf('import ');
|
|
1193
|
+
if (lastImportIdx >= 0) {
|
|
1194
|
+
const importLineEnd = src.indexOf('\n', lastImportIdx) + 1;
|
|
1195
|
+
src = src.slice(0, importLineEnd) + i18nImport + src.slice(importLineEnd);
|
|
1196
|
+
} else {
|
|
1197
|
+
// import 없는 파일 (flat Home.tsx) — 파일 맨 앞에 import + 빈 줄.
|
|
1198
|
+
src = i18nImport + '\n' + src;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// 2) 컴포넌트 함수 본문 첫 줄에 `const { t } = useTranslation();` 추가.
|
|
1202
|
+
// export default function ... () { 또는 export default function ... () {\n...return (
|
|
1203
|
+
// 형태를 가정. 매치 실패 시 silent skip.
|
|
1204
|
+
const fnMatch = src.match(/export default function \w+\s*\([^)]*\)\s*\{\n/);
|
|
1205
|
+
if (!fnMatch) continue;
|
|
1206
|
+
const insertAt = fnMatch.index + fnMatch[0].length;
|
|
1207
|
+
src = src.slice(0, insertAt) + ` const { t } = useTranslation();\n` + src.slice(insertAt);
|
|
1208
|
+
|
|
1209
|
+
// 3) Hello World 텍스트를 {t('greeting')} 으로 치환.
|
|
1210
|
+
src = src.replace(/Hello World/g, "{t('greeting')}");
|
|
1211
|
+
|
|
1212
|
+
await fs.writeFile(filePath, src);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* vite.config.ts 의 `plugins: [ ... ]` 배열에서 top-level entry 들을 분리해
|
|
1218
|
+
* 새 entry 를 append 한 뒤 canonical 멀티라인 형태로 재방출한다.
|
|
1219
|
+
*
|
|
1220
|
+
* - balanced bracket 으로 outer `]` 를 찾으므로 `viteStaticCopy({ targets: [...] })`
|
|
1221
|
+
* 처럼 안쪽에 `[]` 가 있어도 안전.
|
|
1222
|
+
* - 두 개 이상의 patch (i18n + sentry 등) 가 같은 vite.config.ts 를 순차로
|
|
1223
|
+
* 건드릴 때 anchor 경합으로 entry 가 안쪽 배열에 inject 되던 v0.92~0.95 회귀의 원인을 제거.
|
|
1224
|
+
*
|
|
1225
|
+
* 입력 src 가 예상 형태가 아니면 (`plugins: [` 못 찾음 / unbalanced bracket)
|
|
1226
|
+
* `null` 을 반환 → 호출부에서 patch 포기.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {string} src — vite.config.ts 전체 소스
|
|
1229
|
+
* @param {string} callSource — 추가할 plugin 호출 소스 (예: "viteStaticCopy({ ... })"). 선행 indent / 후행 comma 없이 entry 본문만.
|
|
1230
|
+
* @returns {string | null}
|
|
1231
|
+
*/
|
|
1232
|
+
function appendVitePluginEntry(src, callSource) {
|
|
1233
|
+
const openMatch = src.match(/plugins:\s*\[/);
|
|
1234
|
+
if (!openMatch) return null;
|
|
1235
|
+
|
|
1236
|
+
// plugins: 라인의 들여쓰기 추출 → 안쪽 entry 는 한 단계(=2 space) 더 깊게.
|
|
1237
|
+
const pluginsLineStart = src.lastIndexOf('\n', openMatch.index) + 1;
|
|
1238
|
+
const pluginsIndent = src.slice(pluginsLineStart, openMatch.index).match(/^[ \t]*/)[0];
|
|
1239
|
+
const entryIndent = pluginsIndent + ' ';
|
|
1240
|
+
|
|
1241
|
+
const arrStart = openMatch.index + openMatch[0].length; // `[` 직후
|
|
1242
|
+
// balanced bracket walk — `]` 깊이 0 지점이 outer 닫는 `]`.
|
|
1243
|
+
let depth = 1;
|
|
1244
|
+
let i = arrStart;
|
|
1245
|
+
let inStr = null;
|
|
1246
|
+
while (i < src.length && depth > 0) {
|
|
1247
|
+
const ch = src[i];
|
|
1248
|
+
if (inStr) {
|
|
1249
|
+
if (ch === '\\' && i + 1 < src.length) { i += 2; continue; }
|
|
1250
|
+
if (ch === inStr) inStr = null;
|
|
1251
|
+
i++;
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; i++; continue; }
|
|
1255
|
+
if (ch === '[' || ch === '(' || ch === '{') depth++;
|
|
1256
|
+
else if (ch === ']' || ch === ')' || ch === '}') depth--;
|
|
1257
|
+
if (depth === 0) break;
|
|
1258
|
+
i++;
|
|
1259
|
+
}
|
|
1260
|
+
if (depth !== 0) return null;
|
|
1261
|
+
const arrEnd = i; // outer `]` 의 인덱스
|
|
1262
|
+
|
|
1263
|
+
const inner = src.slice(arrStart, arrEnd);
|
|
1264
|
+
const entries = splitTopLevelPluginEntries(inner);
|
|
1265
|
+
entries.push(callSource);
|
|
1266
|
+
|
|
1267
|
+
const rebuilt =
|
|
1268
|
+
'\n' +
|
|
1269
|
+
entries.map((e) => entryIndent + e).join(',\n') +
|
|
1270
|
+
',\n' +
|
|
1271
|
+
pluginsIndent;
|
|
1272
|
+
return src.slice(0, arrStart) + rebuilt + src.slice(arrEnd);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* plugins 배열 내부 문자열을 top-level `,` 기준으로 split.
|
|
1277
|
+
* `()` `[]` `{}` 와 문자열 리터럴 ('`, ', ") 안의 `,` 는 무시.
|
|
1278
|
+
*
|
|
1279
|
+
* @param {string} s
|
|
1280
|
+
* @returns {string[]}
|
|
1281
|
+
*/
|
|
1282
|
+
function splitTopLevelPluginEntries(s) {
|
|
1283
|
+
const entries = [];
|
|
1284
|
+
let depth = 0;
|
|
1285
|
+
let inStr = null;
|
|
1286
|
+
let buf = '';
|
|
1287
|
+
for (let i = 0; i < s.length; i++) {
|
|
1288
|
+
const ch = s[i];
|
|
1289
|
+
if (inStr) {
|
|
1290
|
+
buf += ch;
|
|
1291
|
+
if (ch === '\\' && i + 1 < s.length) { buf += s[i + 1]; i++; continue; }
|
|
1292
|
+
if (ch === inStr) inStr = null;
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; buf += ch; continue; }
|
|
1296
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; buf += ch; continue; }
|
|
1297
|
+
if (ch === ')' || ch === ']' || ch === '}') { depth--; buf += ch; continue; }
|
|
1298
|
+
if (ch === ',' && depth === 0) {
|
|
1299
|
+
const t = buf.trim();
|
|
1300
|
+
if (t) entries.push(t);
|
|
1301
|
+
buf = '';
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
buf += ch;
|
|
1305
|
+
}
|
|
1306
|
+
const t = buf.trim();
|
|
1307
|
+
if (t) entries.push(t);
|
|
1308
|
+
return entries;
|
|
1147
1309
|
}
|
|
1148
1310
|
|
|
1149
1311
|
/**
|
|
@@ -1167,22 +1329,17 @@ async function patchViteConfigForI18n(targetDir, { i18nDirRel }) {
|
|
|
1167
1329
|
const insertImportAt = src.indexOf('\n', lastImportIdx) + 1;
|
|
1168
1330
|
src = src.slice(0, insertImportAt) + importLine + '\n' + src.slice(insertImportAt);
|
|
1169
1331
|
|
|
1170
|
-
|
|
1332
|
+
// entry 본문만 넘기고 indent / 후행 comma 는 helper 가 붙인다.
|
|
1333
|
+
const pluginCall = `viteStaticCopy({
|
|
1171
1334
|
// i18n locale 파일을 public/locales 로 미러 — i18next-http-backend 의 loadPath 와 매칭.
|
|
1172
1335
|
targets: [
|
|
1173
1336
|
{ src: '${i18nDirRel}/locales/*', dest: 'locales' },
|
|
1174
1337
|
],
|
|
1175
|
-
})
|
|
1338
|
+
})`;
|
|
1176
1339
|
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
const insertAt = pluginsMatch.index + pluginsMatch[0].length;
|
|
1181
|
-
src = src.slice(0, insertAt) + '\n' + pluginCall + src.slice(insertAt);
|
|
1182
|
-
} else {
|
|
1183
|
-
// 형태가 예상과 다르면 패치 포기 — config 가 깨지지 않도록.
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1340
|
+
const patched = appendVitePluginEntry(src, pluginCall);
|
|
1341
|
+
if (!patched) return; // 형태가 예상과 다르면 patch 포기 — config 가 깨지지 않도록.
|
|
1342
|
+
src = patched;
|
|
1186
1343
|
|
|
1187
1344
|
await fs.writeFile(viteConfigPath, src);
|
|
1188
1345
|
}
|
|
@@ -1353,30 +1510,29 @@ async function patchViteConfigForSentry(targetDir) {
|
|
|
1353
1510
|
}
|
|
1354
1511
|
|
|
1355
1512
|
if (!cfg.includes("sentryVitePlugin(")) {
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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;
|
|
1363
1523
|
}
|
|
1364
1524
|
|
|
1365
1525
|
if (!cfg.includes("sourcemap:")) {
|
|
1366
1526
|
if (cfg.includes("server: {")) {
|
|
1367
1527
|
cfg = cfg.replace(/(\n\s*server:\s*\{)/, `\n build: { sourcemap: true },$1`);
|
|
1368
|
-
} else if (cfg.includes("plugins: [")) {
|
|
1369
|
-
cfg = cfg.replace(
|
|
1370
|
-
/(plugins:\s*\[[^\]]*?\][^,\n]*[,;]?\n)/s,
|
|
1371
|
-
`$1 build: { sourcemap: true },\n`,
|
|
1372
|
-
);
|
|
1373
1528
|
}
|
|
1529
|
+
// server 가 없는 변종은 build 삽입 위치가 모호 → 보수적으로 skip (사용자가 수동 추가).
|
|
1374
1530
|
}
|
|
1375
1531
|
|
|
1376
1532
|
await fs.writeFile(viteCfgPath, cfg);
|
|
1377
1533
|
}
|
|
1378
1534
|
|
|
1379
|
-
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
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 } = {}) {
|
|
1380
1536
|
await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
|
|
1381
1537
|
|
|
1382
1538
|
// Update root package.json
|
|
@@ -1400,16 +1556,19 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
1400
1556
|
}
|
|
1401
1557
|
await fs.writeJson(turboPath, turbo, { spaces: 2 });
|
|
1402
1558
|
|
|
1403
|
-
// Create first app
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1559
|
+
// Create first app — `appName` 인자가 주어지면 그대로, 아니면 yes 모드 default 'web' / 대화모드 prompt.
|
|
1560
|
+
// v0.96.0+ — describe_template 와 동일한 시그니처 (피드백 #3 의 API 일관성 항목).
|
|
1561
|
+
const appName = appNameOpt
|
|
1562
|
+
? validateProjectName(appNameOpt, 'appName')
|
|
1563
|
+
: (yes ? 'web' : await input({
|
|
1564
|
+
message: '첫 번째 앱 이름:',
|
|
1565
|
+
default: 'web',
|
|
1566
|
+
}));
|
|
1408
1567
|
|
|
1409
|
-
const port = yes ? '3000' : await input({
|
|
1568
|
+
const port = portOpt ?? (yes ? '3000' : await input({
|
|
1410
1569
|
message: '포트 번호:',
|
|
1411
1570
|
default: '3000',
|
|
1412
|
-
});
|
|
1571
|
+
}));
|
|
1413
1572
|
|
|
1414
1573
|
const appsDir = path.join(targetDir, 'apps', appName);
|
|
1415
1574
|
if (platform === 'vite') {
|
package/src/create/index.mjs
CHANGED
|
@@ -101,6 +101,10 @@ export async function runCreate(rest) {
|
|
|
101
101
|
i18n: flags.i18n,
|
|
102
102
|
locales: flags.locales,
|
|
103
103
|
observability: flags.observability,
|
|
104
|
+
// create 컨텍스트에서는 --app 이 첫 앱 이름 (monorepo). 같은 플래그가
|
|
105
|
+
// add-component 컨텍스트에서는 대상 앱 선택 — 의미가 컨텍스트마다 다름.
|
|
106
|
+
appName: flags.app,
|
|
107
|
+
port: flags.port,
|
|
104
108
|
yes: flags.yes,
|
|
105
109
|
dryRun: flags.dryRun,
|
|
106
110
|
});
|
package/src/mcp.mjs
CHANGED
|
@@ -113,11 +113,66 @@ async function captureConsole(fn) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function textResult(text) {
|
|
116
|
-
return { content: [{ type: "text", text }] };
|
|
116
|
+
return { content: withStaleness([{ type: "text", text }]) };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
function jsonResult(data) {
|
|
120
|
-
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
120
|
+
return { content: withStaleness([{ type: "text", text: JSON.stringify(data, null, 2) }]) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Staleness 경고 — npm registry 에서 latest sh-ui-cli 버전을 조회해 현재와 다르면
|
|
125
|
+
* 모든 tool 응답 상단에 한 줄 경고 prepend (v0.95.0+).
|
|
126
|
+
*
|
|
127
|
+
* 왜 필요: npx 가 sh-ui-cli 를 한 번 fetch 한 뒤 ~/.npm/_npx 캐시에 박아두면, 사용자
|
|
128
|
+
* `~/.claude.json` 에 `sh-ui-cli` (버전 비고정) 로 등록해도 MCP 가 오래된 버전으로
|
|
129
|
+
* 계속 실행된다. AI 에이전트는 "응답이 정상 응답" 으로 받아들이므로 stale 임을 의심
|
|
130
|
+
* 못 함 — 사용자 (ai-org 케이스) 가 직접 발견할 때까지 잘못된 plan 으로 작업.
|
|
131
|
+
*
|
|
132
|
+
* 모듈 변수 — startMcpServer 가 fire-and-forget 으로 채움. 첫 응답 직전에 미완료
|
|
133
|
+
* 면 경고 없이 진행 (best-effort).
|
|
134
|
+
*/
|
|
135
|
+
let STALE_WARNING = "";
|
|
136
|
+
|
|
137
|
+
function withStaleness(content) {
|
|
138
|
+
if (!STALE_WARNING) return content;
|
|
139
|
+
return [{ type: "text", text: STALE_WARNING }, ...content];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 비동기로 npm registry 조회 → 더 높은 버전이 있으면 STALE_WARNING 셋업.
|
|
144
|
+
* 3초 timeout · 모든 실패 (오프라인 / DNS / 차단) 는 조용히 무시.
|
|
145
|
+
*/
|
|
146
|
+
async function checkStaleness(currentVersion, cliName) {
|
|
147
|
+
try {
|
|
148
|
+
const ctrl = new AbortController();
|
|
149
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
150
|
+
const res = await fetch(`https://registry.npmjs.org/${cliName}/latest`, {
|
|
151
|
+
signal: ctrl.signal,
|
|
152
|
+
});
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
if (!res.ok) return;
|
|
155
|
+
const data = await res.json();
|
|
156
|
+
const latest = typeof data.version === "string" ? data.version : null;
|
|
157
|
+
if (!latest || latest === currentVersion) return;
|
|
158
|
+
if (!isSemverGreater(latest, currentVersion)) return;
|
|
159
|
+
STALE_WARNING =
|
|
160
|
+
`⚠ sh-ui MCP ${currentVersion} (latest: ${latest}) — stale. ` +
|
|
161
|
+
`최신 옵션·버그픽스를 받으려면: \n` +
|
|
162
|
+
` • ~/.claude.json (또는 다른 MCP 설정) 의 args 를 ["sh-ui-cli@latest", "mcp"] 로 변경 후 재시작\n` +
|
|
163
|
+
` • npx 캐시가 박혀 있으면 \`npx clear-npx-cache\` 한 번 실행`;
|
|
164
|
+
} catch {
|
|
165
|
+
// best-effort.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isSemverGreater(a, b) {
|
|
170
|
+
const parse = (s) => s.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
171
|
+
const [aMaj, aMin, aPatch] = parse(a);
|
|
172
|
+
const [bMaj, bMin, bPatch] = parse(b);
|
|
173
|
+
if (aMaj !== bMaj) return aMaj > bMaj;
|
|
174
|
+
if (aMin !== bMin) return aMin > bMin;
|
|
175
|
+
return aPatch > bPatch;
|
|
121
176
|
}
|
|
122
177
|
|
|
123
178
|
async function loadRegistry(platform) {
|
|
@@ -346,6 +401,13 @@ return (
|
|
|
346
401
|
|
|
347
402
|
export async function startMcpServer() {
|
|
348
403
|
const { version, name: cliName } = await readPackageMeta();
|
|
404
|
+
|
|
405
|
+
// fire-and-forget — server 가 첫 tool 요청 받기 전에 끝나면 경고 prepend, 아니면 다음 요청부터.
|
|
406
|
+
// env SH_UI_SKIP_STALENESS_CHECK=1 로 disable 가능 (CI / 오프라인 환경).
|
|
407
|
+
if (!process.env.SH_UI_SKIP_STALENESS_CHECK) {
|
|
408
|
+
void checkStaleness(version, cliName);
|
|
409
|
+
}
|
|
410
|
+
|
|
349
411
|
const server = new McpServer(
|
|
350
412
|
{ name: "sh-ui", version },
|
|
351
413
|
{
|
|
@@ -421,6 +483,15 @@ export async function startMcpServer() {
|
|
|
421
483
|
"observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
|
|
422
484
|
"@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
|
|
423
485
|
),
|
|
486
|
+
// monorepo 첫 앱 이름 — describe_template 와 시그니처 1:1 일치 (v0.96.0+).
|
|
487
|
+
// standalone 일 땐 무시 (프로젝트 루트가 곧 앱). 미지정 시 'web'.
|
|
488
|
+
appName: z.string().min(1).optional()
|
|
489
|
+
.describe(
|
|
490
|
+
"monorepo 첫 앱 이름. structure=monorepo 일 때만 의미 — apps/{appName}/ + packages/ui/ui-apps/ui-{appName}/ 동시 생성. " +
|
|
491
|
+
"기본 'web'. describe_template 의 동일 인자와 1:1 대응 (v0.96.0+).",
|
|
492
|
+
),
|
|
493
|
+
port: z.string().optional()
|
|
494
|
+
.describe("monorepo 첫 앱의 dev 포트. 기본 '3000'. structure=monorepo 일 때만 의미."),
|
|
424
495
|
},
|
|
425
496
|
},
|
|
426
497
|
async (input) => {
|
|
@@ -498,6 +569,8 @@ export async function startMcpServer() {
|
|
|
498
569
|
i18n: input.i18n,
|
|
499
570
|
locales: input.locales,
|
|
500
571
|
observability: input.observability,
|
|
572
|
+
appName: input.appName,
|
|
573
|
+
port: input.port,
|
|
501
574
|
yes: true, // 사전 검사를 마쳤으니 generator 의 confirm 프롬프트 우회
|
|
502
575
|
}),
|
|
503
576
|
);
|
|
@@ -942,6 +1015,16 @@ export async function startMcpServer() {
|
|
|
942
1015
|
.describe("CSS 프레임워크. 기본 plain. css-modules 면 page.module.css 등 추가"),
|
|
943
1016
|
appName: z.string().optional()
|
|
944
1017
|
.describe("monorepo 첫 앱 이름. 기본 web"),
|
|
1018
|
+
// vite 전용 — sh_ui_create_project 의 동일 옵션과 1:1 대응. describe ↔ create 가 같은
|
|
1019
|
+
// file-plan 을 보장하려면 여기서 받아 describeTemplate 에 전달해야 한다 (v0.95.0+).
|
|
1020
|
+
tauri: z.boolean().optional()
|
|
1021
|
+
.describe("Tauri 2.x 데스크탑 셸 emit (platform=vite 전용). 기본 false"),
|
|
1022
|
+
i18n: z.enum(I18N_LIBRARIES).optional()
|
|
1023
|
+
.describe(`i18n 라이브러리 (platform=vite 전용). 옵션: ${I18N_LIBRARIES.join(', ')}. 기본 none`),
|
|
1024
|
+
locales: z.string().optional()
|
|
1025
|
+
.describe(`i18n 활성화 시 locale 코드 (comma-separated, 예: "ko,en"). 기본 "${I18N_DEFAULT_LOCALES}"`),
|
|
1026
|
+
observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
|
|
1027
|
+
.describe(`observability 백엔드 (platform=vite 전용). 옵션: ${OBSERVABILITY_PROVIDERS.join(', ')}. 기본 none`),
|
|
945
1028
|
},
|
|
946
1029
|
},
|
|
947
1030
|
async (input) => {
|
|
@@ -953,6 +1036,10 @@ export async function startMcpServer() {
|
|
|
953
1036
|
plugins: input.plugins,
|
|
954
1037
|
cssFramework: input.cssFramework,
|
|
955
1038
|
appName: input.appName,
|
|
1039
|
+
tauri: input.tauri,
|
|
1040
|
+
i18n: input.i18n,
|
|
1041
|
+
locales: input.locales,
|
|
1042
|
+
observability: input.observability,
|
|
956
1043
|
});
|
|
957
1044
|
return jsonResult(result);
|
|
958
1045
|
} catch (e) {
|