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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.109.1",
3
+ "version": "0.110.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- * - basename 이 정확히 'gitignore' 인 경로 → '.gitignore' prefix dot 추가.
380
- * (npm publish .gitignore strip 하므로 템플릿엔 점 없이 두고 emit 후 dot-prefix.)
381
- * - 이미 '.gitignore' 경로는 그대로.
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 이 추가되면 여기에 같이 등록 (describeTemplate 의
384
- * file-plan ↔ create_project 실제 emit 1:1 정합성 유지).
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 === 'gitignore') return dir + '.gitignore';
392
+ if (STRIPPED_DOTFILES[base]) return dir + STRIPPED_DOTFILES[base];
391
393
  return p;
392
394
  }
@@ -290,19 +290,28 @@ export async function createProject(options = {}) {
290
290
  }
291
291
  }
292
292
 
293
- // dry-run tmpdir 그대로 생성한 파일 목록 출력 + 정리.
294
- // 사용자 cwd 건드리지 않으면서 실제 generation 흐름을 그대로 검증한다.
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
- : path.resolve(process.cwd(), projectName);
303
+ : inPlace
304
+ ? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-inplace-'))
305
+ : realTargetDir;
298
306
 
299
- // 방어 가드 — projectName 검증을 이미 통과했어도 `fs.remove` 직전에 한 번 더 확인.
300
- // dry-run tmpdir 이라 parent cwd 아니므로 스킵.
307
+ // 방어 가드 — projectName 검증을 이미 통과했어도 fs 쓰기 직전에 한 번 더 확인.
308
+ // dry-run / inPlace targetDir tmpdir 이므로 실제 목적지를 검증한다.
301
309
  if (!options.dryRun) {
302
- assertWithin(process.cwd(), targetDir);
310
+ assertWithin(process.cwd(), realTargetDir);
303
311
  }
304
312
 
305
- if (!options.dryRun && await fs.pathExists(targetDir)) {
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 finalizeProject(targetDir, { dryRun: options.dryRun, gitInit: options.gitInit, locale: options.locale });
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 finalizeProject(targetDir, { dryRun: options.dryRun, gitInit: options.gitInit, locale: options.locale });
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 finalizeProject(targetDir, { dryRun: options.dryRun, gitInit: options.gitInit, locale: options.locale });
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
- * 스캐폴드 마무리 `gitignore` 파일을 `.gitignore` 되돌리고 `git init` 실행.
1648
- *
1649
- * 이름을 우회하는가: npm publish 패키지 안의 `.gitignore` 자동으로
1650
- * strip 한다(없으면 `.npmignore` fallback 으로 사용). 사용자에게 도착하지 않으니
1651
- * 템플릿엔 `gitignore` 두고 복사 직후 dot-prefix 를 붙인다.
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 까지 모든 `gitignore` `.gitignore` rename.
1697
+ // 모노레포 / sub-app 까지 strip 된 dotfile(gitignore/npmrc) 붙은 이름으로 복원.
1663
1698
  // root 만 처리하면 apps/<name>/gitignore 가 그대로 남아 node_modules/dist 가 staged 된다 (v0.93.0 버그).
1664
- await renameAllGitignoreRecursive(targetDir);
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 renameAllGitignoreRecursive(dir) {
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 renameAllGitignoreRecursive(fullPath);
1806
- } else if (entry.name === 'gitignore') {
1807
- await fs.move(fullPath, path.join(dir, '.gitignore'), { overwrite: true });
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
  }
@@ -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("기존 디렉토리 덮어쓰기. 기본 false (안전)"),
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
- if (existsSync(targetDir) && !input.force) {
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
  );
@@ -0,0 +1,5 @@
1
+ # pnpm 으로 워크스페이스 패키지의 peer dep 자동 설치 (host 가 아니라 패키지 자체).
2
+ auto-install-peers=true
3
+
4
+ # `pnpm install` 시 lock 의 strict-version 검사 활성. CI 와 로컬 일관성.
5
+ strict-peer-dependencies=false