byuckchon-frontend-cli 1.9.4 → 1.9.6

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/README.md CHANGED
@@ -104,8 +104,8 @@ bc init
104
104
  # 1) Figma → Settings → Personal access tokens → "Generate new token"
105
105
  # Read 권한만 있으면 충분 (file 읽기 / image export 둘 다 read 로 됨)
106
106
 
107
- # 2) 토큰을 .env 박기 (gitignore 됨)
108
- echo "FIGMA_TOKEN=figd_xxxxxxxxxxxxxxxx" >> .env
107
+ # 2) bc init이 만든 .env FIGMA_TOKEN 채우기 (gitignore 됨)
108
+ FIGMA_TOKEN=figd_xxxxxxxxxxxxxxxx
109
109
 
110
110
  # 3) bc.config.json 의 design.figma 에 파일/노드 URL 박기 (bc adopt 시점에 입력하거나 직접 편집)
111
111
  ```
@@ -250,8 +250,9 @@ bc adopt
250
250
  ```
251
251
 
252
252
  `package.json` 과 디렉터리를 스캔해서 **프레임워크/언어/스타일/라우팅/패키지 매니저** 를 자동 감지하고,
253
- Figma·OpenAPI URL 만 추가로 묻고 `bc.config.json` 떨궈줍니다.
254
- **소스 코드는 절대 건드리지 않습니다.**
253
+ Figma·OpenAPI URL 만 추가로 묻고 `bc.config.json` 생성합니다.
254
+ 필수 의존성(`@tanstack/react-query`, `zod`, React 계열의 `axios`)이 없으면 감지한 패키지 매니저로
255
+ 자동 설치하며, 기존 소스 코드는 건드리지 않습니다.
255
256
 
256
257
  지원 감지: Next.js · Expo · Electron · Vite+React · Remix · CRA · 일반 React.
257
258
 
@@ -291,6 +292,8 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
291
292
  | `/history` | 이전 대화 선택 후 해당 컨텍스트 이어가기 |
292
293
  | `/retry` | 마지막 사용자 요청 다시 실행 |
293
294
  | `/model [id]` | 세션 모델 변경 (인자 없으면 목록) |
295
+ | `/figma-link <url\|off>` | 프로젝트 Figma 링크 변경 또는 해제 |
296
+ | `/openapi-link <url\|off>` | 프로젝트 OpenAPI 링크 변경 또는 해제 |
294
297
  | `/cost` | 누적 토큰/비용 |
295
298
  | `/image <path>` | 다음 메시지에 이미지 첨부 (Vision 모델 권장) |
296
299
  | `/paste` | 클립보드 이미지 첨부 (macOS, `pngpaste` 필요) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,12 +11,13 @@ import {
11
11
  apiRootForFramework,
12
12
  scaffoldApiConventionDoc,
13
13
  } from '../generators/apiConventionDoc.js';
14
+ import { ensureRequiredDependencies } from '../utils/ensureRequiredDependencies.js';
14
15
 
15
16
  /**
16
17
  * `bc adopt`
17
18
  *
18
- * 기존 프로젝트(현재 디렉터리)에 bc.config.json 살포시 깔아준다.
19
- * 소스 코드는 절대 건드리지 않는다.
19
+ * 기존 프로젝트(현재 디렉터리)에 bc.config.json 필수 의존성을 추가한다.
20
+ * 기존 소스 코드는 건드리지 않는다.
20
21
  *
21
22
  * 흐름:
22
23
  * 1) 자동 감지(framework, styling, language, routing 등) 결과를 보여주고
@@ -145,6 +146,22 @@ export async function adoptCommand(opts = {}) {
145
146
  console.log(chalk.green(`\n ✓ ${CONFIG_PATHS.projectFileName} 작성 완료.`));
146
147
  console.log(chalk.dim(` ${targetFile}`));
147
148
 
149
+ const dependencies = await ensureRequiredDependencies({
150
+ cwd,
151
+ pkg: ctx.pkg,
152
+ framework: ctx.framework,
153
+ packageManager: ctx.packageManager,
154
+ });
155
+ if (dependencies.installed.length) {
156
+ console.log(
157
+ chalk.green(
158
+ ` ✓ 필수 의존성 설치 완료 (${dependencies.packageManager}): ${dependencies.installed.join(', ')}`,
159
+ ),
160
+ );
161
+ } else {
162
+ console.log(chalk.dim(' 필수 의존성 유지 (이미 설치됨)'));
163
+ }
164
+
148
165
  // API 코드 컨벤션 .md 를 API 루트(src/api | src/lib/api)에 깐다 (이미 있으면 유지).
149
166
  try {
150
167
  const { relPath, written } = await scaffoldApiConventionDoc({
@@ -25,6 +25,7 @@ import {
25
25
  import { findLastRetryableUser, formatSessionList } from '../history/management.js';
26
26
  import { getCachedOpenApi } from '../openapi/cache.js';
27
27
  import { summarizeOpenApi } from '../openapi/summary.js';
28
+ import { updateProjectLink } from '../config/projectLinks.js';
28
29
 
29
30
  /**
30
31
  * `bc chat`
@@ -330,7 +331,7 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
330
331
  }
331
332
  console.log(
332
333
  chalk.dim(
333
- ' /history · /retry · /image <경로> · /paste · /clear-attach · /exit\n',
334
+ ' /history · /retry · /figma-link <url|off> · /openapi-link <url|off> · /image <경로> · /exit\n',
334
335
  ),
335
336
  );
336
337
 
@@ -425,6 +426,32 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
425
426
  ask();
426
427
  continue;
427
428
  }
429
+ if (/^\/(figma-link|openapi-link)(\s|$)/.test(line)) {
430
+ const isFigma = line === '/figma-link' || line.startsWith('/figma-link ');
431
+ const kind = isFigma ? 'figma' : 'openapi';
432
+ const label = isFigma ? 'Figma' : 'OpenAPI';
433
+ const command = line.match(/^\/(figma-link|openapi-link)/)[1];
434
+ const arg = line.slice(command.length + 1).trim();
435
+ if (!arg) {
436
+ const current = isFigma ? cfg.effective.design?.figma : cfg.effective.api?.openapi;
437
+ console.log(chalk.dim(` 현재 ${label}: ${current ?? '(미설정)'}`));
438
+ console.log(chalk.dim(` 사용법: /${kind} <url|off>`));
439
+ ask();
440
+ continue;
441
+ }
442
+ try {
443
+ cfg = await updateProjectLink(cfg, kind, arg);
444
+ const value = isFigma ? cfg.effective.design?.figma : cfg.effective.api?.openapi;
445
+ system += value
446
+ ? `\n\n[프로젝트 설정 변경] ${label} 소스는 ${value} 이다. 이전 ${label} 링크와 요약은 무시하고 이 값을 사용하라.`
447
+ : `\n\n[프로젝트 설정 변경] ${label} 링크가 해제되었다. 이전 ${label} 링크와 요약은 사용하지 마라.`;
448
+ console.log(chalk.green(` ${label} 링크를 ${value ? '변경했습니다: ' + value : '해제했습니다.'}`));
449
+ } catch (err) {
450
+ console.log(chalk.red(' ' + err.message));
451
+ }
452
+ ask();
453
+ continue;
454
+ }
428
455
  let retryContent = null;
429
456
  if (line === '/retry') {
430
457
  const retry = findLastRetryableUser(history);
@@ -0,0 +1,49 @@
1
+ import { parseFigmaUrl } from '../figma/url.js';
2
+ import { saveProjectConfig } from './index.js';
3
+
4
+ function normalizeUrl(raw, kind) {
5
+ const value = raw.trim();
6
+ if (['off', 'none', 'clear'].includes(value.toLowerCase())) return null;
7
+
8
+ let url;
9
+ try {
10
+ url = new URL(value);
11
+ } catch {
12
+ throw new Error(`${kind} 링크가 올바른 URL이 아닙니다.`);
13
+ }
14
+ if (!['http:', 'https:'].includes(url.protocol)) {
15
+ throw new Error(`${kind} 링크는 http 또는 https URL이어야 합니다.`);
16
+ }
17
+ if (kind === 'Figma' && !parseFigmaUrl(value)) {
18
+ throw new Error('지원하는 Figma file/design/proto 링크가 아닙니다.');
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export async function updateProjectLink(cfg, kind, rawValue) {
24
+ if (!cfg.paths.projectFile) {
25
+ throw new Error('bc.config.json이 없습니다. 먼저 `bc adopt` 또는 `bc init`을 실행하세요.');
26
+ }
27
+
28
+ const value = normalizeUrl(rawValue, kind === 'figma' ? 'Figma' : 'OpenAPI');
29
+ const project = {
30
+ ...cfg.project,
31
+ design: { ...(cfg.project.design ?? {}) },
32
+ api: { ...(cfg.project.api ?? {}) },
33
+ };
34
+
35
+ if (kind === 'figma') project.design.figma = value;
36
+ else project.api.openapi = value;
37
+
38
+ await saveProjectConfig(cfg.paths.projectFile, project);
39
+
40
+ return {
41
+ ...cfg,
42
+ project,
43
+ effective: {
44
+ ...cfg.effective,
45
+ design: project.design,
46
+ api: project.api,
47
+ },
48
+ };
49
+ }
@@ -233,6 +233,9 @@ node_modules
233
233
  dist
234
234
  dist-ssr
235
235
  *.local
236
+ *.md
237
+ !README.md
238
+ .bc/
236
239
  .env
237
240
  .env.production
238
241
  .history
@@ -704,6 +707,8 @@ declare module '*.webp' {
704
707
  // ─── 진입점 ───────────────────────────────────────────────────────────────────
705
708
 
706
709
  export async function createBaseFiles(rootDir, config) {
710
+ await write(path.join(rootDir, ".env"), "FIGMA_TOKEN=\n");
711
+
707
712
  if (config.framework === "react") {
708
713
  await createReactBaseFiles(rootDir, config);
709
714
  } else if (config.framework === "next") {
@@ -33,10 +33,10 @@ export async function createPackageJson(rootDir, config) {
33
33
  'react-dom': isReact ? versions['react-dom'] : versions['next-react-dom'],
34
34
  ...(isReact ? {} : { next: versions.next }),
35
35
  zustand: versions.zustand,
36
+ '@tanstack/react-query': versions['@tanstack/react-query'],
36
37
  ...(isReact
37
38
  ? {
38
39
  axios: versions.axios,
39
- '@tanstack/react-query': versions['@tanstack/react-query'],
40
40
  }
41
41
  : {}),
42
42
  zod: versions.zod,
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
+ }