sh-ui-cli 0.66.0 → 0.67.1

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/bin/sh-ui.mjs CHANGED
@@ -3,6 +3,7 @@ import { init } from "../src/init.mjs";
3
3
  import { add } from "../src/add.mjs";
4
4
  import { list } from "../src/list.mjs";
5
5
  import { remove } from "../src/remove.mjs";
6
+ import { findShUiContext } from "../src/resolve-context.mjs";
6
7
 
7
8
  const [, , cmd, ...rest] = process.argv;
8
9
 
@@ -29,6 +30,8 @@ const usage = `사용법:
29
30
  --force (add) 기존 파일을 모두 덮어쓰기 (prompt 없음)
30
31
  (remove) 사용자가 수정한 파일도 삭제
31
32
  --keep (add) 기존 파일을 모두 유지 (prompt 없음)
33
+ --app <name> (add) monorepo 라우팅 시 대상 ui-{name} 명시
34
+ (apps/<name>/ 안에서 실행하면 자동 추론)
32
35
  --all (list) 설치되지 않은 컴포넌트까지 표시
33
36
  --dry-run (remove, rename-app) 변경 대상만 출력하고 실행 안 함
34
37
  --yes (rename-app) 대화형 확인 생략
@@ -54,13 +57,41 @@ try {
54
57
  process.exit(1);
55
58
  }
56
59
  const onConflict = force ? "overwrite" : keepFlag ? "keep" : "prompt";
57
- const names = rest.filter((a) => !a.startsWith("--"));
60
+ // --app 모노레포 라우팅 시 ui-{app} 명시 (tokens 또는 v0.64.x fallback).
61
+ const appIdx = rest.indexOf("--app");
62
+ const app = appIdx !== -1 ? rest[appIdx + 1] : null;
63
+ const names = rest.filter((a, i) => !a.startsWith("--") && rest[i - 1] !== "--app");
58
64
  if (names.length === 0) {
59
65
  console.error("에러: 추가할 컴포넌트 이름이 필요합니다.\n");
60
66
  console.error(usage);
61
67
  process.exit(1);
62
68
  }
63
- await add({ cwd: process.cwd(), names, skipInstall, diffMode, onConflict });
69
+
70
+ // v0.67+: cwd 부터 walk-up 으로 sh-ui.config.json (standalone/ui-core/ui-app)
71
+ // 또는 pnpm-workspace.yaml (monorepo 루트) 발견. monorepo 면 자동 라우팅 (tokens →
72
+ // ui-app, 그 외 → ui-core), apps/<name>/ 안에서 실행하면 그 앱이 hintApp.
73
+ const ctx = findShUiContext(process.cwd());
74
+ if (!ctx) {
75
+ console.error(
76
+ "✗ sh-ui.config.json 또는 pnpm-workspace.yaml 을 cwd 부터 부모 트리에서 찾지 못했습니다.\n" +
77
+ " 먼저 `sh-ui init` 또는 `sh-ui create` 로 프로젝트를 초기화하세요.",
78
+ );
79
+ process.exit(1);
80
+ }
81
+ if (ctx.kind === "config") {
82
+ await add({ cwd: ctx.root, names, skipInstall, diffMode, onConflict });
83
+ } else {
84
+ const { addComponent } = await import("../src/create/generator.js");
85
+ await addComponent({
86
+ cwd: ctx.root,
87
+ names,
88
+ app,
89
+ hintApp: ctx.hintApp,
90
+ skipInstall,
91
+ diffMode,
92
+ onConflict,
93
+ });
94
+ }
64
95
  break;
65
96
  }
66
97
  case "list": {
@@ -2,6 +2,33 @@
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.67.1",
7
+ "date": "2026-05-09",
8
+ "title": "patch — sh-ui add tokens 가 rose/emerald/violet preset 에서 throw 하던 회귀 수정",
9
+ "type": "patch",
10
+ "highlights": [
11
+ "**`sh-ui add tokens` 가 rose/emerald/violet preset 에서 `해석 실패: {color.rose.50}` 로 throw 하던 회귀 수정** — CLI 의 풍부한 preset (rose/emerald/violet) 은 packages/tokens 의 primitives.json 에 색 스케일이 없어 `buildTokens` 가 해석 불가. injectCssTheme 이 create 시점에 resolved hex 를 박아둔 tokens.css 를 단일 진실로 두고, 재실행 시 'custom' 과 동일하게 보존하도록 가드 확장.",
12
+ "**tokens.css 미존재 + non-buildable preset → 친절한 에러** — `sh-ui init --theme rose` 후 `sh-ui add tokens` 같은 흐름은 preset 사용법 안내와 함께 종료 (해결책: `sh-ui create --theme rose` 또는 theme.base 를 neutral/slate/zinc 로 변경).",
13
+ "**buildable bases (neutral/slate/zinc) 는 정상 빌드** — 회귀 가드 6개 (`add-tokens-presets.test.js`) — 보존 / 친절한 에러 / 정상 빌드 모두 검증."
14
+ ],
15
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.67.1"
16
+ },
17
+ {
18
+ "version": "0.67.0",
19
+ "date": "2026-05-09",
20
+ "title": "monorepo 어디서든 sh-ui add — walk-up 컨텍스트 라우팅",
21
+ "type": "minor",
22
+ "highlights": [
23
+ "**`sh-ui add` 가 monorepo 전 디렉토리에서 동작** — `apps/web/`, monorepo 루트, `packages/ui/ui-core/`, `packages/ui/ui-apps/ui-{name}/` 어디서든. cwd 부터 부모 트리 walk-up 으로 `sh-ui.config.json` (standalone) 또는 `pnpm-workspace.yaml` (monorepo 루트) 자동 발견.",
24
+ "**hintApp 자동 추출** — `apps/web/`에서 `sh-ui add tokens` 실행 시 자동으로 ui-web 으로 라우팅 (별도 `--app` 불필요). `apps/admin/` 에선 ui-admin. monorepo 루트면 prompt (TTY) 또는 모든 앱 (비대화형 + tokens).",
25
+ "**npx subprocess 제거** — orchestrator 가 inner `npx sh-ui add` 대신 `add()` 함수를 in-process 호출. sh-ui-cli 가 모노레포에 local 설치 안 돼도 동작.",
26
+ "**다중 컴포넌트 + tokens 혼합** — `sh-ui add button card tokens` 한 줄로 ui-core 와 ui-app 양쪽에 라우팅 (이름별 그룹화).",
27
+ "**`--app <name>` 플래그 신규 (sh-ui add)** — hintApp 자동 추론을 명시적으로 덮어쓸 때.",
28
+ "**호환** — `sh-ui create add-component <name>` 는 alias 로 유지. v0.65/v0.66 의 ui-core SoT 라우팅 그대로."
29
+ ],
30
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.67.0"
31
+ },
5
32
  {
6
33
  "version": "0.66.0",
7
34
  "date": "2026-05-09",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.66.0",
3
+ "version": "0.67.1",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/add.mjs CHANGED
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { select } from "@inquirer/prompts";
7
7
  import { formatUnifiedDiff } from "./diff.mjs";
8
8
  import { getRegistryRoot, getTokensRoot, getPeerVersionsPath } from "./paths.mjs";
9
+ import { THEME_BASES } from "./constants.js";
9
10
 
10
11
  /**
11
12
  * 기존 파일과 registry 파일 내용이 다를 때 keep/overwrite 결정.
@@ -185,6 +186,29 @@ async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
185
186
  return;
186
187
  }
187
188
 
189
+ // CLI 가 THEME_PRESETS 에서 알지만 primitives.json 의 THEME_BASES 에 없는 풍부한 preset
190
+ // (rose/emerald/violet 등) 도 buildTokens 가 `{color.rose.50}` 등을 해석할 수 없어 throw.
191
+ // create 시점에 injectCssTheme 이 resolved hex 를 박아둔 tokens.css 가 단일 진실 —
192
+ // sh-ui add tokens 는 보존만. base 가 buildable 하지 않으면 같은 정책 적용.
193
+ const base = config.theme?.base;
194
+ if (base && !THEME_BASES.includes(base)) {
195
+ if (!existsSync(dest)) {
196
+ throw new Error(
197
+ `'${base}' preset 의 tokens.css 가 아직 없습니다. 이 preset 은 ` +
198
+ `sh-ui add tokens 로 빌드 불가 (primitives 미정의 — buildable: ${THEME_BASES.join('/')}). ` +
199
+ `해결: sh-ui create --theme ${base} 로 새 프로젝트 스캐폴드, 또는 ` +
200
+ `sh-ui.config.json 의 theme.base 를 ${THEME_BASES.join('/')} 중 하나로 변경 후 재실행.`,
201
+ );
202
+ }
203
+ if (!diffMode) {
204
+ console.log(
205
+ `↷ tokens → ${relative(cwd, dest)} ('${base}' preset — tokens.css 보존, ` +
206
+ `색 조정은 파일 직접 편집 또는 sh-ui create --theme <new> 로 재스캐폴드)`
207
+ );
208
+ }
209
+ return;
210
+ }
211
+
188
212
  const { buildTokens } = await loadTokensBuilder();
189
213
  const content = await buildTokens(config);
190
214
 
@@ -423,108 +423,163 @@ export async function addApp(options = {}) {
423
423
 
424
424
  // ─── Add component to ui packages ───
425
425
 
426
- export async function addComponent(componentName, appName) {
427
- const cwd = process.cwd();
428
- const isMonorepo = await fs.pathExists(path.join(cwd, 'pnpm-workspace.yaml'));
426
+ /**
427
+ * 컴포넌트/토큰 추가 오케스트레이터 — monorepo 의 어느 디렉토리에서든 의도대로 라우팅.
428
+ *
429
+ * **v0.67+ 시그니처**: 옵션 객체 받음. `bin/sh-ui.mjs` 가 walk-up 으로 monorepo 루트를 찾아
430
+ * 호출하고, `add()` 를 in-process 로 호출 (npx subprocess 없음 — 외부 sh-ui-cli 설치 의존 X).
431
+ *
432
+ * 라우팅 (monorepo, v0.65+):
433
+ * - `tokens` → `packages/ui/ui-apps/ui-{hintApp 또는 app 또는 prompt 또는 단일}`
434
+ * - 그 외(컴포넌트/훅/lib) → `packages/ui/ui-core` 단일 SoT
435
+ *
436
+ * v0.64.x 호환: ui-core/sh-ui.config.json 부재 시 모든 add 는 ui-apps/ui-{app} 으로 폴백.
437
+ *
438
+ * @param {Object} options
439
+ * @param {string} [options.cwd] monorepo 루트. 기본 process.cwd()
440
+ * @param {string[]} options.names 추가할 이름들 (예: ['button', 'tokens'])
441
+ * @param {string|null} [options.app] 명시적 --app 플래그 (있으면 hintApp 무시)
442
+ * @param {string|null} [options.hintApp] walk-up 결과의 힌트 (apps/web → 'web')
443
+ * @param {boolean} [options.skipInstall]
444
+ * @param {boolean} [options.diffMode]
445
+ * @param {string} [options.onConflict] 'prompt' | 'keep' | 'overwrite'
446
+ */
447
+ export async function addComponent(options = {}) {
448
+ const { add } = await import('../add.mjs');
429
449
 
430
- if (!isMonorepo) {
431
- // Standalone: 현재 디렉토리에서 바로 실행
432
- if (!componentName) {
433
- componentName = await input({ message: '컴포넌트 이름:' });
450
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
451
+ const names = (options.names ?? []).filter(Boolean);
452
+ const app = options.app ?? null;
453
+ const hintApp = options.hintApp ?? null;
454
+ const skipInstall = options.skipInstall === true;
455
+ const diffMode = options.diffMode === true;
456
+ const onConflict = options.onConflict ?? 'prompt';
457
+
458
+ if (names.length === 0) {
459
+ if (!process.stdin.isTTY) {
460
+ throw new Error('비대화형 환경(TTY 없음)에서는 추가할 이름이 필요합니다.');
434
461
  }
435
- console.log(`\n📦 sh-ui 컴포넌트 추가: ${componentName}`);
436
- execSync(`npx sh-ui add ${componentName}`, { cwd, stdio: 'inherit' });
437
- console.log(`\n✅ ${componentName} 추가 완료!`);
462
+ const single = await input({ message: '컴포넌트 이름:' });
463
+ names.push(single);
464
+ }
465
+
466
+ const isMonorepo = await fs.pathExists(path.join(cwd, 'pnpm-workspace.yaml'));
467
+ if (!isMonorepo) {
468
+ // Standalone: cwd 자체가 sh-ui.config.json 보유 — 그대로 add 호출.
469
+ await add({ cwd, names, skipInstall, diffMode, onConflict });
438
470
  return;
439
471
  }
440
472
 
441
- // Monorepo (v0.65+): tokens 는 packages/ui/ui-apps/ui-{app} 으로, 그 외 컴포넌트/훅은
442
- // packages/ui/ui-core 단일 SoT 로 라우팅. 컴포넌트 중복 emit 제거가 v0.65 의 핵심.
443
- // v0.64.x 호환: ui-core/sh-ui.config.json 미존재 시 (구 모노레포 레이아웃) 기존 ui-apps
444
- // 직접 라우팅 분기로 내려감.
445
473
  const uiCoreDir = path.join(cwd, 'packages', 'ui', 'ui-core');
446
474
  const uiAppsDir = path.join(cwd, 'packages', 'ui', 'ui-apps');
447
475
  const hasUiCore = await fs.pathExists(path.join(uiCoreDir, 'sh-ui.config.json'));
448
476
  const hasUiApps = await fs.pathExists(uiAppsDir);
449
477
 
450
478
  if (!hasUiCore && !hasUiApps) {
451
- console.log('packages/ui/ui-core 또는 packages/ui/ui-apps/ 디렉토리가 없습니다.');
452
- return;
479
+ throw new Error('packages/ui/ui-core 또는 packages/ui/ui-apps/ 디렉토리가 없습니다.');
453
480
  }
454
481
 
455
- if (!componentName) {
456
- componentName = await input({ message: '컴포넌트 이름:' });
457
- }
482
+ // 이름을 라우팅 그룹별로 분리: tokens → ui-app, 그 외 → ui-core.
483
+ const tokenNames = names.filter((n) => n === 'tokens');
484
+ const componentNames = names.filter((n) => n !== 'tokens');
458
485
 
459
- const isTokens = componentName === 'tokens';
486
+ // ─── 비-tokens ui-core (또는 v0.64.x fallback 시 ui-apps) ───
487
+ if (componentNames.length > 0) {
488
+ if (hasUiCore) {
489
+ if (app) {
490
+ console.log(
491
+ `ℹ️ v0.65+ 에서 컴포넌트는 packages/ui/ui-core 에 단일 emit (--app ${app} 무시).`,
492
+ );
493
+ }
494
+ console.log(`\n📦 packages/ui/ui-core ← ${componentNames.join(', ')}`);
495
+ await add({ cwd: uiCoreDir, names: componentNames, skipInstall, diffMode, onConflict });
496
+ } else {
497
+ // v0.64.x 호환: ui-core 없으니 모든 ui-app 에 컴포넌트 직접 emit (구 동작).
498
+ const targets = await pickUiAppTargets({ uiAppsDir, app, hintApp, kind: 'component' });
499
+ for (const pkg of targets) {
500
+ const pkgDir = path.join(uiAppsDir, pkg);
501
+ console.log(`\n📦 packages/ui/ui-apps/${pkg} ← ${componentNames.join(', ')}`);
502
+ await add({ cwd: pkgDir, names: componentNames, skipInstall, diffMode, onConflict });
503
+ }
504
+ }
505
+ }
460
506
 
461
- // ─── 비-tokens (컴포넌트/훅/lib) → ui-core 단일 라우팅 ───
462
- if (!isTokens && hasUiCore) {
463
- if (appName) {
464
- console.log(
465
- `ℹ️ v0.65+ 에서 컴포넌트는 packages/ui/ui-core 에 단일 emit 됩니다 (--app ${appName} 무시).`,
466
- );
507
+ // ─── tokens → ui-app(s) ───
508
+ if (tokenNames.length > 0) {
509
+ if (!hasUiApps) {
510
+ throw new Error('tokens 추가에는 packages/ui/ui-apps/ 가 필요합니다.');
467
511
  }
468
- console.log(`\n📦 packages/ui/ui-core ${componentName} 추가 중...`);
469
- try {
470
- execSync(`npx sh-ui add ${componentName}`, { cwd: uiCoreDir, stdio: 'inherit' });
471
- console.log(`✅ packages/ui/ui-core 완료`);
472
- } catch (error) {
473
- console.log(`❌ packages/ui/ui-core 실패: ${error.message}`);
512
+ const targets = await pickUiAppTargets({ uiAppsDir, app, hintApp, kind: 'tokens' });
513
+ for (const pkg of targets) {
514
+ const pkgDir = path.join(uiAppsDir, pkg);
515
+ console.log(`\n📦 packages/ui/ui-apps/${pkg} ← tokens`);
516
+ await add({ cwd: pkgDir, names: tokenNames, skipInstall, diffMode, onConflict });
474
517
  }
475
- return;
476
518
  }
477
519
 
478
- // ─── tokens → ui-apps/ui-{app}(s), 또는 v0.64.x 호환 fallback (ui-core 없음) ───
479
- if (!hasUiApps) {
480
- console.log('❌ packages/ui/ui-apps/ 디렉토리가 없습니다.');
481
- return;
482
- }
520
+ console.log('\n✅ 추가 완료');
521
+ }
483
522
 
523
+ /**
524
+ * monorepo 의 ui-app 패키지 후보를 정해 1+ 개 반환.
525
+ *
526
+ * 우선순위:
527
+ * 1. 명시적 `--app` 플래그
528
+ * 2. walk-up 결과의 hintApp (apps/<name>/ 또는 ui-apps/ui-<name>/ 컨텍스트)
529
+ * 3. 단일 ui-app 만 존재 → 자동 선택
530
+ * 4. TTY → 사용자에게 select prompt
531
+ * 5. 비대화형 + 다중 ui-app → tokens 는 'all', 컴포넌트(v0.64.x fallback)는 throw
532
+ */
533
+ async function pickUiAppTargets({ uiAppsDir, app, hintApp, kind }) {
484
534
  const entries = await fs.readdir(uiAppsDir, { withFileTypes: true });
485
535
  const uiPackages = entries
486
536
  .filter((e) => e.isDirectory() && e.name.startsWith('ui-') && e.name !== 'ui-app-template')
487
537
  .map((e) => e.name);
488
538
 
489
539
  if (uiPackages.length === 0) {
490
- console.log('ui-* 패키지가 없습니다.');
491
- return;
540
+ throw new Error('packages/ui/ui-apps/ui-* 패키지가 없습니다.');
492
541
  }
493
542
 
494
- let targets;
495
- if (appName) {
496
- const pkgName = `ui-${appName}`;
543
+ if (app) {
544
+ const pkgName = `ui-${app}`;
497
545
  if (!uiPackages.includes(pkgName)) {
498
- console.log(`❌ packages/ui/ui-apps/${pkgName} 이 존재하지 않습니다.`);
499
- console.log(` 사용 가능: ${uiPackages.join(', ')}`);
500
- return;
546
+ throw new Error(
547
+ `packages/ui/ui-apps/${pkgName} 이 존재하지 않습니다. 사용 가능: ${uiPackages.join(', ')}`,
548
+ );
501
549
  }
502
- targets = [pkgName];
503
- } else if (uiPackages.length === 1) {
504
- targets = uiPackages;
505
- } else {
506
- const choice = await select({
507
- message: isTokens ? '어느 ui 패키지에 토큰을 추가할까요?' : '어디에 추가할까요?',
508
- choices: [
509
- { name: '모든 ui 패키지', value: 'all' },
510
- ...uiPackages.map((name) => ({ name: `packages/ui/ui-apps/${name}`, value: name })),
511
- ],
512
- });
513
- targets = choice === 'all' ? uiPackages : [choice];
550
+ return [pkgName];
514
551
  }
515
552
 
516
- for (const pkg of targets) {
517
- const pkgDir = path.join(uiAppsDir, pkg);
518
- console.log(`\n📦 packages/ui/ui-apps/${pkg}에 ${componentName} 추가 중...`);
519
- try {
520
- execSync(`npx sh-ui add ${componentName}`, { cwd: pkgDir, stdio: 'inherit' });
521
- console.log(`✅ packages/ui/ui-apps/${pkg} 완료`);
522
- } catch (error) {
523
- console.log(`❌ packages/ui/ui-apps/${pkg} 실패: ${error.message}`);
553
+ if (hintApp) {
554
+ const pkgName = `ui-${hintApp}`;
555
+ if (uiPackages.includes(pkgName)) {
556
+ return [pkgName];
557
+ }
558
+ // hintApp 이 가리키는 앱의 ui-패키지가 없으면 일반 흐름으로 떨어짐 (드문 케이스).
559
+ }
560
+
561
+ if (uiPackages.length === 1) {
562
+ return uiPackages;
563
+ }
564
+
565
+ if (!process.stdin.isTTY) {
566
+ if (kind === 'tokens') {
567
+ // 비대화형 + 다중 ui-app + tokens: 모든 앱에 적용 (가장 안전한 기본값).
568
+ return uiPackages;
524
569
  }
570
+ throw new Error(
571
+ `여러 ui-app 후보 (${uiPackages.join(', ')}) — 비대화형 환경에서는 --app 으로 명시하세요.`,
572
+ );
525
573
  }
526
574
 
527
- console.log('\n✅ 컴포넌트 추가 완료!');
575
+ const choice = await select({
576
+ message: kind === 'tokens' ? '어느 ui 패키지에 토큰을 추가할까요?' : '어디에 추가할까요?',
577
+ choices: [
578
+ { name: '모든 ui 패키지', value: 'all' },
579
+ ...uiPackages.map((name) => ({ name: `packages/ui/ui-apps/${name}`, value: name })),
580
+ ],
581
+ });
582
+ return choice === 'all' ? uiPackages : [choice];
528
583
  }
529
584
 
530
585
  // ─── Generators ───
@@ -75,7 +75,11 @@ export async function runCreate(rest) {
75
75
  css: flags.css,
76
76
  });
77
77
  } else if (command === 'add-component') {
78
- await addComponent(positional[0], flags.app);
78
+ // 호환 별칭 — 신규 진입점은 `sh-ui add <name>` (bin/sh-ui.mjs 가 walk-up 으로 라우팅).
79
+ await addComponent({
80
+ names: positional.filter(Boolean),
81
+ app: flags.app ?? null,
82
+ });
79
83
  } else {
80
84
  await createProject({
81
85
  name: positional[0],
@@ -0,0 +1,56 @@
1
+ // sh-ui 의 동작 컨텍스트 해석 — `sh-ui add` 등이 cwd 부터 부모 트리를 walk up 해
2
+ // **standalone (sh-ui.config.json 발견)** 또는 **monorepo (pnpm-workspace.yaml 발견)** 컨텍스트 결정.
3
+ //
4
+ // monorepo 에서 사용자가 어디서 명령을 실행했는지 (apps/web, packages/ui/ui-apps/ui-admin 등)
5
+ // 까지 추출해 hintApp 으로 넘김 — `sh-ui add tokens` 가 어느 ui-app 으로 라우팅될지의 기본값.
6
+
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ /**
11
+ * @param {string} startDir - 검색 시작점 (보통 process.cwd())
12
+ * @returns {{kind: 'config', root: string} | {kind: 'monorepo', root: string, hintApp: string|null} | null}
13
+ */
14
+ export function findShUiContext(startDir) {
15
+ let dir = path.resolve(startDir);
16
+ while (true) {
17
+ if (existsSync(path.join(dir, "sh-ui.config.json"))) {
18
+ return { kind: "config", root: dir };
19
+ }
20
+ if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
21
+ const hintApp = extractHintApp(dir, startDir);
22
+ return { kind: "monorepo", root: dir, hintApp };
23
+ }
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) return null;
26
+ dir = parent;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * monorepo 루트가 정해진 뒤 startDir 의 상대 경로에서 어느 앱 컨텍스트인지 추출.
32
+ *
33
+ * 매칭 패턴 (모노레포 루트 기준):
34
+ * - `apps/<name>/...` → <name>
35
+ * - `packages/ui/ui-apps/ui-<name>/...` → <name>
36
+ *
37
+ * 그 외(루트 자체, packages/ui/ui-core 등)는 null — 호출자가 prompt 또는 fallback.
38
+ */
39
+ function extractHintApp(monorepoRoot, startDir) {
40
+ const rel = path.relative(monorepoRoot, path.resolve(startDir));
41
+ if (!rel || rel.startsWith("..")) return null;
42
+ const parts = rel.split(path.sep);
43
+ if (parts[0] === "apps" && parts[1]) {
44
+ return parts[1];
45
+ }
46
+ if (
47
+ parts[0] === "packages" &&
48
+ parts[1] === "ui" &&
49
+ parts[2] === "ui-apps" &&
50
+ parts[3] &&
51
+ parts[3].startsWith("ui-")
52
+ ) {
53
+ return parts[3].slice(3);
54
+ }
55
+ return null;
56
+ }