byuckchon-frontend-cli 1.9.3 → 1.9.5

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.
@@ -1,6 +1,134 @@
1
1
  import inquirer from 'inquirer';
2
2
 
3
3
  import { modelChoices, DEFAULT_MODEL_ID } from '../ai/models.js';
4
+ import { deriveScope } from '../context/monorepo.js';
5
+
6
+ const NAME_RULE = (input) => {
7
+ if (!input.trim()) return '이름을 입력해주세요.';
8
+ if (!/^[a-z0-9\-_]+$/i.test(input)) return '영문, 숫자, -, _ 만 사용 가능합니다.';
9
+ return true;
10
+ };
11
+
12
+ const SCOPE_RULE = (input) => {
13
+ if (!input.trim()) return 'scope 를 입력해주세요.';
14
+ if (!/^[a-z0-9\-]+$/i.test(input)) return '영문, 숫자, - 만 사용 가능합니다. (@ 없이)';
15
+ return true;
16
+ };
17
+
18
+ const FRAMEWORK_CHOICES = [
19
+ { name: 'React (Vite + TypeScript)', value: 'react' },
20
+ { name: 'Next.js (App Router + TypeScript)', value: 'next' },
21
+ ];
22
+
23
+ /** 단일 프로젝트 vs 모노레포 선택. */
24
+ export async function askProjectType() {
25
+ const { projectType } = await inquirer.prompt([
26
+ {
27
+ type: 'list',
28
+ name: 'projectType',
29
+ message: '무엇을 생성할까요?',
30
+ choices: [
31
+ { name: '단일 프로젝트 (React 또는 Next 하나)', value: 'single' },
32
+ {
33
+ name: '모노레포 (pnpm + Turborepo, 앱 하나로 시작 · 이후 bc add 로 추가)',
34
+ value: 'monorepo',
35
+ },
36
+ ],
37
+ },
38
+ ]);
39
+ return projectType;
40
+ }
41
+
42
+ /** 모노레포 생성 질문. */
43
+ export async function askMonorepoQuestions() {
44
+ const base = await inquirer.prompt([
45
+ {
46
+ type: 'input',
47
+ name: 'projectName',
48
+ message: '모노레포(루트 폴더) 이름:',
49
+ validate: NAME_RULE,
50
+ },
51
+ ]);
52
+
53
+ return inquirer.prompt([
54
+ {
55
+ type: 'input',
56
+ name: 'scope',
57
+ message: 'npm scope (@scope/앱이름 형태로 쓰임, @ 없이):',
58
+ default: deriveScope(base.projectName),
59
+ validate: SCOPE_RULE,
60
+ },
61
+ {
62
+ type: 'input',
63
+ name: 'appName',
64
+ message: '처음 만들 앱 이름 (apps/ 아래에 생성):',
65
+ default: 'web',
66
+ validate: NAME_RULE,
67
+ },
68
+ {
69
+ type: 'list',
70
+ name: 'framework',
71
+ message: '이 앱의 프레임워크는?',
72
+ choices: FRAMEWORK_CHOICES,
73
+ },
74
+ {
75
+ type: 'list',
76
+ name: 'aiModel',
77
+ message: 'bc chat 에서 기본으로 쓸 AI 모델은?',
78
+ choices: [...modelChoices(), { name: '나중에 설정 (bc config set-model)', value: null }],
79
+ default: DEFAULT_MODEL_ID,
80
+ },
81
+ {
82
+ type: 'input',
83
+ name: 'figmaUrl',
84
+ message: 'Figma 파일 URL (선택, 엔터로 건너뛰기):',
85
+ default: '',
86
+ },
87
+ {
88
+ type: 'input',
89
+ name: 'openapiUrl',
90
+ message: '백엔드 OpenAPI(Swagger) URL (선택, 엔터로 건너뛰기):',
91
+ default: '',
92
+ },
93
+ ]).then((rest) => ({ ...base, ...rest }));
94
+ }
95
+
96
+ /** 기존 모노레포에 앱 추가 질문. */
97
+ export async function askAddQuestions({ scope } = {}) {
98
+ return inquirer.prompt([
99
+ {
100
+ type: 'input',
101
+ name: 'appName',
102
+ message: `추가할 앱 이름 (@${scope ?? 'scope'}/<이름> · apps/ 아래에 생성):`,
103
+ validate: NAME_RULE,
104
+ },
105
+ {
106
+ type: 'list',
107
+ name: 'framework',
108
+ message: '프레임워크는?',
109
+ choices: FRAMEWORK_CHOICES,
110
+ },
111
+ {
112
+ type: 'list',
113
+ name: 'aiModel',
114
+ message: '이 앱 bc chat 기본 모델은?',
115
+ choices: [...modelChoices(), { name: '나중에 설정 (bc config set-model)', value: null }],
116
+ default: DEFAULT_MODEL_ID,
117
+ },
118
+ {
119
+ type: 'input',
120
+ name: 'figmaUrl',
121
+ message: 'Figma 파일 URL (선택):',
122
+ default: '',
123
+ },
124
+ {
125
+ type: 'input',
126
+ name: 'openapiUrl',
127
+ message: '백엔드 OpenAPI(Swagger) URL (선택):',
128
+ default: '',
129
+ },
130
+ ]);
131
+ }
4
132
 
5
133
  export async function askInitQuestions() {
6
134
  return inquirer.prompt([
package/src/ui/ChatApp.js CHANGED
@@ -16,6 +16,7 @@ import { searchIndex } from '../indexer/search.js';
16
16
  import { loadIndex, buildIndex } from '../indexer/store.js';
17
17
  import { listSessions } from '../history/store.js';
18
18
  import { findLastRetryableUser } from '../history/management.js';
19
+ import { updateProjectLink } from '../config/projectLinks.js';
19
20
 
20
21
  const h = React.createElement;
21
22
 
@@ -292,6 +293,8 @@ const SLASH_COMMANDS = [
292
293
  { cmd: '/history', hint: '', desc: '프로젝트의 이전 대화 목록' },
293
294
  { cmd: '/retry', hint: '', desc: '마지막 사용자 요청 다시 실행' },
294
295
  { cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
296
+ { cmd: '/figma-link', hint: '<url|off>', desc: '프로젝트 Figma 링크 변경/해제' },
297
+ { cmd: '/openapi-link', hint: '<url|off>', desc: '프로젝트 OpenAPI 링크 변경/해제' },
295
298
  { cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
296
299
  { cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
297
300
  { cmd: '/paste', hint: '', desc: '클립보드의 이미지(스크린샷)를 첨부 — macOS' },
@@ -422,6 +425,7 @@ export function ChatApp({
422
425
  const [indexProgress, setIndexProgress] = useState('');
423
426
  const [ragEnabled, setRagEnabled] = useState(true);
424
427
  const [menuIndex, setMenuIndex] = useState(0);
428
+ const [inputRevision, setInputRevision] = useState(0);
425
429
  const [historyItems, setHistoryItems] = useState([]);
426
430
  const [historyIndex, setHistoryIndex] = useState(0);
427
431
  const [historyDeleteId, setHistoryDeleteId] = useState(null);
@@ -438,6 +442,16 @@ export function ChatApp({
438
442
  setMenuIndex(0);
439
443
  }, [input]);
440
444
 
445
+ // showCursor=false인 ink-text-input은 외부에서 value가 바뀌어도 내부 cursorOffset을
446
+ // 갱신하지 않는다. 자동완성/초기화 때 재마운트해 커서를 새 문자열 끝으로 맞춘다.
447
+ const replaceInput = useCallback((value) => {
448
+ setInput(value);
449
+ setInputRevision((revision) => revision + 1);
450
+ }, []);
451
+ const completeSlash = useCallback((item) => {
452
+ replaceInput(expandSlash(item));
453
+ }, [replaceInput]);
454
+
441
455
  // 인덱스 빌드 헬퍼 — 자동/수동 양쪽에서 공유.
442
456
  const runIndexBuild = useCallback(
443
457
  async ({ rebuild = false, silent = false } = {}) => {
@@ -587,13 +601,13 @@ export function ChatApp({
587
601
  return;
588
602
  }
589
603
  if (key.escape) {
590
- setInput('');
604
+ replaceInput('');
591
605
  return;
592
606
  }
593
607
  // Tab 자동완성 — ink-text-input 이 Tab 을 자체 처리하지 않아 충돌 없음.
594
608
  if (key.tab) {
595
609
  const sel = slashItems[Math.min(menuIndex, slashItems.length - 1)];
596
- if (sel) setInput(expandSlash(sel));
610
+ if (sel) completeSlash(sel);
597
611
  return;
598
612
  }
599
613
  });
@@ -676,6 +690,27 @@ export function ChatApp({
676
690
  }
677
691
  return true;
678
692
  }
693
+ if (cmd === 'figma-link' || cmd === 'openapi-link') {
694
+ const kind = cmd === 'figma-link' ? 'figma' : 'openapi';
695
+ if (!arg) {
696
+ const current = kind === 'figma' ? cfg.effective.design?.figma : cfg.effective.api?.openapi;
697
+ pushSystemInfo(`현재 ${kind === 'figma' ? 'Figma' : 'OpenAPI'}: ${current ?? '(미설정)'}\n사용법: /${cmd} <url|off>`);
698
+ return true;
699
+ }
700
+ try {
701
+ const nextCfg = await updateProjectLink(cfg, kind, arg);
702
+ const label = kind === 'figma' ? 'Figma' : 'OpenAPI';
703
+ const value = kind === 'figma' ? nextCfg.effective.design?.figma : nextCfg.effective.api?.openapi;
704
+ const sessionRule = value
705
+ ? `\n\n[프로젝트 설정 변경] ${label} 소스는 ${value} 이다. 이전 ${label} 링크와 요약은 무시하고 이 값을 사용하라.`
706
+ : `\n\n[프로젝트 설정 변경] ${label} 링크가 해제되었다. 이전 ${label} 링크와 요약은 사용하지 마라.`;
707
+ setCfg({ ...nextCfg, system: cfg.system + sessionRule });
708
+ pushSystemInfo(`${label} 링크를 ${value ? '변경했습니다: ' + value : '해제했습니다.'}`);
709
+ } catch (err) {
710
+ pushSystemError(err.message);
711
+ }
712
+ return true;
713
+ }
679
714
  if (cmd === 'image') {
680
715
  if (!arg) {
681
716
  pushSystemError('사용법: /image <파일 경로> (Finder 에서 입력창 위로 끌어다 놓으면 경로가 자동 입력됩니다.)');
@@ -1006,25 +1041,28 @@ export function ChatApp({
1006
1041
 
1007
1042
  const onSubmit = async (raw) => {
1008
1043
  const line = raw.trim();
1044
+ const exactCommand = SLASH_COMMANDS.find((item) => item.cmd === line);
1045
+
1046
+ // 인자가 없는 명령은 메뉴 선택 상태와 관계없이 정확히 일치하면 즉시 실행한다.
1047
+ if (exactCommand && !exactCommand.hint) {
1048
+ replaceInput('');
1049
+ await handleSlash(exactCommand.cmd);
1050
+ return;
1051
+ }
1009
1052
 
1010
- // 슬래시 메뉴가 열려 있으면 Enter 의미가 갈래:
1011
- // 1) input 이 메뉴의 아이템 cmd 와 정확히 일치 + 인자 필요 없는 명령 → 즉시 실행
1012
- // 2) 그 외 → 선택 항목으로 자동완성만 (실행은 Enter 한 번 더)
1053
+ // 메뉴에서 선택한 명령도 인자가 없으면 즉시 실행하고, 인자가 필요할 때만 자동완성한다.
1013
1054
  if (slashOpen) {
1014
1055
  const sel = slashItems[Math.min(menuIndex, slashItems.length - 1)];
1015
- const exact = slashItems.find((it) => it.cmd === line);
1016
- if (exact && !exact.hint) {
1017
- // 정확히 매칭 + 인자 불필요 → 바로 실행
1018
- setInput('');
1019
- await handleSlash(exact.cmd);
1056
+ if (sel && !sel.hint) {
1057
+ replaceInput('');
1058
+ await handleSlash(sel.cmd);
1020
1059
  return;
1021
1060
  }
1022
- // 자동완성만 하고 멈춤 — 사용자가 인자를 더 칠 수 있게.
1023
- if (sel) setInput(expandSlash(sel));
1061
+ if (sel) completeSlash(sel);
1024
1062
  return;
1025
1063
  }
1026
1064
 
1027
- setInput('');
1065
+ replaceInput('');
1028
1066
  if (!line) return;
1029
1067
 
1030
1068
  if (line.startsWith('/')) {
@@ -1077,6 +1115,7 @@ export function ChatApp({
1077
1115
  h(Text, { color: 'magenta', bold: true }, 'you › '),
1078
1116
  state === 'idle' && !historyOpen
1079
1117
  ? h(TextInput, {
1118
+ key: inputRevision,
1080
1119
  value: input,
1081
1120
  onChange: setInput,
1082
1121
  onSubmit,
@@ -0,0 +1,58 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ import { versions } from '../constants/versions.js';
5
+
6
+ const execFile = promisify(execFileCallback);
7
+
8
+ const COMMON_DEPENDENCIES = ['@tanstack/react-query', 'zod'];
9
+
10
+ const installCommands = {
11
+ npm: ['npm', ['install']],
12
+ pnpm: ['pnpm', ['add']],
13
+ yarn: ['yarn', ['add']],
14
+ bun: ['bun', ['add']],
15
+ };
16
+
17
+ export function hasDependency(pkg, packageName) {
18
+ return Boolean(
19
+ pkg?.dependencies?.[packageName] ||
20
+ pkg?.devDependencies?.[packageName] ||
21
+ pkg?.peerDependencies?.[packageName] ||
22
+ pkg?.optionalDependencies?.[packageName],
23
+ );
24
+ }
25
+
26
+ export function requiredDependenciesForFramework(framework) {
27
+ return framework === 'next'
28
+ ? COMMON_DEPENDENCIES
29
+ : [...COMMON_DEPENDENCIES, 'axios'];
30
+ }
31
+
32
+ export async function ensureRequiredDependencies({
33
+ cwd,
34
+ pkg,
35
+ framework,
36
+ packageManager,
37
+ run = execFile,
38
+ }) {
39
+ const missing = requiredDependenciesForFramework(framework).filter(
40
+ (packageName) => !hasDependency(pkg, packageName),
41
+ );
42
+
43
+ if (!missing.length) {
44
+ return { installed: [], packageManager };
45
+ }
46
+
47
+ const selectedPackageManager = installCommands[packageManager]
48
+ ? packageManager
49
+ : 'npm';
50
+ const [command, baseArgs] = installCommands[selectedPackageManager];
51
+ const packageSpecs = missing.map(
52
+ (packageName) => `${packageName}@${versions[packageName]}`,
53
+ );
54
+
55
+ await run(command, [...baseArgs, ...packageSpecs], { cwd });
56
+
57
+ return { installed: missing, packageManager: selectedPackageManager };
58
+ }