sh-ui-cli 0.109.1 → 0.111.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,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.111.0",
7
+ "date": "2026-05-22",
8
+ "title": "theme — extraTokens 패스스루 + 모노레포 공유 theme",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`theme.extraTokens` 신규** — `sh-ui.config.json` 의 `theme.extraTokens: { root, light, dark }` 에 임의 토큰(`accent-soft`·`surface`·`cta-*`·`border-focus` 등)을 정의하면 tokens.css 와 `@theme inline` 양쪽에 자동 emit. TOKEN_KEYS/OPTIONAL_TOKEN_KEYS 의 고정 스키마를 벗어나는 디자인 시스템 확장을 config-driven 으로 — tokens.css 손-편집 후 `tokens upgrade`/재스캐폴드 시 날아가던 클로버 문제 해결. `root` 는 모드 무관 고정(CTA 같은 light/dark 공통), `light`/`dark` 는 모드별.",
12
+ "**모노레포 공유 theme** — `packages/ui/ui-core/sh-ui.config.json` 에 `theme` 블록을 두면 sibling `ui-apps/ui-*` 들이 묵시적 상속. ui-app 측 `theme` 가 있으면 deep-merge (app 키가 core 키 override). 같은 디자인 시스템을 쓰는 다중 앱이 토큰을 중복 정의하지 않음. `add_app` 가 ui-core 의 공유 theme 을 감지하면 새 ui-app 의 `theme` 블록을 자동으로 비워서 상속하게 만든다.",
13
+ "**`tokens upgrade` 가 공유 theme 반영** — `sh-ui tokens upgrade` 가 sibling ui-core 의 theme 을 머지한 effective config 로 buildTokens 호출 → ui-app 단독에 있는 토큰만 갖고 빌드하던 종전 동작 보강.",
14
+ "**Tailwind 유틸 자동 매핑** — `buildTokensCss` 의 `@theme inline` 블록이 extraTokens 키마다 `--color-<key>: var(--<key>)` 를 함께 emit → `bg-cta-bg`/`text-accent-soft-fg` 같은 Tailwind utility 가 별도 손-매핑 없이 즉시 사용 가능."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.111.0"
17
+ },
18
+ {
19
+ "version": "0.110.0",
20
+ "date": "2026-05-22",
21
+ "title": "create — inPlace 비파괴 머지 + .npmrc 템플릿 parity",
22
+ "type": "minor",
23
+ "highlights": [
24
+ "**`inPlace` 옵션 신규** — `sh_ui_create_project` 의 `inPlace` 인자 / `sh-ui create --in-place`. 기존 디렉토리를 삭제하지 않고 비파괴 머지 — 이미 있는 파일은 보존하고 없는 파일만 채운다. 커스터마이즈된 monorepo 루트(`CLAUDE.md`·`.gitignore` 등)와 `.git` 을 지키며 `apps/`·`packages/` 를 재생성할 때 사용. tmpdir 에 스캐폴드 후 `overwrite:false` 머지로 구현 — 전체 generator 파이프라인은 그대로, 마지막 머지만 비파괴.",
25
+ "**`.npmrc` 템플릿 parity 수정** — npm publish 가 패키지 tarball 에서 `.npmrc` 를 항상 strip(레지스트리 토큰 유출 방지)해, published CLI 의 monorepo 스캐폴드에 `.npmrc` 가 누락되던 버그. `describe_template` 은 정적 매니페스트 기준이라 계속 `.npmrc` 를 보고 → dry-run ↔ 실제 산출물 불일치. `gitignore` 와 동일 패턴으로 템플릿을 `npmrc`(점 없음)로 두고 `finalizeProject` 가 `.npmrc` 로 복원하도록 수정 (`STRIPPED_DOTFILES` 맵으로 일반화).",
26
+ "**CLI `--git-init`/`--locale` 전달 버그 수정** — 두 플래그를 파싱·검증만 하고 `createProject` 로 넘기지 않아 무시되던 문제. `--in-place` 와 함께 연결."
27
+ ],
28
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.110.0"
29
+ },
5
30
  {
6
31
  "version": "0.109.1",
7
32
  "date": "2026-05-22",
@@ -85,13 +85,22 @@ function toCssValue(entry) {
85
85
  return entry.value;
86
86
  }
87
87
 
88
- function emitCssBlock(selector, entries) {
89
- const lines = Object.entries(entries)
90
- .map(([p, entry]) => ` ${toCssVar(p)}: ${toCssValue(entry)};`)
91
- .join("\n");
88
+ function emitCssBlock(selector, entries, extraLines = []) {
89
+ const baseLines = Object.entries(entries)
90
+ .map(([p, entry]) => ` ${toCssVar(p)}: ${toCssValue(entry)};`);
91
+ const lines = [...baseLines, ...extraLines].join("\n");
92
92
  return `${selector} {\n${lines}\n}`;
93
93
  }
94
94
 
95
+ /** extraTokens 카테고리(plain {key: value}) → CSS 라인 배열. key 가 '--' 로 시작하면 그대로. */
96
+ function extraTokensToLines(map) {
97
+ if (!map || typeof map !== "object") return [];
98
+ return Object.entries(map).map(([rawKey, value]) => {
99
+ const key = rawKey.startsWith("--") ? rawKey.slice(2) : rawKey;
100
+ return ` --${key}: ${value};`;
101
+ });
102
+ }
103
+
95
104
  /**
96
105
  * `prefers-color-scheme: dark` 자동 적용 블록.
97
106
  *
@@ -105,10 +114,12 @@ function emitCssBlock(selector, entries) {
105
114
  * .light 클래스 → :root 만 적용 (강제 라이트, OS 무관)
106
115
  * .dark 클래스 → .dark 블록이 마지막에 와서 항상 승리 (강제 다크)
107
116
  */
108
- function emitAutoDarkBlock(darkEntries) {
109
- const lines = Object.entries(darkEntries)
110
- .map(([p, entry]) => ` ${toCssVar(p)}: ${toCssValue(entry)};`)
111
- .join("\n");
117
+ function emitAutoDarkBlock(darkEntries, extraLines = []) {
118
+ const baseLines = Object.entries(darkEntries)
119
+ .map(([p, entry]) => ` ${toCssVar(p)}: ${toCssValue(entry)};`);
120
+ // 미디어쿼리 안쪽은 한 단계 더 들여쓰기 — extraTokens 라인도 같은 들여쓰기 적용.
121
+ const indentedExtras = extraLines.map((line) => ` ${line}`);
122
+ const lines = [...baseLines, ...indentedExtras].join("\n");
112
123
  return `@media (prefers-color-scheme: dark) {\n :root:not(.light):not(.dark) {\n${lines}\n }\n}`;
113
124
  }
114
125
 
@@ -131,13 +142,28 @@ function mergeThemeIndependent(tokens) {
131
142
  * radius 는 단일 토큰 (--radius) 이지만 Tailwind 의 rounded-{sm,md,lg,xl}
132
143
  * 4 단계로 expand. 템플릿이 손으로 박아두던 패턴을 단일 소스로 옮긴 것.
133
144
  */
134
- function buildTailwindThemeBlock(lightTokens) {
145
+ function buildTailwindThemeBlock(lightTokens, extraTokens) {
135
146
  const lines = [];
136
147
  for (const path of Object.keys(lightTokens)) {
137
148
  const cssVar = toCssVar(path);
138
149
  const themeKey = cssVar.replace(/^--/, "--color-");
139
150
  lines.push(` ${themeKey}: var(${cssVar});`);
140
151
  }
152
+ // extraTokens (v0.111.0+) → Tailwind utility 자동 생성용 매핑.
153
+ // root/light/dark 의 모든 키 합집합 — 각각 한 번씩만 --color-* 매핑 (var 가 모드별로 해석되므로).
154
+ if (extraTokens && typeof extraTokens === "object") {
155
+ const seen = new Set();
156
+ for (const cat of ["root", "light", "dark"]) {
157
+ const map = extraTokens[cat];
158
+ if (!map || typeof map !== "object") continue;
159
+ for (const rawKey of Object.keys(map)) {
160
+ const key = rawKey.startsWith("--") ? rawKey.slice(2) : rawKey;
161
+ if (seen.has(key)) continue;
162
+ seen.add(key);
163
+ lines.push(` --color-${key}: var(--${key});`);
164
+ }
165
+ }
166
+ }
141
167
  lines.push(` --radius-sm: calc(var(--radius) - 2px);`);
142
168
  lines.push(` --radius-md: var(--radius);`);
143
169
  lines.push(` --radius-lg: calc(var(--radius) + 2px);`);
@@ -149,18 +175,27 @@ export async function buildTokensCss(config) {
149
175
  const tokens = await resolveTokens(config);
150
176
  const mode = config.theme.mode;
151
177
  const themeIndep = mergeThemeIndependent(tokens);
178
+
179
+ // extraTokens (v0.111.0+) — TOKEN_KEYS/OPTIONAL_TOKEN_KEYS 에 없는 자유 추가 토큰.
180
+ // root: 모드 무관 — light :root 에 emit (dark 블록이 덮어쓰지 않아 자동 cascade).
181
+ // light: light :root 에. dark: dark 블록 양쪽 (@media + .dark) 에.
182
+ const extra = config.theme?.extraTokens ?? {};
183
+ const extraRoot = extraTokensToLines(extra.root);
184
+ const extraLight = extraTokensToLines(extra.light);
185
+ const extraDark = extraTokensToLines(extra.dark);
186
+
152
187
  const blocks = [];
153
188
  if (mode === "light" || mode === "light-dark") {
154
- blocks.push(emitCssBlock(":root", { ...tokens.light, ...themeIndep }));
189
+ blocks.push(emitCssBlock(":root", { ...tokens.light, ...themeIndep }, [...extraLight, ...extraRoot]));
155
190
  }
156
191
  if (mode === "dark") {
157
- blocks.push(emitCssBlock(":root", { ...tokens.dark, ...themeIndep }));
192
+ blocks.push(emitCssBlock(":root", { ...tokens.dark, ...themeIndep }, [...extraDark, ...extraRoot]));
158
193
  }
159
194
  if (mode === "light-dark") {
160
- blocks.push(emitAutoDarkBlock(tokens.dark));
161
- blocks.push(emitCssBlock(".dark", tokens.dark));
195
+ blocks.push(emitAutoDarkBlock(tokens.dark, extraDark));
196
+ blocks.push(emitCssBlock(".dark", tokens.dark, extraDark));
162
197
  }
163
- blocks.push(buildTailwindThemeBlock(tokens.light));
198
+ blocks.push(buildTailwindThemeBlock(tokens.light, extra));
164
199
  const header = `/* Generated by @sh-ui/tokens — do not edit directly */\n/* base=${config.theme.base} radius=${config.theme.radius} mode=${config.theme.mode} */\n`;
165
200
  return header + "\n" + blocks.join("\n\n") + "\n";
166
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.109.1",
3
+ "version": "0.111.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
package/sh-ui.schema.json CHANGED
@@ -46,6 +46,16 @@
46
46
  "mode": {
47
47
  "enum": ["light", "dark", "light-dark"],
48
48
  "description": "다크 모드 전략. light-dark = OS 자동 + .light/.dark 클래스 토글, light/dark = 강제 단일 모드."
49
+ },
50
+ "extraTokens": {
51
+ "type": "object",
52
+ "description": "v0.111.0+ 신규. 디자인 시스템 자유 추가 토큰 — TOKEN_KEYS/OPTIONAL_TOKEN_KEYS 에 없는 임의 키(`accent-soft`, `surface`, `cta-bg` 등) 를 tokens.css 와 globals.css `@theme` 에 자동 emit. root=모드 무관 고정, light/dark=모드별. 모노레포에서 ui-core 의 theme 에 두면 모든 ui-app 이 상속 (app 측 override 시 deep-merge).",
53
+ "additionalProperties": false,
54
+ "properties": {
55
+ "root": { "type": "object", "additionalProperties": { "type": "string" }, "description": "모드 무관 고정 토큰 (예: CTA 밴드 색 — light/dark 동일)." },
56
+ "light": { "type": "object", "additionalProperties": { "type": "string" }, "description": "라이트 모드 토큰." },
57
+ "dark": { "type": "object", "additionalProperties": { "type": "string" }, "description": "다크 모드 토큰." }
58
+ }
49
59
  }
50
60
  }
51
61
  },
@@ -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);
@@ -575,6 +604,23 @@ export async function addApp(options = {}) {
575
604
  }
576
605
  await patchShUiConfig(path.join(uiAppDir, 'sh-ui.config.json'), css, themeBase);
577
606
 
607
+ // v0.111.0+ — ui-core 가 공유 theme 을 호스팅하고 사용자가 --theme 미지정 시,
608
+ // 새 ui-app 의 theme 블록을 제거해 ui-core 로부터 묵시적 상속. 같은 디자인 시스템을
609
+ // 쓰는 모노레포 다중 앱이 토큰을 중복 정의하지 않도록.
610
+ if (!options.theme) {
611
+ const uiAppConfigPath = path.join(uiAppDir, 'sh-ui.config.json');
612
+ const uiCoreConfigPath = path.resolve(cwd, 'packages', 'ui', 'ui-core', 'sh-ui.config.json');
613
+ if (await fs.pathExists(uiCoreConfigPath)) {
614
+ const coreCfg = await fs.readJson(uiCoreConfigPath);
615
+ if (coreCfg.theme && (coreCfg.theme.extraTokens || coreCfg.theme.base || coreCfg.theme.radius || coreCfg.theme.mode)) {
616
+ const appCfg = await fs.readJson(uiAppConfigPath);
617
+ delete appCfg.theme;
618
+ await fs.writeJson(uiAppConfigPath, appCfg, { spaces: 2 });
619
+ console.log(`\n ℹ ui-core 가 공유 theme 을 호스팅 — 새 ui-app 은 theme 블록을 두지 않고 상속.`);
620
+ }
621
+ }
622
+ }
623
+
578
624
  console.log(`\n✅ apps/${appName} 이 추가되었습니다!`);
579
625
  console.log('\n pnpm install');
580
626
  console.log(` pnpm --filter ${appName} dev\n`);
@@ -1644,11 +1690,17 @@ async function stripTailwindFromPrettier(prettierPath) {
1644
1690
  }
1645
1691
 
1646
1692
  /**
1647
- * 스캐폴드 마무리 `gitignore` 파일을 `.gitignore` 되돌리고 `git init` 실행.
1648
- *
1649
- * 이름을 우회하는가: npm publish 패키지 안의 `.gitignore` 자동으로
1650
- * strip 한다(없으면 `.npmignore` fallback 으로 사용). 사용자에게 도착하지 않으니
1651
- * 템플릿엔 `gitignore` 두고 복사 직후 dot-prefix 를 붙인다.
1693
+ * npm publish 패키지 tarball 에서 strip/변형하는 dotfile 템플릿엔 점 없는
1694
+ * 이름으로 두고 스캐폴드 직후 점을 복원한다.
1695
+ * - `.gitignore` npm strip (없으면 `.npmignore` fallback 으로 사용)
1696
+ * - `.npmrc` → npm 이 항상 strip (publish 레지스트리 토큰 유출 방지)
1697
+ * 둘 다 published CLI 엔 도착하지 않으므로 템플릿엔 `gitignore` / `npmrc` 둔다.
1698
+ */
1699
+ const STRIPPED_DOTFILES = { gitignore: '.gitignore', npmrc: '.npmrc' };
1700
+
1701
+ /**
1702
+ * 스캐폴드 마무리 — strip 된 dotfile(`gitignore`/`npmrc`) 을 점 붙은 이름으로
1703
+ * 되돌리고 `git init` 실행.
1652
1704
  *
1653
1705
  * gitInit 옵션:
1654
1706
  * - undefined (auto): parent 가 이미 git tree 안이면 스킵, 아니면 init. nested .git
@@ -1659,9 +1711,9 @@ async function stripTailwindFromPrettier(prettierPath) {
1659
1711
  * git init 은 dry-run 에서는 스킵하고, 실패해도(git 미설치 등) 조용히 넘어간다.
1660
1712
  */
1661
1713
  async function finalizeProject(targetDir, { dryRun = false, gitInit, locale } = {}) {
1662
- // 모노레포 / sub-app 까지 모든 `gitignore` `.gitignore` rename.
1714
+ // 모노레포 / sub-app 까지 strip 된 dotfile(gitignore/npmrc) 붙은 이름으로 복원.
1663
1715
  // root 만 처리하면 apps/<name>/gitignore 가 그대로 남아 node_modules/dist 가 staged 된다 (v0.93.0 버그).
1664
- await renameAllGitignoreRecursive(targetDir);
1716
+ await restoreStrippedDotfilesRecursive(targetDir);
1665
1717
 
1666
1718
  // locale 후처리 — 한국어면 globals.css 들에 Pretendard 자동 적용 (Aifice 피드백 3.1).
1667
1719
  // dryRun 이면 skip — globals.css 가 디스크에 안 써졌을 수 있다.
@@ -1790,7 +1842,7 @@ function describeAppPlatform(platform) {
1790
1842
  return 'Next.js 앱 (App Router + Server Components). 라우트 + 비즈니스 로직.';
1791
1843
  }
1792
1844
 
1793
- async function renameAllGitignoreRecursive(dir) {
1845
+ async function restoreStrippedDotfilesRecursive(dir) {
1794
1846
  let entries;
1795
1847
  try {
1796
1848
  entries = await fs.readdir(dir, { withFileTypes: true });
@@ -1802,9 +1854,9 @@ async function renameAllGitignoreRecursive(dir) {
1802
1854
  if (entry.isDirectory()) {
1803
1855
  // 스캐폴드 직후엔 node_modules / .git 가 없지만 방어적으로.
1804
1856
  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 });
1857
+ await restoreStrippedDotfilesRecursive(fullPath);
1858
+ } else if (STRIPPED_DOTFILES[entry.name]) {
1859
+ await fs.move(fullPath, path.join(dir, STRIPPED_DOTFILES[entry.name]), { overwrite: true });
1808
1860
  }
1809
1861
  }
1810
1862
  }
@@ -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",
@@ -37,15 +37,31 @@ export const buildCssColorsBlock = (theme) => {
37
37
  );
38
38
  const allKeys = [...TOKEN_KEYS, ...optionalKeys];
39
39
 
40
- const lightLines = allKeys.map((k) => cssColorLine(k, theme.light[k])).join('\n');
41
- const darkLines = allKeys.map((k) => cssColorLine(k, theme.dark[k])).join('\n');
40
+ // extraTokens (v0.111.0+) TOKEN_KEYS/OPTIONAL_TOKEN_KEYS 에 없는 자유 추가 토큰.
41
+ // root: 모드 무관 고정 — :root 에만 emit (dark 블록에 덮어쓰지 않아 자동 cascade).
42
+ // light: :root 에 emit (light 가 :root 기본). dark: dark 블록 두 곳에 emit.
43
+ const extra = theme.extraTokens ?? {};
44
+ const extraRootLines = entriesToCssLines(extra.root);
45
+ const extraLightLines = entriesToCssLines(extra.light);
46
+ const extraDarkLines = entriesToCssLines(extra.dark);
47
+
48
+ const lightSection = [
49
+ ...allKeys.map((k) => cssColorLine(k, theme.light[k])),
50
+ ...extraLightLines,
51
+ ...extraRootLines,
52
+ ].join('\n');
53
+
54
+ const darkLines = [
55
+ ...allKeys.map((k) => cssColorLine(k, theme.dark[k])),
56
+ ...extraDarkLines,
57
+ ];
42
58
  // 미디어쿼리 안의 다크 라인은 한 단계 더 들여쓰기 (`:root:not(...)` 안쪽).
43
- const darkLinesIndented = allKeys
44
- .map((k) => ` ${cssColorLine(k, theme.dark[k])}`)
45
- .join('\n');
59
+ const darkLinesIndented = darkLines.map((line) => ` ${line}`).join('\n');
60
+ const darkSection = darkLines.join('\n');
61
+
46
62
  return [
47
63
  ':root {',
48
- lightLines,
64
+ lightSection,
49
65
  '}',
50
66
  '@media (prefers-color-scheme: dark) {',
51
67
  ' :root:not(.light):not(.dark) {',
@@ -53,11 +69,20 @@ export const buildCssColorsBlock = (theme) => {
53
69
  ' }',
54
70
  '}',
55
71
  '.dark {',
56
- darkLines,
72
+ darkSection,
57
73
  '}',
58
74
  ].join('\n');
59
75
  };
60
76
 
77
+ /** extraTokens 카테고리(plain object) → CSS 라인 배열. key 가 '--' 로 시작하면 그대로, 아니면 prepend. */
78
+ function entriesToCssLines(map) {
79
+ if (!map || typeof map !== 'object') return [];
80
+ return Object.entries(map).map(([rawKey, value]) => {
81
+ const key = rawKey.startsWith('--') ? rawKey.slice(2) : rawKey;
82
+ return cssColorLine(key, value);
83
+ });
84
+ }
85
+
61
86
  export const buildCssRadiusBlock = (theme) => {
62
87
  return ` --radius: ${theme.radius}rem;`;
63
88
  };
@@ -0,0 +1,95 @@
1
+ // resolveTheme — ui-core/sh-ui.config.json 의 공유 theme 과 ui-app/sh-ui.config.json 의
2
+ // app theme 을 머지해 effective theme 반환. v0.111.0+ 의 shared-theme 메커니즘.
3
+ //
4
+ // 머지 규칙:
5
+ // - 스칼라 (base/radius/mode): app 값 우선 (undefined 면 core)
6
+ // - extraTokens.{root,light,dark}: 키 단위 deep-merge — app 키가 core 키 override, 누락 키는 core 상속
7
+ // - extraTokens 카테고리 자체가 한쪽만 있으면 그쪽 그대로
8
+ //
9
+ // 모노레포 ui-core 의 theme 이 없으면 (or coreConfig 미제공) app theme 그대로 반환 — backward-compat.
10
+
11
+ import path from 'node:path';
12
+ import { promises as fsp } from 'node:fs';
13
+
14
+ /** ui-app config 경로에서 sibling ui-core/sh-ui.config.json 을 찾는다. 모노레포 컨벤션 가정:
15
+ * .../packages/ui/ui-apps/ui-{name}/sh-ui.config.json
16
+ * .../packages/ui/ui-core/sh-ui.config.json
17
+ * 못 찾으면 null. */
18
+ export async function findUiCoreConfigPath(uiAppConfigPath) {
19
+ // ui-apps/ui-{name}/ -> ../../ui-core/
20
+ const appDir = path.dirname(path.resolve(uiAppConfigPath));
21
+ const candidate = path.resolve(appDir, '..', '..', 'ui-core', 'sh-ui.config.json');
22
+ try {
23
+ await fsp.access(candidate);
24
+ return candidate;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** sh-ui.config.json 을 읽어 객체 반환. 파일 없거나 파싱 실패 시 null. */
31
+ export async function readShUiConfig(configPath) {
32
+ if (!configPath) return null;
33
+ try {
34
+ const text = await fsp.readFile(configPath, 'utf-8');
35
+ return JSON.parse(text);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** extraTokens 한 카테고리(root/light/dark) deep-merge. app 키 우선. 둘 다 없으면 undefined. */
42
+ function mergeTokenMap(coreMap, appMap) {
43
+ if (!coreMap && !appMap) return undefined;
44
+ return { ...(coreMap ?? {}), ...(appMap ?? {}) };
45
+ }
46
+
47
+ /** extraTokens 블록 deep-merge. */
48
+ function mergeExtraTokens(coreExtra, appExtra) {
49
+ if (!coreExtra && !appExtra) return undefined;
50
+ const result = {};
51
+ for (const cat of ['root', 'light', 'dark']) {
52
+ const merged = mergeTokenMap(coreExtra?.[cat], appExtra?.[cat]);
53
+ if (merged && Object.keys(merged).length > 0) result[cat] = merged;
54
+ }
55
+ return Object.keys(result).length > 0 ? result : undefined;
56
+ }
57
+
58
+ /**
59
+ * theme block 두 개를 머지해 effective theme 반환.
60
+ * 둘 다 null/undefined 면 null. 한쪽만 있으면 그쪽 그대로.
61
+ */
62
+ export function resolveTheme(coreTheme, appTheme) {
63
+ if (!coreTheme && !appTheme) return null;
64
+ if (!coreTheme) return appTheme;
65
+ if (!appTheme) return coreTheme;
66
+
67
+ const merged = {
68
+ ...coreTheme,
69
+ ...appTheme, // app 의 스칼라(base/radius/mode) 가 core 를 override
70
+ };
71
+
72
+ const extra = mergeExtraTokens(coreTheme.extraTokens, appTheme.extraTokens);
73
+ if (extra) merged.extraTokens = extra;
74
+ else delete merged.extraTokens;
75
+
76
+ return merged;
77
+ }
78
+
79
+ /**
80
+ * ui-app config 객체 + (선택) ui-core config 객체로부터 effective theme 계산.
81
+ * config 자체에 theme 필드가 없으면 null 자리에서 시작.
82
+ */
83
+ export function resolveThemeFromConfigs(coreConfig, appConfig) {
84
+ return resolveTheme(coreConfig?.theme ?? null, appConfig?.theme ?? null);
85
+ }
86
+
87
+ /**
88
+ * ui-app config 경로에서 sibling ui-core 를 찾아 머지된 theme 을 반환.
89
+ * 모노레포가 아니거나 sibling 없으면 app theme 그대로.
90
+ */
91
+ export async function loadResolvedTheme(uiAppConfigPath, appConfig) {
92
+ const corePath = await findUiCoreConfigPath(uiAppConfigPath);
93
+ const coreConfig = corePath ? await readShUiConfig(corePath) : null;
94
+ return resolveThemeFromConfigs(coreConfig, appConfig);
95
+ }
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
  );
@@ -34,23 +34,41 @@ async function loadConfig(cwd) {
34
34
 
35
35
  /**
36
36
  * config 로 expected tokens.css 를 생성. custom / non-buildable preset 은 throw.
37
+ *
38
+ * v0.111.0+ — 모노레포 ui-app 의 경우 sibling ui-core/sh-ui.config.json 의
39
+ * 공유 theme 을 merge (resolveTheme: app 키가 core 키 override).
37
40
  */
38
- async function buildExpected(config) {
39
- if (config.theme?.base === "custom") {
41
+ async function buildExpected(config, cwd) {
42
+ const effectiveConfig = await applySharedTheme(config, cwd);
43
+ const effectiveBase = effectiveConfig.theme?.base;
44
+ if (effectiveBase === "custom") {
40
45
  throw new Error(
41
46
  "custom theme 은 buildTokens 로 재생성 불가 (base64 가 단일 진실). " +
42
47
  "tokens diff/upgrade 는 buildable preset (neutral/zinc/slate) 에서만 동작합니다.",
43
48
  );
44
49
  }
45
- const base = config.theme?.base;
46
- if (base && !THEME_BASES.includes(base)) {
50
+ if (effectiveBase && !THEME_BASES.includes(effectiveBase)) {
47
51
  throw new Error(
48
- `'${base}' preset 은 buildTokens 로 재생성 불가 (primitives 미정의 — buildable: ${THEME_BASES.join("/")}). ` +
52
+ `'${effectiveBase}' preset 은 buildTokens 로 재생성 불가 (primitives 미정의 — buildable: ${THEME_BASES.join("/")}). ` +
49
53
  "diff/upgrade 는 base 가 neutral/zinc/slate 일 때만 사용 가능합니다.",
50
54
  );
51
55
  }
52
56
  const { buildTokens } = await loadTokensBuilder();
53
- return buildTokens(config);
57
+ return buildTokens(effectiveConfig);
58
+ }
59
+
60
+ /** 모노레포 ui-app 의 sibling ui-core 의 theme 을 머지한 config 를 반환. ui-core 없으면 그대로. */
61
+ async function applySharedTheme(config, cwd) {
62
+ const { findUiCoreConfigPath, readShUiConfig, resolveThemeFromConfigs } =
63
+ await import("./create/theme/resolveTheme.js");
64
+ const appConfigPath = resolve(cwd, "sh-ui.config.json");
65
+ const corePath = await findUiCoreConfigPath(appConfigPath);
66
+ // 같은 파일을 가리키면 (이게 곧 ui-core) merge 불필요.
67
+ if (!corePath || corePath === appConfigPath) return config;
68
+ const coreConfig = await readShUiConfig(corePath);
69
+ if (!coreConfig?.theme) return config;
70
+ const mergedTheme = resolveThemeFromConfigs(coreConfig, config);
71
+ return { ...config, theme: mergedTheme };
54
72
  }
55
73
 
56
74
  async function loadCurrent(config, cwd) {
@@ -106,7 +124,7 @@ function renderDiffReport({ added, removed, changed, unchangedCount }, rel) {
106
124
 
107
125
  export async function runTokensDiff({ cwd }) {
108
126
  const config = await loadConfig(cwd);
109
- const expectedText = await buildExpected(config);
127
+ const expectedText = await buildExpected(config, cwd);
110
128
  const { text: currentText, rel } = await loadCurrent(config, cwd);
111
129
  const diff =
112
130
  config.platform === "flutter"
@@ -117,7 +135,7 @@ export async function runTokensDiff({ cwd }) {
117
135
 
118
136
  export async function runTokensUpgrade({ cwd, mode }) {
119
137
  const config = await loadConfig(cwd);
120
- const expectedText = await buildExpected(config);
138
+ const expectedText = await buildExpected(config, cwd);
121
139
  const current = await loadCurrent(config, cwd);
122
140
  const isFlutter = config.platform === "flutter";
123
141
 
@@ -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