sh-ui-cli 0.109.1 → 0.110.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 +12 -0
- package/package.json +1 -1
- package/src/create/cli-args.js +3 -1
- package/src/create/describeTemplate.js +9 -7
- package/src/create/generator.js +56 -21
- package/src/create/index.mjs +6 -0
- package/src/create/templateManifest.js +1 -1
- package/src/mcp.mjs +21 -3
- package/templates/monorepo/npmrc +5 -0
|
@@ -2,6 +2,18 @@
|
|
|
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.110.0",
|
|
7
|
+
"date": "2026-05-22",
|
|
8
|
+
"title": "create — inPlace 비파괴 머지 + .npmrc 템플릿 parity",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`inPlace` 옵션 신규** — `sh_ui_create_project` 의 `inPlace` 인자 / `sh-ui create --in-place`. 기존 디렉토리를 삭제하지 않고 비파괴 머지 — 이미 있는 파일은 보존하고 없는 파일만 채운다. 커스터마이즈된 monorepo 루트(`CLAUDE.md`·`.gitignore` 등)와 `.git` 을 지키며 `apps/`·`packages/` 를 재생성할 때 사용. tmpdir 에 스캐폴드 후 `overwrite:false` 머지로 구현 — 전체 generator 파이프라인은 그대로, 마지막 머지만 비파괴.",
|
|
12
|
+
"**`.npmrc` 템플릿 parity 수정** — npm publish 가 패키지 tarball 에서 `.npmrc` 를 항상 strip(레지스트리 토큰 유출 방지)해, published CLI 의 monorepo 스캐폴드에 `.npmrc` 가 누락되던 버그. `describe_template` 은 정적 매니페스트 기준이라 계속 `.npmrc` 를 보고 → dry-run ↔ 실제 산출물 불일치. `gitignore` 와 동일 패턴으로 템플릿을 `npmrc`(점 없음)로 두고 `finalizeProject` 가 `.npmrc` 로 복원하도록 수정 (`STRIPPED_DOTFILES` 맵으로 일반화).",
|
|
13
|
+
"**CLI `--git-init`/`--locale` 전달 버그 수정** — 두 플래그를 파싱·검증만 하고 `createProject` 로 넘기지 않아 무시되던 문제. `--in-place` 와 함께 연결."
|
|
14
|
+
],
|
|
15
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.110.0"
|
|
16
|
+
},
|
|
5
17
|
{
|
|
6
18
|
"version": "0.109.1",
|
|
7
19
|
"date": "2026-05-22",
|
package/package.json
CHANGED
package/src/create/cli-args.js
CHANGED
|
@@ -18,7 +18,7 @@ const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css',
|
|
|
18
18
|
// `locale` (단수) 은 한국어/일본어 같은 사용자 지역 디폴트 가정 (폰트 등) 을 활성화하는
|
|
19
19
|
// 옵션. `locales` (복수) 와 다르다: locales 는 i18n 활성화 시 생성할 locale 코드 목록.
|
|
20
20
|
const VALID_LOCALES = ['default', 'ko'];
|
|
21
|
-
const BOOL_FLAGS = ['yes', 'help', 'dry-run', 'no-git-init', 'git-init'];
|
|
21
|
+
const BOOL_FLAGS = ['yes', 'help', 'dry-run', 'no-git-init', 'git-init', 'in-place'];
|
|
22
22
|
|
|
23
23
|
const SUBCOMMANDS = ['add-app', 'add-component'];
|
|
24
24
|
|
|
@@ -45,9 +45,11 @@ export const parseArgs = (argv) => {
|
|
|
45
45
|
const name = arg.slice(2);
|
|
46
46
|
if (BOOL_FLAGS.includes(name)) {
|
|
47
47
|
// dry-run → dryRun. --no-git-init → gitInit:false. --git-init → gitInit:true.
|
|
48
|
+
// --in-place → inPlace:true (기존 디렉토리 비파괴 머지).
|
|
48
49
|
if (name === 'dry-run') flags.dryRun = true;
|
|
49
50
|
else if (name === 'no-git-init') flags.gitInit = false;
|
|
50
51
|
else if (name === 'git-init') flags.gitInit = true;
|
|
52
|
+
else if (name === 'in-place') flags.inPlace = true;
|
|
51
53
|
else flags[name] = true;
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
@@ -375,18 +375,20 @@ function finalize(groups) {
|
|
|
375
375
|
* generator.js 의 `finalizeProject` 가 실제 fs 단계에서 적용하는 rename 을
|
|
376
376
|
* file plan 텍스트 레벨에서 mock-apply (v0.96.0+ — 피드백 #3 buglet).
|
|
377
377
|
*
|
|
378
|
-
* 현재
|
|
379
|
-
* -
|
|
380
|
-
*
|
|
381
|
-
* - 이미
|
|
378
|
+
* 현재 규칙 — npm publish 가 strip 하는 dotfile 의 점 없는 템플릿 이름을 복원:
|
|
379
|
+
* - 'gitignore' → '.gitignore' (npm 이 strip, generator 의 STRIPPED_DOTFILES 와 일치)
|
|
380
|
+
* - 'npmrc' → '.npmrc' (npm 이 publish 시 항상 strip)
|
|
381
|
+
* - 이미 점 붙은 경로는 그대로.
|
|
382
382
|
*
|
|
383
|
-
* 미래에 다른 fs-level rename 이 추가되면
|
|
384
|
-
* file-plan ↔ create_project
|
|
383
|
+
* 미래에 다른 fs-level rename 이 추가되면 generator.js 의 STRIPPED_DOTFILES 와
|
|
384
|
+
* 함께 여기에 등록 (describeTemplate 의 file-plan ↔ create_project 실제 emit 1:1 정합성).
|
|
385
385
|
*/
|
|
386
|
+
const STRIPPED_DOTFILES = { gitignore: '.gitignore', npmrc: '.npmrc' };
|
|
387
|
+
|
|
386
388
|
function applyFinalizeRenames(p) {
|
|
387
389
|
const slash = p.lastIndexOf('/');
|
|
388
390
|
const dir = slash === -1 ? '' : p.slice(0, slash + 1);
|
|
389
391
|
const base = slash === -1 ? p : p.slice(slash + 1);
|
|
390
|
-
if (base
|
|
392
|
+
if (STRIPPED_DOTFILES[base]) return dir + STRIPPED_DOTFILES[base];
|
|
391
393
|
return p;
|
|
392
394
|
}
|
package/src/create/generator.js
CHANGED
|
@@ -290,19 +290,28 @@ export async function createProject(options = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
293
|
+
// inPlace — 이미 커스터마이즈된 디렉토리(루트 docs·git 보존)에 비파괴 머지.
|
|
294
|
+
// generation 은 dry-run 과 동일하게 tmpdir 에서 돌린 뒤, 이미 있는 파일은
|
|
295
|
+
// 건드리지 않고 새 파일만 realTargetDir 로 복사한다.
|
|
296
|
+
const inPlace = options.inPlace === true && !options.dryRun;
|
|
297
|
+
const realTargetDir = path.resolve(process.cwd(), projectName);
|
|
298
|
+
|
|
299
|
+
// dry-run / inPlace 는 tmpdir 에 생성. dry-run 은 목록 출력 후 정리,
|
|
300
|
+
// inPlace 는 realTargetDir 로 비파괴 머지. 일반 모드는 cwd/name 에 직접 생성.
|
|
295
301
|
const targetDir = options.dryRun
|
|
296
302
|
? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-dry-'))
|
|
297
|
-
:
|
|
303
|
+
: inPlace
|
|
304
|
+
? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-inplace-'))
|
|
305
|
+
: realTargetDir;
|
|
298
306
|
|
|
299
|
-
// 방어 가드 — projectName 검증을 이미 통과했어도
|
|
300
|
-
// dry-run
|
|
307
|
+
// 방어 가드 — projectName 검증을 이미 통과했어도 fs 쓰기 직전에 한 번 더 확인.
|
|
308
|
+
// dry-run / inPlace 의 targetDir 는 tmpdir 이므로 실제 목적지를 검증한다.
|
|
301
309
|
if (!options.dryRun) {
|
|
302
|
-
assertWithin(process.cwd(),
|
|
310
|
+
assertWithin(process.cwd(), realTargetDir);
|
|
303
311
|
}
|
|
304
312
|
|
|
305
|
-
|
|
313
|
+
// 일반 모드만 기존 디렉토리 덮어쓰기 확인. inPlace 는 비파괴라 remove 하지 않는다.
|
|
314
|
+
if (!options.dryRun && !inPlace && await fs.pathExists(targetDir)) {
|
|
306
315
|
if (options.yes) {
|
|
307
316
|
await fs.remove(targetDir);
|
|
308
317
|
} else {
|
|
@@ -318,9 +327,29 @@ export async function createProject(options = {}) {
|
|
|
318
327
|
}
|
|
319
328
|
}
|
|
320
329
|
|
|
330
|
+
// finalizeProject + (inPlace 면) temp → realTargetDir 비파괴 머지.
|
|
331
|
+
// 세 플랫폼 경로(flutter/vite/next)가 공통으로 호출한다.
|
|
332
|
+
async function finalizeAndCommit() {
|
|
333
|
+
await finalizeProject(targetDir, {
|
|
334
|
+
dryRun: options.dryRun,
|
|
335
|
+
// inPlace 는 temp 에서 git init 해봐야 버려지므로 스킵 — realTargetDir 의
|
|
336
|
+
// 기존 .git 을 그대로 둔다 (gitignore/npmrc 복원은 finalize 안에서 수행됨).
|
|
337
|
+
gitInit: inPlace ? false : options.gitInit,
|
|
338
|
+
locale: options.locale,
|
|
339
|
+
});
|
|
340
|
+
if (inPlace) {
|
|
341
|
+
await fs.ensureDir(realTargetDir);
|
|
342
|
+
await fs.copy(targetDir, realTargetDir, { overwrite: false, errorOnExist: false });
|
|
343
|
+
await fs.remove(targetDir);
|
|
344
|
+
console.log(
|
|
345
|
+
`\n ℹ inPlace — 기존 디렉토리에 비파괴 머지 완료 (이미 있는 파일은 보존).`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
321
350
|
if (platform === 'flutter') {
|
|
322
351
|
await generateFlutter(targetDir, projectName, theme, cssFramework, themeBase);
|
|
323
|
-
await
|
|
352
|
+
await finalizeAndCommit();
|
|
324
353
|
console.log(`\n✅ ${projectName} Flutter 프로젝트가 생성되었습니다!`);
|
|
325
354
|
console.log(`\n cd ${projectName}`);
|
|
326
355
|
console.log(' flutter pub get');
|
|
@@ -355,7 +384,7 @@ export async function createProject(options = {}) {
|
|
|
355
384
|
});
|
|
356
385
|
}
|
|
357
386
|
|
|
358
|
-
await
|
|
387
|
+
await finalizeAndCommit();
|
|
359
388
|
|
|
360
389
|
if (options.dryRun) {
|
|
361
390
|
const files = await listAllFiles(targetDir);
|
|
@@ -401,7 +430,7 @@ export async function createProject(options = {}) {
|
|
|
401
430
|
});
|
|
402
431
|
}
|
|
403
432
|
|
|
404
|
-
await
|
|
433
|
+
await finalizeAndCommit();
|
|
405
434
|
|
|
406
435
|
if (options.dryRun) {
|
|
407
436
|
const files = await listAllFiles(targetDir);
|
|
@@ -1644,11 +1673,17 @@ async function stripTailwindFromPrettier(prettierPath) {
|
|
|
1644
1673
|
}
|
|
1645
1674
|
|
|
1646
1675
|
/**
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
* strip
|
|
1651
|
-
* 템플릿엔 `gitignore`
|
|
1676
|
+
* npm publish 가 패키지 tarball 에서 strip/변형하는 dotfile 들 — 템플릿엔 점 없는
|
|
1677
|
+
* 이름으로 두고 스캐폴드 직후 점을 복원한다.
|
|
1678
|
+
* - `.gitignore` → npm 이 strip (없으면 `.npmignore` fallback 으로 사용)
|
|
1679
|
+
* - `.npmrc` → npm 이 항상 strip (publish 시 레지스트리 토큰 유출 방지)
|
|
1680
|
+
* 둘 다 published CLI 엔 도착하지 않으므로 템플릿엔 `gitignore` / `npmrc` 로 둔다.
|
|
1681
|
+
*/
|
|
1682
|
+
const STRIPPED_DOTFILES = { gitignore: '.gitignore', npmrc: '.npmrc' };
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* 스캐폴드 마무리 — strip 된 dotfile(`gitignore`/`npmrc`) 을 점 붙은 이름으로
|
|
1686
|
+
* 되돌리고 `git init` 실행.
|
|
1652
1687
|
*
|
|
1653
1688
|
* gitInit 옵션:
|
|
1654
1689
|
* - undefined (auto): parent 가 이미 git tree 안이면 스킵, 아니면 init. nested .git
|
|
@@ -1659,9 +1694,9 @@ async function stripTailwindFromPrettier(prettierPath) {
|
|
|
1659
1694
|
* git init 은 dry-run 에서는 스킵하고, 실패해도(git 미설치 등) 조용히 넘어간다.
|
|
1660
1695
|
*/
|
|
1661
1696
|
async function finalizeProject(targetDir, { dryRun = false, gitInit, locale } = {}) {
|
|
1662
|
-
// 모노레포 / sub-app 까지
|
|
1697
|
+
// 모노레포 / sub-app 까지 strip 된 dotfile(gitignore/npmrc) 을 점 붙은 이름으로 복원.
|
|
1663
1698
|
// root 만 처리하면 apps/<name>/gitignore 가 그대로 남아 node_modules/dist 가 staged 된다 (v0.93.0 버그).
|
|
1664
|
-
await
|
|
1699
|
+
await restoreStrippedDotfilesRecursive(targetDir);
|
|
1665
1700
|
|
|
1666
1701
|
// locale 후처리 — 한국어면 globals.css 들에 Pretendard 자동 적용 (Aifice 피드백 3.1).
|
|
1667
1702
|
// dryRun 이면 skip — globals.css 가 디스크에 안 써졌을 수 있다.
|
|
@@ -1790,7 +1825,7 @@ function describeAppPlatform(platform) {
|
|
|
1790
1825
|
return 'Next.js 앱 (App Router + Server Components). 라우트 + 비즈니스 로직.';
|
|
1791
1826
|
}
|
|
1792
1827
|
|
|
1793
|
-
async function
|
|
1828
|
+
async function restoreStrippedDotfilesRecursive(dir) {
|
|
1794
1829
|
let entries;
|
|
1795
1830
|
try {
|
|
1796
1831
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -1802,9 +1837,9 @@ async function renameAllGitignoreRecursive(dir) {
|
|
|
1802
1837
|
if (entry.isDirectory()) {
|
|
1803
1838
|
// 스캐폴드 직후엔 node_modules / .git 가 없지만 방어적으로.
|
|
1804
1839
|
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
1805
|
-
await
|
|
1806
|
-
} else if (entry.name
|
|
1807
|
-
await fs.move(fullPath, path.join(dir,
|
|
1840
|
+
await restoreStrippedDotfilesRecursive(fullPath);
|
|
1841
|
+
} else if (STRIPPED_DOTFILES[entry.name]) {
|
|
1842
|
+
await fs.move(fullPath, path.join(dir, STRIPPED_DOTFILES[entry.name]), { overwrite: true });
|
|
1808
1843
|
}
|
|
1809
1844
|
}
|
|
1810
1845
|
}
|
package/src/create/index.mjs
CHANGED
|
@@ -32,6 +32,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
32
32
|
--locales <ko,en> i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en'
|
|
33
33
|
--locale <default|ko> 사용자 지역 디폴트 가정 — 'ko' 선택 시 globals.css 에 Pretendard 자동 적용 (v0.103.0+)
|
|
34
34
|
--yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
|
|
35
|
+
--in-place 기존 디렉토리에 비파괴 머지 — 이미 있는 파일은 보존, 없는 파일만 채움. 커스텀 루트 docs·.git 보존하며 재생성 (디렉토리 삭제 안 함)
|
|
35
36
|
--dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
|
|
36
37
|
--no-git-init git init 스킵 (기존 git tree 안에서 호출 시 nested .git 충돌 방지). 기본 auto — parent 가 git tree 안이면 자동 스킵
|
|
37
38
|
--git-init git init 무조건 실행 (nested 가 의도된 경우)
|
|
@@ -105,6 +106,11 @@ export async function runCreate(rest) {
|
|
|
105
106
|
port: flags.port,
|
|
106
107
|
yes: flags.yes,
|
|
107
108
|
dryRun: flags.dryRun,
|
|
109
|
+
// gitInit / locale / inPlace — 이전엔 파싱만 하고 createProject 로
|
|
110
|
+
// 전달하지 않아 무시되던 버그. v0.110.0 에서 함께 연결.
|
|
111
|
+
gitInit: flags.gitInit,
|
|
112
|
+
locale: flags.locale,
|
|
113
|
+
inPlace: flags.inPlace,
|
|
108
114
|
});
|
|
109
115
|
}
|
|
110
116
|
}
|
|
@@ -17,11 +17,11 @@ export const TEMPLATE_MANIFEST = {
|
|
|
17
17
|
"monorepo": {
|
|
18
18
|
"base": [
|
|
19
19
|
".dockerignore",
|
|
20
|
-
".npmrc",
|
|
21
20
|
".prettierrc",
|
|
22
21
|
"CLAUDE.md",
|
|
23
22
|
"README.md",
|
|
24
23
|
"gitignore",
|
|
24
|
+
"npmrc",
|
|
25
25
|
"package.json",
|
|
26
26
|
"packages/eslint-config/base.js",
|
|
27
27
|
"packages/eslint-config/flat.js",
|
package/src/mcp.mjs
CHANGED
|
@@ -488,7 +488,14 @@ export async function startMcpServer() {
|
|
|
488
488
|
cwd: z.string().optional()
|
|
489
489
|
.describe("부모 디렉토리. 기본 process.cwd()"),
|
|
490
490
|
force: z.boolean().optional()
|
|
491
|
-
.describe("기존 디렉토리
|
|
491
|
+
.describe("기존 디렉토리 덮어쓰기 — 디렉토리 전체를 삭제 후 새로 스캐폴드. 기본 false (안전)."),
|
|
492
|
+
inPlace: z.boolean().optional()
|
|
493
|
+
.describe(
|
|
494
|
+
"기존 디렉토리에 비파괴 머지 — 디렉토리를 삭제하지 않고, 이미 있는 파일은 보존한 채 " +
|
|
495
|
+
"없는 파일만 채워 넣는다. 커스터마이즈된 monorepo 루트(CLAUDE.md·.gitignore 등)·.git 을 " +
|
|
496
|
+
"지키면서 apps/·packages/ 를 재생성할 때 사용. force 와 상호배타 — force 는 전체 삭제, " +
|
|
497
|
+
"inPlace 는 보존. 재생성하려는 파일은 미리 지워두면 그 자리만 새로 채워진다. v0.110.0+ 신규.",
|
|
498
|
+
),
|
|
492
499
|
i18n: z.enum(I18N_LIBRARIES).optional()
|
|
493
500
|
.describe(
|
|
494
501
|
"i18n 라이브러리 — platform=vite 일 때만 의미. 'react-i18next' 로 설정 시 i18next + react-i18next + browser-languagedetector + http-backend 셋업 + " +
|
|
@@ -549,15 +556,25 @@ export async function startMcpServer() {
|
|
|
549
556
|
}],
|
|
550
557
|
};
|
|
551
558
|
}
|
|
559
|
+
if (input.force && input.inPlace) {
|
|
560
|
+
return {
|
|
561
|
+
isError: true,
|
|
562
|
+
content: [{
|
|
563
|
+
type: "text",
|
|
564
|
+
text: "force 와 inPlace 는 동시에 쓸 수 없습니다 — force 는 디렉토리 전체 삭제, inPlace 는 보존 머지.",
|
|
565
|
+
}],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
552
568
|
const targetParent = resolveCwd(input);
|
|
553
569
|
const targetDir = resolve(targetParent, input.name);
|
|
554
|
-
|
|
570
|
+
// inPlace 는 기존 디렉토리를 전제로 하므로 '이미 존재' 가 에러가 아니다.
|
|
571
|
+
if (existsSync(targetDir) && !input.force && !input.inPlace) {
|
|
555
572
|
return {
|
|
556
573
|
isError: true,
|
|
557
574
|
content: [
|
|
558
575
|
{
|
|
559
576
|
type: "text",
|
|
560
|
-
text: `'${targetDir}' 가 이미 존재합니다. 덮어쓰려면 force: true.`,
|
|
577
|
+
text: `'${targetDir}' 가 이미 존재합니다. 덮어쓰려면 force: true, 보존 머지하려면 inPlace: true.`,
|
|
561
578
|
},
|
|
562
579
|
],
|
|
563
580
|
};
|
|
@@ -580,6 +597,7 @@ export async function startMcpServer() {
|
|
|
580
597
|
port: input.port,
|
|
581
598
|
gitInit: input.gitInit,
|
|
582
599
|
locale: input.locale,
|
|
600
|
+
inPlace: input.inPlace,
|
|
583
601
|
yes: true, // 사전 검사를 마쳤으니 generator 의 confirm 프롬프트 우회
|
|
584
602
|
}),
|
|
585
603
|
);
|