From dbdac6e68a77477360d3aa019538bb434384c8f7 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 15 Jun 2026 14:56:11 +0800 Subject: [PATCH] feat: add typed user query CLI --- tools/hwlab-cli/client.test.ts | 46 ++++ tools/src/hwlab-cli-lib.ts | 374 +++++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+) diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index d3cef5b0..3fb98068 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -2114,6 +2114,52 @@ test("hwlab-cli client request covers arbitrary Cloud Web same-origin API routes assert.equal(result.payload.body.fullBodyAvailable, true); }); +test("hwlab-cli client user query wrappers expose typed redacted summaries", async () => { + const calls: any[] = []; + const fetchImpl = async (url: string | URL, init: any = {}) => { + calls.push({ url: String(url), init }); + if (String(url).endsWith("/v1/users/me")) { + return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { id: "usr_test", username: "inner-test", role: "user", status: "active" }, authMethod: "user-billing-api-key", session: { id: "key_test", keyPrefix: "hwl_live_Zci", key: "hwl_live_should_not_print" } }), { status: 200 }); + } + if (String(url).endsWith("/v1/access/status")) { + return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { id: "usr_test", username: "inner-test", role: "user" }, authMethod: "user-billing-api-key", permissions: ["code_agent"], routes: { currentUser: "/v1/users/me" } }), { status: 200 }); + } + if (String(url).endsWith("/v1/api-keys")) { + return new Response(JSON.stringify({ ok: true, count: 1, items: [{ id: "key_test", name: "fixed", keyPrefix: "hwl_live_Zci", key: "hwl_live_raw_secret", scopes: ["api"], status: "active", valuesRedacted: true }] }), { status: 200 }); + } + if (String(url).endsWith("/v1/billing/summary?limit=7")) { + return new Response(JSON.stringify({ ok: true, credits: { balance: 99, reserved: 0 }, ledger: { count: 1, items: [{ id: "led_test", kind: "usage", reason: "hwlab-code-agent", deltaCredits: -1, metadata: { traceId: "trc_test", sessionId: "ses_test" } }] }, usage: { recordCount: 1, byService: [{ service: "hwlab-code-agent", credits: 1 }] } }), { status: 200 }); + } + if (String(url).endsWith("/v1/usage/summary")) { + return new Response(JSON.stringify({ ok: true, usage: { recordCount: 1, byService: [{ service: "hwlab-code-agent", credits: 1 }], lastUsedAt: "2026-06-15T00:00:00Z" }, records: [{ id: "usage_test", service: "hwlab-code-agent", credits: 1, metadata: { traceId: "trc_test" } }] }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); + }; + const env = { HWLAB_API_KEY: "hwl_live_user_test_key" }; + + const account = await runHwlabCli(["client", "account", "status", "--base-url", "http://web.test"], { env, fetchImpl }); + const keys = await runHwlabCli(["client", "api-keys", "list", "--base-url", "http://web.test"], { env, fetchImpl }); + const billing = await runHwlabCli(["client", "billing", "summary", "--limit", "7", "--base-url", "http://web.test"], { env, fetchImpl }); + const usage = await runHwlabCli(["client", "usage", "summary", "--base-url", "http://web.test"], { env, fetchImpl }); + + assert.equal(account.exitCode, 0); + assert.equal(account.payload.action, "client.account.status"); + assert.equal(account.payload.identity.actor.username, "inner-test"); + assert.equal(account.payload.access.permissions[0], "code_agent"); + assert.equal(keys.exitCode, 0); + assert.equal(keys.payload.action, "client.api-keys.list"); + assert.equal(keys.payload.body.items[0].prefix, "hwl_live_Zci"); + assert.equal(billing.exitCode, 0); + assert.equal(billing.payload.route.path, "/v1/billing/summary?limit=7"); + assert.equal(billing.payload.body.ledger.items[0].traceId, "trc_test"); + assert.equal(usage.exitCode, 0); + assert.equal(usage.payload.body.usage.recordCount, 1); + assert.equal(calls.every((call) => call.init.headers.authorization === "Bearer hwl_live_user_test_key"), true); + assert.equal(calls.every((call) => call.init.headers.cookie === undefined), true); + assert.equal(JSON.stringify([account.payload, keys.payload, billing.payload, usage.payload]).includes("hwl_live_raw_secret"), false); + assert.equal(JSON.stringify([account.payload, keys.payload, billing.payload, usage.payload]).includes("hwl_live_should_not_print"), false); +}); + test("hwlab-cli client request compacts array responses by default", async () => { const result = await runHwlabCli([ "client", diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 21264658..47998a51 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -74,6 +74,10 @@ async function clientCommand(context: any) { if (groupHelp) return groupHelp; } if (group === "auth") return authCommand(next); + if (group === "account" || group === "user") return accountCommand(next); + if (group === "api-keys" || group === "apikeys" || group === "keys") return apiKeysCommand(next); + if (group === "billing") return billingCommand(next); + if (group === "usage") return usageCommand(next); if (group === "access") return accessCommand(next); if (group === "provider-profiles" || group === "provider-profile" || group === "providers") return providerProfilesCommand(next); if (group === "runtime") return runtimeCommand(next); @@ -89,6 +93,10 @@ async function clientCommand(context: any) { function clientSubcommandHelp(group: string) { if (group === "auth") return authHelp(); + if (group === "account" || group === "user") return accountHelp(); + if (group === "api-keys" || group === "apikeys" || group === "keys") return apiKeysHelp(); + if (group === "billing") return billingHelp(); + if (group === "usage") return usageHelp(); if (group === "access") return accessHelp(); if (group === "provider-profiles" || group === "provider-profile" || group === "providers") return providerProfilesHelp(); if (group === "runtime") return runtimeHelp(); @@ -120,6 +128,10 @@ function help() { "HWLAB_API_KEY=hwl_live_... HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth whoami", "HWLAB_API_KEY=hwl_live_... HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client request GET /v1/users/me", "hwlab-cli client auth status", + "hwlab-cli client account status", + "hwlab-cli client api-keys list", + "hwlab-cli client billing summary --limit 5", + "hwlab-cli client usage summary", "hwlab-cli client auth session --web-session", "hwlab-cli client auth profiles", "hwlab-cli client access summary", @@ -218,6 +230,117 @@ function authHelp() { }); } +async function accountCommand(context: any) { + const subcommand = context.rest[0] || "status"; + if (wantsHelp(context)) return accountHelp(); + if (subcommand !== "status" && subcommand !== "summary") throw cliError("unsupported_account_command", `unsupported account command: ${subcommand}`, { subcommand }); + const identityPath = "/v1/users/me"; + const accessPath = "/v1/access/status"; + const [identity, access] = await Promise.all([ + requestJson({ ...context, method: "GET", path: identityPath }), + requestJson({ ...context, method: "GET", path: accessPath }) + ]); + const success = responseSucceeded(identity) && responseSucceeded(access); + const endpoint = identity.runtimeEndpoint ?? access.runtimeEndpoint ?? runtimeEndpoint(context.parsed, context.env, context.endpointKind ?? "web"); + return ok("client.account.status", { + baseUrl: endpoint.baseUrl, + runtimeEndpoint: runtimeEndpointVisibility(endpoint), + routes: { + identity: route("GET", identityPath), + access: route("GET", accessPath) + }, + requests: { + identity: requestVisibility(identity), + access: requestVisibility(access) + }, + auth: identity.auth ?? access.auth, + authDiagnosis: identity.authDiagnosis ?? access.authDiagnosis ?? null, + identity: identityBodyForCli(identity.body, context.parsed), + access: accessStatusBodyForCli(access.body, context.parsed), + body: { + identity: identityBodyForCli(identity.body, context.parsed), + access: accessStatusBodyForCli(access.body, context.parsed) + } + }, success ? "succeeded" : "failed"); +} + +function accountHelp() { + return ok("client.account.help", { + serviceRuntime: false, + imagePublished: false, + sameOrigin: true, + valuesPrinted: false, + commands: [ + "status" + ], + routes: { + identity: route("GET", "/v1/users/me"), + access: route("GET", "/v1/access/status") + } + }); +} + +async function apiKeysCommand(context: any) { + const subcommand = context.rest[0] || "list"; + if (wantsHelp(context)) return apiKeysHelp(); + if (subcommand !== "list" && subcommand !== "summary") throw cliError("unsupported_api_keys_command", `unsupported api-keys command: ${subcommand}`, { subcommand }); + const pathName = "/v1/api-keys"; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.api-keys.list", response, context, { route: route("GET", pathName), body: apiKeysBodyForCli(response.body, context.parsed), valuesPrinted: false }); +} + +function apiKeysHelp() { + return ok("client.api-keys.help", { + serviceRuntime: false, + imagePublished: false, + sameOrigin: true, + valuesPrinted: false, + commands: ["list"], + routes: { list: route("GET", "/v1/api-keys") } + }); +} + +async function billingCommand(context: any) { + const subcommand = context.rest[0] || "summary"; + if (wantsHelp(context)) return billingHelp(); + if (subcommand !== "summary") throw cliError("unsupported_billing_command", `unsupported billing command: ${subcommand}`, { subcommand }); + const limit = boundedInteger(context.parsed.limit, 5, 1, 100); + const pathName = `/v1/billing/summary?limit=${encodeURIComponent(String(limit))}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.billing.summary", response, context, { route: route("GET", pathName), limit, availability: selfServiceAvailability(response, "billing-summary"), body: billingSummaryBodyForCli(response.body, context.parsed), valuesPrinted: false }); +} + +function billingHelp() { + return ok("client.billing.help", { + serviceRuntime: false, + imagePublished: false, + sameOrigin: true, + valuesPrinted: false, + commands: ["summary [--limit N]"], + routes: { summary: route("GET", "/v1/billing/summary?limit=N") } + }); +} + +async function usageCommand(context: any) { + const subcommand = context.rest[0] || "summary"; + if (wantsHelp(context)) return usageHelp(); + if (subcommand !== "summary") throw cliError("unsupported_usage_command", `unsupported usage command: ${subcommand}`, { subcommand }); + const pathName = "/v1/usage/summary"; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.usage.summary", response, context, { route: route("GET", pathName), availability: selfServiceAvailability(response, "usage-summary"), body: usageSummaryBodyForCli(response.body, context.parsed), valuesPrinted: false }); +} + +function usageHelp() { + return ok("client.usage.help", { + serviceRuntime: false, + imagePublished: false, + sameOrigin: true, + valuesPrinted: false, + commands: ["summary"], + routes: { summary: route("GET", "/v1/usage/summary") } + }); +} + async function accessCommand(context: any) { const subcommand = context.rest[0] || "summary"; if (wantsHelp(context)) return accessHelp(); @@ -2934,6 +3057,243 @@ function responseBodyForCli(body: any, parsed: ParsedArgs) { return parsed.full === true ? body : compactApiBody(body); } +function identityBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactUserQueryBody(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + return pruneUndefined({ + ok: body?.ok, + authenticated: source?.authenticated ?? body?.authenticated, + actor: actorForCli(source?.actor ?? body?.actor), + user: actorForCli(source?.user ?? body?.user), + authMethod: text(source?.authMethod ?? body?.authMethod) || undefined, + session: apiKeyMetadataForCli(source?.session ?? body?.session ?? source?.apiKey ?? body?.apiKey), + roles: arrayOfText(source?.roles ?? body?.roles), + permissions: arrayOfText(source?.permissions ?? body?.permissions), + scopes: arrayOfText(source?.scopes ?? body?.scopes), + error: errorForCli(body?.error), + valuesPrinted: false, + fullBodyAvailable: true + }); +} + +function accessStatusBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactUserQueryBody(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + const routes = source?.routes && typeof source.routes === "object" ? Object.keys(source.routes).sort() : undefined; + return pruneUndefined({ + ok: body?.ok, + authenticated: source?.authenticated ?? body?.authenticated, + actor: actorForCli(source?.actor ?? body?.actor), + user: actorForCli(source?.user ?? body?.user), + role: text(source?.role ?? body?.role) || undefined, + authMethod: text(source?.authMethod ?? body?.authMethod) || undefined, + permissions: arrayOfText(source?.permissions ?? body?.permissions), + scopes: arrayOfText(source?.scopes ?? body?.scopes), + roles: arrayOfText(source?.roles ?? body?.roles), + tools: source?.tools, + routes, + routeCount: routes?.length, + key: apiKeyMetadataForCli(source?.key ?? source?.apiKey ?? body?.key ?? body?.apiKey), + error: errorForCli(body?.error), + valuesPrinted: false, + fullBodyAvailable: true + }); +} + +function apiKeysBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactUserQueryBody(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + const items = firstArray(source?.items, source?.apiKeys, source?.keys, body?.items, body?.apiKeys, body?.keys); + return pruneUndefined({ + ok: body?.ok, + status: text(source?.status ?? body?.status) || undefined, + actor: actorForCli(source?.actor ?? body?.actor), + authMethod: text(source?.authMethod ?? body?.authMethod) || undefined, + items: items.map(apiKeyMetadataForCli).filter(Boolean), + count: source?.count ?? body?.count ?? items.length, + valuesPrinted: false, + fullBodyAvailable: true + }); +} + +function billingSummaryBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactUserQueryBody(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + const ledgerItems = ledgerItemsFromBody(source); + return pruneUndefined({ + ok: body?.ok, + status: text(source?.status ?? body?.status) || undefined, + actor: actorForCli(source?.actor ?? body?.actor), + authMethod: text(source?.authMethod ?? body?.authMethod) || undefined, + credits: creditsForCli(source?.credits ?? source?.balance ?? source?.wallet), + balance: typeof source?.balance === "number" ? source.balance : undefined, + available: source?.available ?? source?.availableCredits, + reserved: source?.reserved ?? source?.reservedCredits, + plan: compactApiBody(redactUserQueryBody(source?.plan ?? source?.subscription?.plan)), + entitlement: compactApiBody(redactUserQueryBody(source?.entitlement ?? source?.entitlements)), + usage: usageSummaryCompact(source?.usage), + ledger: ledgerItems.length > 0 ? { count: source?.ledger?.count ?? source?.ledgerCount ?? ledgerItems.length, items: ledgerItems.slice(0, 20).map(ledgerEntryForCli) } : undefined, + error: errorForCli(body?.error), + valuesPrinted: false, + fullBodyAvailable: true + }); +} + +function usageSummaryBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactUserQueryBody(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + const usageRecords = firstArray(source?.records, source?.usageRecords, source?.usage?.records); + const recordCount = source?.recordCount ?? source?.usage?.recordCount ?? usageRecords.length; + return pruneUndefined({ + ok: body?.ok, + status: text(source?.status ?? body?.status) || undefined, + actor: actorForCli(source?.actor ?? body?.actor), + authMethod: text(source?.authMethod ?? body?.authMethod) || undefined, + usage: usageSummaryCompact(source?.usage ?? source), + byService: usageByServiceForCli(source?.byService ?? source?.usage?.byService), + records: usageRecords.slice(0, 20).map(usageRecordForCli), + recordCount: recordCount || undefined, + error: errorForCli(body?.error), + valuesPrinted: false, + fullBodyAvailable: true + }); +} + +function actorForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ id: text(value.id), username: text(value.username), displayName: text(value.displayName), role: text(value.role), status: text(value.status), email: text(value.email) || undefined }); +} + +function apiKeyMetadataForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ + id: text(value.id ?? value.keyId), + keyId: text(value.keyId ?? value.id), + name: text(value.name ?? value.keyName), + prefix: text(value.prefix ?? value.keyPrefix), + source: text(value.source ?? value.sourceKind), + status: text(value.status), + scopes: arrayOfText(value.scopes), + createdAt: text(value.createdAt), + updatedAt: text(value.updatedAt), + lastUsedAt: text(value.lastUsedAt), + expiresAt: text(value.expiresAt), + fingerprint: text(value.fingerprint ?? value.keyFingerprint), + valuesRedacted: value.valuesRedacted === false ? false : true + }); +} + +function ledgerItemsFromBody(source: any) { + const ledger = source?.ledger; + return firstArray(ledger?.items, ledger?.entries, ledger?.rows, source?.ledgerItems, source?.entries, source?.rows); +} + +function ledgerEntryForCli(value: any) { + return pruneUndefined({ + id: text(value?.id ?? value?.ledgerId), + kind: text(value?.kind ?? value?.type), + reason: text(value?.reason), + service: text(value?.service ?? value?.serviceId), + resourceType: text(value?.resourceType), + deltaCredits: value?.deltaCredits ?? value?.delta, + beforeCredits: value?.beforeCredits ?? value?.before, + afterCredits: value?.afterCredits ?? value?.after, + traceId: text(value?.traceId ?? value?.metadata?.traceId), + sessionId: text(value?.sessionId ?? value?.metadata?.sessionId), + conversationId: text(value?.conversationId ?? value?.metadata?.conversationId), + createdAt: text(value?.createdAt), + valuesPrinted: false + }); +} + +function usageSummaryCompact(value: any) { + if (!value || typeof value !== "object") return compactApiBody(redactUserQueryBody(value)); + return pruneUndefined({ + credits: value.credits, + totalCredits: value.totalCredits, + recordCount: value.recordCount, + byService: usageByServiceForCli(value.byService), + lastUsedAt: text(value.lastUsedAt), + fullBodyAvailable: true + }); +} + +function creditsForCli(value: any) { + if (!value || typeof value !== "object") return value; + return pruneUndefined({ + balance: value.balance ?? value.balanceCredits, + available: value.available ?? value.availableCredits, + reserved: value.reserved ?? value.reservedCredits, + total: value.total ?? value.totalCredits, + currency: text(value.currency), + fullBodyAvailable: true + }); +} + +function usageByServiceForCli(value: any) { + if (Array.isArray(value)) return value.slice(0, 20).map(usageServiceForCli); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, usageServiceForCli(item)])); + return undefined; +} + +function usageServiceForCli(value: any) { + if (!value || typeof value !== "object") return value; + return pruneUndefined({ + service: text(value.service ?? value.serviceId), + serviceId: text(value.serviceId ?? value.service), + resourceType: text(value.resourceType), + credits: value.credits ?? value.totalCredits, + recordCount: value.recordCount, + lastUsedAt: text(value.lastUsedAt), + fullBodyAvailable: true + }); +} + +function errorForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ + code: text(value.code), + status: text(value.status), + message: text(value.message), + details: value.details ? compactApiBody(redactUserQueryBody(value.details)) : undefined, + fullBodyAvailable: true + }); +} + +function selfServiceAvailability(response: any, subject: string) { + if (responseSucceeded(response)) return { status: "available", subject }; + if (response.status === 403 || response.status === 404) { + return pruneUndefined({ status: "not_available", subject, httpStatus: response.status, reason: text(response.body?.error?.code ?? response.body?.status) || "unsupported_or_forbidden", message: text(response.body?.error?.message) || undefined }); + } + return pruneUndefined({ status: "failed", subject, httpStatus: response.status, reason: text(response.body?.error?.code ?? response.body?.status) || undefined, message: text(response.body?.error?.message) || undefined }); +} + +function usageRecordForCli(value: any) { + return pruneUndefined({ + id: text(value?.id ?? value?.usageId), + service: text(value?.service ?? value?.serviceId), + resourceType: text(value?.resourceType), + credits: value?.credits ?? value?.deltaCredits, + traceId: text(value?.traceId ?? value?.metadata?.traceId), + sessionId: text(value?.sessionId ?? value?.metadata?.sessionId), + conversationId: text(value?.conversationId ?? value?.metadata?.conversationId), + status: text(value?.status), + createdAt: text(value?.createdAt), + valuesPrinted: false + }); +} + +function firstArray(...values: any[]) { + for (const value of values) { + if (Array.isArray(value)) return value; + } + return []; +} + +function arrayOfText(value: any) { + return Array.isArray(value) ? value.map(text).filter(Boolean) : undefined; +} + function adminAccessBodyForCli(body: any, parsed: ParsedArgs) { if (parsed.full === true) return body; const access = body?.access && typeof body.access === "object" ? body.access : null; @@ -3111,11 +3471,25 @@ function redactSecretLike(value: any): any { return value; } +function redactUserQueryBody(value: any): any { + if (Array.isArray(value)) return value.map(redactUserQueryBody); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, userQuerySecretLikeField(key) ? "[redacted]" : redactUserQueryBody(item)])); + } + if (typeof value === "string" && (/^sk-[A-Za-z0-9_-]{10,}/u.test(value) || /^hwl_live_[A-Za-z0-9_-]{10,}/u.test(value))) return "[redacted]"; + return value; +} + function secretLikeField(key: string) { const lower = key.toLowerCase(); return ["apikey", "api_key", "authorization", "auth.json", "config.toml", "authjson", "configtoml"].includes(lower) || lower.includes("password") || lower.endsWith("token"); } +function userQuerySecretLikeField(key: string) { + const lower = key.toLowerCase(); + return secretLikeField(key) || ["key", "secret", "credential", "dsn", "database_url", "databaseurl"].includes(lower) || lower.includes("secret") || lower.includes("credential"); +} + function traceBodyForCli(body: any, parsed: ParsedArgs) { if (text(parsed.render) === "web") return webTraceRenderBody(body, parsed); return responseBodyForCli(body, parsed);