byuckchon-frontend-cli 1.9.1 → 1.9.2

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
@@ -193,6 +193,10 @@ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙
193
193
 
194
194
  - **OpenAPI**: chat 시작 시 자동 fetch + 1시간 디스크 캐시 → 엔드포인트 요약을 시스템 프롬프트에 박음.
195
195
  - 헤더에 `openapi` 줄로 표시. 캐시 hit 면 `(cached)`, fresh fetch 면 `(live)`.
196
+ - **세션 중 서버가 스펙을 바꿔도 자동 대응 (v1.10+)**: `search_openapi` / `get_openapi_endpoint` 가
197
+ 캐시에서 엔드포인트를 못 찾으면 **딱 한 번 최신본을 다시 받아 재검색**합니다 (`🔄 OpenAPI 스펙 새로고침`).
198
+ 남용 방지를 위해 세션당 횟수·간격이 제한됩니다. "방금 스웨거 업데이트했어, 다시 읽어줘" 라고 하면
199
+ 즉시 강제 새로고침(`refresh_openapi`)합니다.
196
200
  - **코드 인덱스**: chat 시작 시 인덱스 파일이 없으면 **백그라운드에서 자동 빌드**.
197
201
  - 빌드 중에는 화면에 `📚 인덱싱 중 ...` 진행 표시. 끝나면 `✓` 메시지 한 줄.
198
202
  - OpenAI 키가 없으면 빌드를 건너뛰고 도움 메시지를 띄움 (Anthropic 은 임베딩 API 미제공).
package/bin/index.js CHANGED
@@ -28,7 +28,7 @@ const program = new Command();
28
28
  program
29
29
  .name('bc')
30
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
31
- .version('1.9.0');
31
+ .version('1.10.0');
32
32
 
33
33
  program
34
34
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
package/src/ai/tools.js CHANGED
@@ -44,6 +44,39 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
44
44
  return _openapiDocPromise;
45
45
  }
46
46
 
47
+ // ── live refetch (서버가 세션 도중 스펙을 바꾼 경우 대비) ──
48
+ // 남용 방지: 세션당 최대 횟수 + 최소 간격 throttle.
49
+ const REFRESH_MAX = 6;
50
+ const REFRESH_MIN_INTERVAL_MS = 10_000;
51
+ let _refreshCount = 0;
52
+ let _lastRefreshAt = 0;
53
+
54
+ /**
55
+ * 캐시를 무시하고 OpenAPI 스펙을 다시 fetch 한다.
56
+ * @returns {Promise<{ doc: object|null, refreshed: boolean, reason?: string }>}
57
+ */
58
+ async function refreshOpenApiDoc() {
59
+ if (!openapiSource) return { doc: null, refreshed: false, reason: 'no-source' };
60
+
61
+ const now = Date.now();
62
+ if (_refreshCount >= REFRESH_MAX) {
63
+ return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'limit' };
64
+ }
65
+ if (now - _lastRefreshAt < REFRESH_MIN_INTERVAL_MS) {
66
+ return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'throttled' };
67
+ }
68
+
69
+ _refreshCount += 1;
70
+ _lastRefreshAt = now;
71
+ _openapiDocPromise = getCachedOpenApi(openapiSource, { force: true })
72
+ .then((res) => res.doc ?? null)
73
+ .catch(() => null);
74
+
75
+ const doc = await _openapiDocPromise;
76
+ onEvent({ kind: 'openapi_refreshed', ok: !!doc });
77
+ return { doc, refreshed: true };
78
+ }
79
+
47
80
  function safePath(p) {
48
81
  if (!p || typeof p !== 'string') {
49
82
  throw new Error('path 가 비어있습니다');
@@ -188,7 +221,11 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
188
221
  // ─────────── OpenAPI 툴 ───────────
189
222
 
190
223
  async function searchOpenApi({ query, limit = 40 }) {
191
- const doc = await loadOpenApiDoc();
224
+ let doc = await loadOpenApiDoc();
225
+ if (!doc) {
226
+ // 첫 로드 실패면 한 번 live refetch 시도.
227
+ ({ doc } = await refreshOpenApiDoc());
228
+ }
192
229
  if (!doc) {
193
230
  return {
194
231
  ok: false,
@@ -197,11 +234,25 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
197
234
  '(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
198
235
  };
199
236
  }
200
- const hits = searchEndpoints(doc, query, { limit });
237
+
238
+ let hits = searchEndpoints(doc, query, { limit });
239
+ let refreshed = false;
240
+
241
+ // 캐시된 스펙에서 못 찾으면 → 서버에서 방금 추가됐을 수 있으니 딱 한 번 live refetch 후 재검색.
242
+ if (hits.length === 0) {
243
+ const r = await refreshOpenApiDoc();
244
+ if (r.refreshed && r.doc) {
245
+ refreshed = true;
246
+ doc = r.doc;
247
+ hits = searchEndpoints(doc, query, { limit });
248
+ }
249
+ }
250
+
201
251
  return {
202
252
  ok: true,
203
253
  query,
204
254
  count: hits.length,
255
+ refreshed,
205
256
  endpoints: hits.map((e) => ({
206
257
  method: e.method,
207
258
  path: e.path,
@@ -210,17 +261,63 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
210
261
  })),
211
262
  hint:
212
263
  hits.length === 0
213
- ? '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.'
214
- : '상세 스키마가 필요하면 get_openapi_endpoint(path, method)호출하세요.',
264
+ ? (refreshed
265
+ ? '최신 스펙을 다시 받아왔는데도 매치가 없습니다. 다른 키워드로 재시도하거나 query 비워 전체 목록을 확인하세요.'
266
+ : '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.')
267
+ : (refreshed
268
+ ? '캐시엔 없던 항목을 최신 스펙에서 찾았습니다. 상세는 get_openapi_endpoint(path, method).'
269
+ : '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.'),
215
270
  };
216
271
  }
217
272
 
218
273
  async function getOpenApiEndpoint({ path: epPath, method }) {
219
- const doc = await loadOpenApiDoc();
274
+ let doc = await loadOpenApiDoc();
275
+ if (!doc) {
276
+ ({ doc } = await refreshOpenApiDoc());
277
+ }
220
278
  if (!doc) {
221
279
  return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
222
280
  }
223
- return getEndpoint(doc, epPath, method);
281
+
282
+ let result = getEndpoint(doc, epPath, method);
283
+
284
+ // 못 찾으면 → 최신 스펙으로 한 번 더.
285
+ if (!result.ok) {
286
+ const r = await refreshOpenApiDoc();
287
+ if (r.refreshed && r.doc) {
288
+ const retry = getEndpoint(r.doc, epPath, method);
289
+ if (retry.ok) result = { ...retry, refreshed: true };
290
+ else result = { ...retry, refreshed: true };
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+
296
+ async function refreshOpenApi() {
297
+ if (!openapiSource) {
298
+ return { ok: false, error: 'bc.config.json 에 api.openapi 가 설정되어 있지 않습니다.' };
299
+ }
300
+ const r = await refreshOpenApiDoc();
301
+ if (!r.doc) {
302
+ return {
303
+ ok: false,
304
+ refreshed: r.refreshed,
305
+ error:
306
+ r.reason === 'throttled'
307
+ ? '방금 새로고침했습니다. 잠시 후 다시 시도하세요.'
308
+ : '스펙을 다시 받아오지 못했습니다 (네트워크/URL 확인).',
309
+ };
310
+ }
311
+ const count = (r.doc.paths ? Object.keys(r.doc.paths).length : 0);
312
+ return {
313
+ ok: true,
314
+ refreshed: r.refreshed,
315
+ reason: r.refreshed ? undefined : r.reason,
316
+ paths: count,
317
+ message: r.refreshed
318
+ ? `최신 OpenAPI 스펙을 다시 받아왔습니다 (path ${count}개).`
319
+ : '최근에 이미 새로고침되어 캐시를 재사용했습니다.',
320
+ };
224
321
  }
225
322
 
226
323
  // ─────────── Figma 툴 ───────────
@@ -381,6 +478,20 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
381
478
  }),
382
479
  execute: getOpenApiEndpoint,
383
480
  }),
481
+ refresh_openapi: tool({
482
+ description:
483
+ '연결된 OpenAPI(Swagger) 스펙을 캐시 무시하고 서버에서 다시 받아온다. ' +
484
+ '사용자가 "방금 스웨거(백엔드 API) 를 업데이트했다 / 다시 읽어라" 라고 하거나, ' +
485
+ 'search_openapi 가 분명히 있어야 할 엔드포인트를 못 찾을 때 호출. ' +
486
+ '(search_openapi / get_openapi_endpoint 는 못 찾으면 자동으로 한 번 새로고침하므로, ' +
487
+ '명시적 요청이 있을 때만 직접 부르면 된다.)',
488
+ inputSchema: jsonSchema({
489
+ type: 'object',
490
+ properties: {},
491
+ additionalProperties: false,
492
+ }),
493
+ execute: refreshOpenApi,
494
+ }),
384
495
  write_file: tool({
385
496
  description:
386
497
  '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
@@ -121,6 +121,9 @@ export async function chatCommand(opts = {}) {
121
121
  '가져와서 zod/타입/요청 함수를 만든다. ' +
122
122
  '예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
123
123
  '`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
124
+ '스펙은 캐시(최대 1시간)라 서버가 방금 바꿨으면 오래됐을 수 있다 — ' +
125
+ 'search_openapi/get_openapi_endpoint 는 못 찾으면 자동으로 한 번 최신본을 다시 받아온다. ' +
126
+ '사용자가 "방금 스웨거 업데이트했어/다시 읽어" 라고 하면 `refresh_openapi()` 를 먼저 호출한다. ' +
124
127
  '이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
125
128
  }
126
129
  } catch {
@@ -264,6 +267,12 @@ async function runOnce({ cfg, resolved, system, prompt }) {
264
267
  effective: cfg.effective,
265
268
  openapiSource: cfg.effective.api?.openapi ?? null,
266
269
  onEvent: (ev) => {
270
+ if (ev.kind === 'openapi_refreshed') {
271
+ console.log(
272
+ chalk.dim(ev.ok ? ' 🔄 OpenAPI 스펙 새로고침' : ' ⚠️ OpenAPI 새로고침 실패'),
273
+ );
274
+ return;
275
+ }
267
276
  const label =
268
277
  ev.kind === 'write_created'
269
278
  ? '🆕'
@@ -489,6 +498,12 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
489
498
  effective: cfg.effective,
490
499
  openapiSource: cfg.effective.api?.openapi ?? null,
491
500
  onEvent: (ev) => {
501
+ if (ev.kind === 'openapi_refreshed') {
502
+ console.log(
503
+ chalk.dim(ev.ok ? '\n 🔄 OpenAPI 스펙 새로고침' : '\n ⚠️ OpenAPI 새로고침 실패'),
504
+ );
505
+ return;
506
+ }
492
507
  const label =
493
508
  ev.kind === 'write_created'
494
509
  ? '🆕 생성'
@@ -23,8 +23,13 @@ function keyFor(input) {
23
23
  *
24
24
  * - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
25
25
  * - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
26
+ *
27
+ * @param {string} input OpenAPI URL 또는 파일 경로
28
+ * @param {object} [opts]
29
+ * @param {boolean} [opts.force] true 면 TTL 무시하고 무조건 live refetch (캐시 갱신).
30
+ * 서버가 세션 도중 스펙을 바꿨을 때 사용.
26
31
  */
27
- export async function getCachedOpenApi(input) {
32
+ export async function getCachedOpenApi(input, { force = false } = {}) {
28
33
  const dir = await getCacheDir();
29
34
  const file = path.join(dir, `openapi-${keyFor(input)}.json`);
30
35
 
@@ -39,7 +44,7 @@ export async function getCachedOpenApi(input) {
39
44
  /* miss */
40
45
  }
41
46
 
42
- if (cachedFresh && cached) {
47
+ if (!force && cachedFresh && cached) {
43
48
  return { doc: cached, cached: true, source: input };
44
49
  }
45
50
 
package/src/ui/ChatApp.js CHANGED
@@ -871,6 +871,18 @@ export function ChatApp({
871
871
 
872
872
  // 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
873
873
  const onToolEvent = (ev) => {
874
+ if (ev.kind === 'openapi_refreshed') {
875
+ setMessages((m) => [
876
+ ...m,
877
+ {
878
+ role: 'system-info',
879
+ text: ev.ok
880
+ ? '🔄 OpenAPI 스펙 새로고침 (서버에서 최신본 다시 받음)'
881
+ : '⚠️ OpenAPI 새로고침 실패 (네트워크/URL 확인)',
882
+ },
883
+ ]);
884
+ return;
885
+ }
874
886
  const labels = {
875
887
  write_created: '🆕 생성',
876
888
  write_overwritten: '✏️ 덮어씀',