@cartanova/qgrid-cli 2.5.10 → 2.6.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.
Files changed (54) hide show
  1. package/README.ko.md +0 -1
  2. package/README.md +0 -1
  3. package/bundle/dist/application/qgrid/oauth.js +3 -2
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +90 -24
  6. package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
  7. package/bundle/dist/application/request-log/request-log.model.js +3 -2
  8. package/bundle/dist/application/request-log/request-log.types.js +4 -2
  9. package/bundle/dist/application/sonamu.generated.js +8 -1
  10. package/bundle/dist/application/sonamu.generated.sso.js +2 -1
  11. package/bundle/dist/i18n/sd.generated.js +2 -1
  12. package/bundle/dist/migrations/20260806141701_alter_request_logs_add1_alter1.js +15 -0
  13. package/bundle/src/application/qgrid/oauth.ts +3 -0
  14. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +151 -0
  15. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +14 -0
  16. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +3 -0
  17. package/bundle/src/application/qgrid/qgrid.frame.ts +110 -38
  18. package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
  19. package/bundle/src/application/request-log/request-log.entity.json +8 -0
  20. package/bundle/src/application/request-log/request-log.model.test.ts +3 -1
  21. package/bundle/src/application/request-log/request-log.model.ts +7 -1
  22. package/bundle/src/application/request-log/request-log.types.ts +5 -0
  23. package/bundle/src/application/sonamu.generated.http +10 -2
  24. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  25. package/bundle/src/application/sonamu.generated.ts +13 -0
  26. package/bundle/src/i18n/sd.generated.ts +1 -0
  27. package/bundle/src/migrations/20260806141701_alter_request_logs_add1_alter1.ts +15 -0
  28. package/bundle/web-dist/client/assets/{index-SdI2T3b8.js → index-BGGBFmzE.js} +3 -3
  29. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +1 -0
  30. package/bundle/web-dist/client/assets/{logs-O-8hGStp.js → logs-Dh19u6Tk.js} +1 -1
  31. package/bundle/web-dist/client/assets/{monit-DA8MAY-d.js → monit-CyqMmy1G.js} +1 -1
  32. package/bundle/web-dist/client/assets/{routes-COPjkgNr.js → routes-Cy0gCq8c.js} +1 -1
  33. package/bundle/web-dist/client/assets/{sd.generated-DbBC73_z.js → sd.generated-CjW3rfJ8.js} +1 -1
  34. package/bundle/web-dist/client/assets/{services.generated-BWyAypSh.js → services.generated-BuT6Gp8V.js} +1 -1
  35. package/bundle/web-dist/client/assets/show-C5WxfoAt.js +30 -0
  36. package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +1 -0
  37. package/bundle/web-dist/client/assets/{use-update-token-mutation-DeDBiZiM.js → use-update-token-mutation-DBkxL29Y.js} +1 -1
  38. package/bundle/web-dist/client/index.html +3 -3
  39. package/bundle/web-dist/server/assets/{lazyRouteComponent-BCJr868T.js → lazyRouteComponent-PRxXWW3t.js} +1 -1
  40. package/bundle/web-dist/server/assets/{logs-33YNafdW.js → logs-D0z1Ghj1.js} +3 -3
  41. package/bundle/web-dist/server/assets/{monit--9mVsjNU.js → monit-dBXxnAyI.js} +2 -2
  42. package/bundle/web-dist/server/assets/{routes-BK3Xh1UB.js → routes-B3p7fuwk.js} +3 -3
  43. package/bundle/web-dist/server/assets/{sd.generated-C3gcvRcU.js → sd.generated-ek-tz-7H.js} +1 -0
  44. package/bundle/web-dist/server/assets/{services.generated-B2_Auwvk.js → services.generated-BNuxWs7K.js} +10 -1
  45. package/bundle/web-dist/server/assets/{show-Dxxxe608.js → show-0iNioJ2I.js} +39 -13
  46. package/bundle/web-dist/server/assets/{tokens-BWPVvdfi.js → tokens-CvytQ9Xy.js} +86 -18
  47. package/bundle/web-dist/server/assets/{use-update-token-mutation-CNBgTysy.js → use-update-token-mutation-BBCC2GKM.js} +1 -1
  48. package/bundle/web-dist/server/entry-server.generated.js +8 -8
  49. package/package.json +1 -1
  50. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -2
  51. package/skills/qgrid/references/token-auth-quota-lifecycle.md +5 -1
  52. package/bundle/web-dist/client/assets/index-B-cZKEyf.css +0 -1
  53. package/bundle/web-dist/client/assets/show-ClG4e0BP.js +0 -30
  54. package/bundle/web-dist/client/assets/tokens-Cc5frn5f.js +0 -1
@@ -19,6 +19,7 @@ import { TokenCredentials } from "../token/token.types";
19
19
  import {
20
20
  type AnthropicUsageRaw,
21
21
  buildAuthUrl,
22
+ CONSOLE_CALLBACK_URL,
22
23
  exchangeCodeForTokens,
23
24
  fetchUsage,
24
25
  generatePKCE,
@@ -80,12 +81,50 @@ async function deleteOAuthState(state: string): Promise<void> {
80
81
  await cache.delete({ key: `${OAUTH_STATE_PREFIX}${state}` });
81
82
  }
82
83
 
83
- function getOAuthRedirectUri(): string {
84
- const publicBaseUrl = process.env.QGRID_PUBLIC_BASE_URL?.replace(/\/+$/, "");
85
- if (publicBaseUrl) return `${publicBaseUrl}/callback`;
84
+ // OAuth 접속 주소는 브라우저가 보낸 Origin(우선) 또는 Host 헤더에서 파생한다.
85
+ // HTTP context 가 없는 직접 호출(테스트 등)은 null 을 반환한다.
86
+ function deriveRequestBaseUrl(): string | null {
87
+ try {
88
+ const { headers, request } = Sonamu.getContext();
89
+ const origin = headers.origin;
90
+ if (typeof origin === "string" && /^https?:\/\//.test(origin)) {
91
+ return origin.replace(/\/+$/, "");
92
+ }
93
+ const forwardedHost = headers["x-forwarded-host"];
94
+ const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? headers.host;
95
+ if (host) {
96
+ const forwardedProto = headers["x-forwarded-proto"];
97
+ const proto =
98
+ (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0] ??
99
+ request.protocol;
100
+ return `${proto}://${host}`;
101
+ }
102
+ } catch {
103
+ // direct frame call — HTTP context 없음
104
+ }
105
+ return null;
106
+ }
86
107
 
87
- const serverPort = process.env.PORT ?? "44900";
88
- return `http://localhost:${serverPort}/callback`;
108
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
109
+
110
+ function isLoopbackBase(base: string): boolean {
111
+ try {
112
+ return LOOPBACK_HOSTNAMES.has(new URL(base).hostname);
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // Anthropic OAuth client 는 redirect_uri 허용 목록을 강제한다: 루프백(localhost)과
119
+ // 콘솔 콜백만 허용된다. 따라서 루프백 접속은 자동 리다이렉트 플로우를,
120
+ // 원격 접속은 콘솔 콜백 + 코드 붙여넣기 플로우(Claude Code CLI 와 동일)를 쓴다.
121
+ function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
122
+ const base = deriveRequestBaseUrl();
123
+ if (base && !isLoopbackBase(base)) {
124
+ return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
125
+ }
126
+ const loopbackBase = base ?? `http://localhost:${process.env.PORT ?? "44900"}`;
127
+ return { redirectUri: `${loopbackBase}/callback`, mode: "redirect" };
89
128
  }
90
129
 
91
130
  function rejectImageGenerationStream(args: QueryInput): void {
@@ -516,54 +555,87 @@ class QgridFrameClass extends BaseFrameClass {
516
555
  async oauthStart(name: string): Promise<OAuthStartResult> {
517
556
  const { codeVerifier, codeChallenge, state } = generatePKCE();
518
557
 
519
- const redirectUri = getOAuthRedirectUri();
558
+ const { redirectUri, mode } = resolveOAuthRedirect();
520
559
  const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
521
560
 
522
561
  await setOAuthState(state, { codeVerifier, name, redirectUri });
523
562
 
524
- return { authUrl };
563
+ return { authUrl, mode };
525
564
  }
526
565
 
527
- async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
566
+ // 코드 붙여넣기 플로우(원격 접속): 콘솔 콜백이 표시한 `code#state` 를 받아 교환한다.
567
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
568
+ async oauthComplete(pastedCode: string): Promise<{ added: boolean; name: string }> {
569
+ const trimmed = pastedCode.trim();
570
+ const separatorAt = trimmed.indexOf("#");
571
+ const code = separatorAt > 0 ? trimmed.slice(0, separatorAt) : "";
572
+ const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1) : "";
573
+ if (!code || !state) {
574
+ throw new BadRequestException(
575
+ "expected the code shown after login, in code#state form" as LocalizedString,
576
+ );
577
+ }
578
+
528
579
  const pending = await getOAuthState(state);
529
580
  if (!pending) {
530
- logger.warn("oauth callback: invalid_state");
531
- return reply.redirect("/?oauth=error&reason=invalid_state");
581
+ throw new BadRequestException(
582
+ "login session not found or expired — start the login again" as LocalizedString,
583
+ );
532
584
  }
533
585
  await deleteOAuthState(state);
534
586
 
535
- try {
536
- const tokens = await exchangeCodeForTokens(
537
- code,
538
- pending.codeVerifier,
539
- state,
540
- pending.redirectUri,
541
- );
587
+ await this.completeAnthropicLogin(code, state, pending);
588
+ return { added: true, name: pending.name };
589
+ }
542
590
 
543
- if (tokens.accountUuid) {
544
- const oldEntries = await TokenModel.findByAccountIdentifier(
545
- "A",
546
- "anthropic",
547
- tokens.accountUuid,
548
- );
549
- if (oldEntries.length > 0) {
550
- await TokenModel.del(oldEntries.map((o) => o.id));
551
- }
591
+ // 교환 + 계정 중복 제거 + 저장 — redirect 콜백과 코드 붙여넣기 플로우가 공유한다.
592
+ private async completeAnthropicLogin(
593
+ code: string,
594
+ state: string,
595
+ pending: PendingOAuth,
596
+ ): Promise<void> {
597
+ const tokens = await exchangeCodeForTokens(
598
+ code,
599
+ pending.codeVerifier,
600
+ state,
601
+ pending.redirectUri,
602
+ );
603
+
604
+ if (tokens.accountUuid) {
605
+ const oldEntries = await TokenModel.findByAccountIdentifier(
606
+ "A",
607
+ "anthropic",
608
+ tokens.accountUuid,
609
+ );
610
+ if (oldEntries.length > 0) {
611
+ await TokenModel.del(oldEntries.map((o) => o.id));
552
612
  }
613
+ }
553
614
 
554
- await TokenModel.save([
555
- {
556
- provider: "anthropic",
557
- credentials: {
558
- accessToken: tokens.accessToken,
559
- refreshToken: tokens.refreshToken,
560
- expiresAt: tokens.expiresAt ?? 0,
561
- accountUuid: tokens.accountUuid ?? "",
562
- },
563
- name: pending.name,
615
+ await TokenModel.save([
616
+ {
617
+ provider: "anthropic",
618
+ credentials: {
619
+ accessToken: tokens.accessToken,
620
+ refreshToken: tokens.refreshToken,
621
+ expiresAt: tokens.expiresAt ?? 0,
622
+ accountUuid: tokens.accountUuid ?? "",
564
623
  },
565
- ]);
624
+ name: pending.name,
625
+ },
626
+ ]);
627
+ }
566
628
 
629
+ async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
630
+ const pending = await getOAuthState(state);
631
+ if (!pending) {
632
+ logger.warn("oauth callback: invalid_state");
633
+ return reply.redirect("/?oauth=error&reason=invalid_state");
634
+ }
635
+ await deleteOAuthState(state);
636
+
637
+ try {
638
+ await this.completeAnthropicLogin(code, state, pending);
567
639
  return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
568
640
  } catch (e) {
569
641
  return reply.redirect(`/?oauth=error&reason=${encodeURIComponent((e as Error).message)}`);
@@ -608,7 +680,7 @@ class QgridFrameClass extends BaseFrameClass {
608
680
  logger.warn(`OpenAI browser login failed: ${(e as Error).message}`);
609
681
  });
610
682
 
611
- return { authUrl };
683
+ return { authUrl, mode: "redirect" };
612
684
  }
613
685
 
614
686
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
@@ -274,6 +274,9 @@ export type TokenStats = z.infer<typeof TokenStats>;
274
274
 
275
275
  export const OAuthStartResult = z.object({
276
276
  authUrl: z.string(),
277
+ // redirect: 브라우저 자동 복귀(루프백 접속). code: 콘솔 콜백 + 코드 붙여넣기(원격 접속).
278
+ // OpenAI 경로는 codex 가 콜백을 처리하므로 항상 redirect 를 반환한다.
279
+ mode: z.enum(["redirect", "code"]),
277
280
  });
278
281
  export type OAuthStartResult = z.infer<typeof OAuthStartResult>;
279
282
 
@@ -97,6 +97,13 @@
97
97
  "nullable": true,
98
98
  "desc": "inject_items 히스토리"
99
99
  },
100
+ {
101
+ "name": "tools",
102
+ "type": "json",
103
+ "id": "ToolDefinitions",
104
+ "nullable": true,
105
+ "desc": "요청에 장착된 tool 정의 목록"
106
+ },
100
107
  {
101
108
  "name": "status",
102
109
  "type": "enum",
@@ -153,6 +160,7 @@
153
160
  "image_cost_method",
154
161
  "effort",
155
162
  "history",
163
+ "tools",
156
164
  "status",
157
165
  "error_message",
158
166
  "tool_call_count",
@@ -113,7 +113,7 @@ describe("RequestLogModel run lifecycle", () => {
113
113
  vi.restoreAllMocks();
114
114
  });
115
115
 
116
- it("stores the requested model but no serving model when a run starts", async () => {
116
+ it("stores the requested model and attached tools but no serving model when a run starts", async () => {
117
117
  const ubRegister = vi.fn();
118
118
  const transaction = vi.fn(
119
119
  async (cb: (trx: { ubUpsert: () => Promise<number[]> }) => Promise<number>) =>
@@ -130,6 +130,7 @@ describe("RequestLogModel run lifecycle", () => {
130
130
  RequestLogModel.createRun({
131
131
  user_prompt: "hi",
132
132
  requested_model_name: "openai/gpt-5-codex",
133
+ tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
133
134
  }),
134
135
  ).resolves.toBe(17);
135
136
 
@@ -139,6 +140,7 @@ describe("RequestLogModel run lifecycle", () => {
139
140
  status: "running",
140
141
  requested_model_name: "openai/gpt-5-codex",
141
142
  model_name: null,
143
+ tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
142
144
  }),
143
145
  );
144
146
  });
@@ -14,7 +14,11 @@ import { SD } from "../../i18n/sd.generated";
14
14
  import { calculateCostUsd } from "../../utils/providers/common/model-cost";
15
15
  import { type RequestLogSubsetKey, type RequestLogSubsetMapping } from "../sonamu.generated";
16
16
  import { requestLogLoaderQueries, requestLogSubsetQueries } from "../sonamu.generated.sso";
17
- import { type RequestLogListParams, type RequestLogSaveParams } from "./request-log.types";
17
+ import {
18
+ type RequestLogListParams,
19
+ type RequestLogSaveParams,
20
+ type ToolDefinitions,
21
+ } from "./request-log.types";
18
22
 
19
23
  // cost_usd는 정수 micro-USD로 저장. 실제 USD = cost_usd / MICRO_USD.
20
24
  export const MICRO_USD = 1_000_000;
@@ -287,6 +291,7 @@ class RequestLogModelClass extends BaseModelClass<
287
291
  effort?: string | null;
288
292
  project_name?: string | null;
289
293
  history?: unknown;
294
+ tools?: ToolDefinitions;
290
295
  is_image_generation?: boolean;
291
296
  }): Promise<number> {
292
297
  const wdb = this.getPuri("w");
@@ -309,6 +314,7 @@ class RequestLogModelClass extends BaseModelClass<
309
314
  // 이미지 turn 식별(R13). run 경로(tools+image 조합)도 auto 경로와 동일하게 마킹.
310
315
  is_image_generation: params.is_image_generation ?? false,
311
316
  ...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
317
+ ...(params.tools !== undefined ? { tools: params.tools } : {}),
312
318
  });
313
319
  return wdb.transaction(async (trx) => {
314
320
  const ids = await trx.ubUpsert("request_logs");
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { QgridTool } from "../qgrid/qgrid.types";
3
4
  import { RequestLogBaseListParams, RequestLogBaseSchema } from "../sonamu.generated";
4
5
 
5
6
  export const HistoryItems = z.array(
@@ -15,6 +16,10 @@ export const HistoryItems = z.array(
15
16
  );
16
17
  export type HistoryItems = z.infer<typeof HistoryItems>;
17
18
 
19
+ // 요청에 장착된 tool 정의 목록 — 저장 형태는 QgridTool 계약을 그대로 따른다.
20
+ export const ToolDefinitions = z.array(QgridTool);
21
+ export type ToolDefinitions = z.infer<typeof ToolDefinitions>;
22
+
18
23
  // RequestLog - ListParams
19
24
  // project_name NULL 분기: IS NULL만 조회(unassigned 필터) 또는 IS NOT NULL만(distinct 조회).
20
25
  export const RequestLogListParams = RequestLogBaseListParams.extend({
@@ -199,6 +199,7 @@ Content-Type: application/json
199
199
  "image_cost_method": null,
200
200
  "effort": null,
201
201
  "history": null,
202
+ "tools": null,
202
203
  "status": "running",
203
204
  "error_message": null,
204
205
  "tool_call_count": 0,
@@ -252,7 +253,6 @@ Content-Type: application/json
252
253
  "serviceTier": "fast",
253
254
  "history": "HISTORY",
254
255
  "projectName": "PROJECTNAME",
255
- "isStep": false,
256
256
  "logger": false,
257
257
  "runContext": {
258
258
  "requestLogId": 0,
@@ -313,7 +313,6 @@ Content-Type: application/json
313
313
  "serviceTier": "fast",
314
314
  "history": "HISTORY",
315
315
  "projectName": "PROJECTNAME",
316
- "isStep": false,
317
316
  "logger": false,
318
317
  "runContext": {
319
318
  "requestLogId": 0,
@@ -501,6 +500,15 @@ Content-Type: application/json
501
500
 
502
501
  ###
503
502
 
503
+ POST {{baseUrl}}/api/qgrid/oauthComplete
504
+ Content-Type: application/json
505
+
506
+ {
507
+ "pastedCode": "PASTEDCODE"
508
+ }
509
+
510
+ ###
511
+
504
512
  POST {{baseUrl}}/api/qgrid/oauthStartOpenAI
505
513
  Content-Type: application/json
506
514
 
@@ -42,6 +42,7 @@ export const requestLogSubsetQueries = {
42
42
  image_cost_method: "request_logs.image_cost_method",
43
43
  effort: "request_logs.effort",
44
44
  history: "request_logs.history",
45
+ tools: "request_logs.tools",
45
46
  status: "request_logs.status",
46
47
  error_message: "request_logs.error_message",
47
48
  tool_call_count: "request_logs.tool_call_count",
@@ -42,6 +42,16 @@ const TokenCredentials = z.union([
42
42
  ]);
43
43
  type TokenCredentials = z.infer<typeof TokenCredentials>;
44
44
 
45
+ // CustomScalar: ToolDefinitions
46
+ const ToolDefinitions = z.array(
47
+ z.object({
48
+ name: z.string(),
49
+ description: z.string().optional(),
50
+ inputSchema: z.unknown(),
51
+ }),
52
+ );
53
+ type ToolDefinitions = z.infer<typeof ToolDefinitions>;
54
+
45
55
  // Enums: RequestLog
46
56
  export const RequestLogOrderBy = z.enum(["id-desc"]).describe("RequestLogOrderBy");
47
57
  export type RequestLogOrderBy = z.infer<typeof RequestLogOrderBy>;
@@ -111,6 +121,7 @@ export const RequestLogBaseSchema = z.object({
111
121
  image_cost_method: z.string().max(100).nullable(),
112
122
  effort: z.string().max(10).nullable(),
113
123
  history: HistoryItems.nullable(),
124
+ tools: ToolDefinitions.nullable(),
114
125
  status: RequestLogStatus,
115
126
  error_message: z.string().nullable(),
116
127
  tool_call_count: z.int(),
@@ -141,6 +152,7 @@ export type RequestLogBaseSchema = z.infer<typeof RequestLogBaseSchema> & {
141
152
  "image_cost_method",
142
153
  "effort",
143
154
  "history",
155
+ "tools",
144
156
  "status",
145
157
  "error_message",
146
158
  "tool_call_count",
@@ -308,6 +320,7 @@ export const RequestLogSubsetA = z.object({
308
320
  image_cost_method: z.string().max(100).nullable(),
309
321
  effort: z.string().max(10).nullable(),
310
322
  history: HistoryItems.nullable(),
323
+ tools: ToolDefinitions.nullable(),
311
324
  status: RequestLogStatus,
312
325
  error_message: z.string().nullable(),
313
326
  tool_call_count: z.int(),
@@ -234,6 +234,7 @@ const entityLabels = {
234
234
  "entity.RequestLog.image_cost_method": "이미지 비용 추정 방식",
235
235
  "entity.RequestLog.effort": "effort level",
236
236
  "entity.RequestLog.history": "inject_items 히스토리",
237
+ "entity.RequestLog.tools": "요청에 장착된 tool 정의 목록",
237
238
  "entity.RequestLog.status": "실행 상태",
238
239
  "entity.RequestLog.error_message": "에러 메시지",
239
240
  "entity.RequestLog.tool_call_count": "tool call 횟수",
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("request_logs", (table) => {
5
+ // add
6
+ table.jsonb("tools").nullable();
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("request_logs", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("tools");
14
+ });
15
+ }