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.
- package/README.md +13 -9
- package/bin/index.js +9 -1
- package/package.json +8 -8
- package/src/ai/models.js +21 -12
- package/src/commands/add.js +82 -0
- package/src/commands/adopt.js +19 -2
- package/src/commands/chat.js +28 -1
- package/src/commands/init.js +85 -36
- package/src/config/projectLinks.js +49 -0
- package/src/context/monorepo.js +98 -0
- package/src/generators/createApp.js +211 -0
- package/src/generators/createBaseFiles.js +5 -0
- package/src/generators/createMonorepo.js +514 -0
- package/src/generators/createPackageJson.js +1 -1
- package/src/generators/install.js +69 -0
- package/src/prompts/initPrompts.js +128 -0
- package/src/ui/ChatApp.js +52 -13
- package/src/utils/ensureRequiredDependencies.js +58 -0
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1011
|
-
// 1) input 이 메뉴의 아이템 cmd 와 정확히 일치 + 인자 필요 없는 명령 → 즉시 실행
|
|
1012
|
-
// 2) 그 외 → 선택 항목으로 자동완성만 (실행은 Enter 한 번 더)
|
|
1053
|
+
// 메뉴에서 선택한 명령도 인자가 없으면 즉시 실행하고, 인자가 필요할 때만 자동완성한다.
|
|
1013
1054
|
if (slashOpen) {
|
|
1014
1055
|
const sel = slashItems[Math.min(menuIndex, slashItems.length - 1)];
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
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
|
+
}
|