diff --git a/cli-access-token.md b/cli-access-token.md new file mode 100644 index 0000000..5e07cec --- /dev/null +++ b/cli-access-token.md @@ -0,0 +1,11 @@ +## 登录 + +https://signin.aliyun.com/1062516667359476.onaliyun.com/login.htm +lisheng@1062516667359476.onaliyun.com +app$$5%%%Ehiliao + +## 获得 AK SK STS 三元组 + +``` +pnpm bl auth generate-access-token --access-key-id STS.NXsfUgEqhDQBJTz1V5JJSMRDw --access-key-secret CmJU9so7yMTjZ3mpVF9eMqFSZXkEn2LhFMogi1hfn8rk --security-token CAIS1gJ1q6Ft5B2yfSjIr5vGLe/TqK5J85OpSHLL1VZgRsV/opfvlTz2IHhMe3BtAuwXtvQ1mG9R7/0ZlqBpR4RIXlfFas0oFyyqTp/6MeT7oMWQweEuqv/MQBq+aXPS2MvVfJ+KLrf0ceusbFbpjzJ6xaCAGxypQ12iN+/i6/clFKN1ODO1dj1bHtxbCxJ/ocsBTxvrOO2qLwThjxi7biMqmHIl2T8ns/vlnpbHs0KP0gWq8IJP+dSteKrDRtJ3IZJyX+2y2OFLbafb2EdSkUMSrPgv0fcYqG+X5I3CWgAKuA/MKefP9cB1JwJ1Z7I3ELJDtun1nvZ/p+rPno/8xg1WJ+ZRXjRD7XJMD2hdcQnAF6HaFd6TUxylurgExgnkPL5jz1gvlRKYWhvQG45hiCZWPhXwAIHJtv6kTMnd5abLPm9I37QLATeM356+Q3LrJRHx74QEOMJUBysagAFIsU+wgReSHvEEXx5y3qyr3JB3t7tY9nI0FrIx0GudXEjl2vE4sD7aTxleD1Weqlq8XAzIu8DrdU8tkNdQ0rGricEZd9DY2WWDer1eD7IdoLuavwGHXyLaJkw1c1UPq2O3fKp2EQ8b5zei/Ep+MGfMCYDHyVqDPAPLD5yTtN6eOCAA +``` diff --git a/packages/commands/src/commands/bootstrap/index.ts b/packages/commands/src/commands/bootstrap/index.ts index 1e2e27f..dbeedea 100644 --- a/packages/commands/src/commands/bootstrap/index.ts +++ b/packages/commands/src/commands/bootstrap/index.ts @@ -4,6 +4,7 @@ import { emitResult, emitBare } from "bailian-cli-runtime"; const API = { loginInfo: "zeldaEasy.cornerstone-portal.cs-console.loginInfo", initSpace: "zeldaEasy.bailian-dash-workspace.space.initSpace", + createUser: "zeldaEasy.bailian-dash-workspace.account.createUser", queryBuyResult: "zeldaEasy.bailian-commerce.bill.queryBuyPostpaidResult", commodityOrderInfo: "zeldaEasy.bailian-commerce.bill.postpaidCommodityOrderInfo", buyCommodity: "zeldaEasy.bailian-commerce.bill.buyPostpaidCommodity", @@ -51,16 +52,21 @@ export default defineCommand({ }, { step: 3, + api: API.createUser, + description: "Create console account user", + }, + { + step: 4, api: API.queryBuyResult, description: "Query postpaid order status", }, { - step: 4, + step: 5, api: API.commodityOrderInfo, description: "Query commodity activation status", }, { - step: 5, + step: 6, api: API.buyCommodity, description: "Activate postpaid commodities (if needed)", }, @@ -72,14 +78,18 @@ export default defineCommand({ } const verbose = settings.verbose; - const callApi = async (api: string) => { + const callApi = async (api: string, data: Record = {}) => { if (verbose) process.stderr.write(`> ${api}\n`); try { - const resp = await ctx.client.console(api, {}); + const resp = await ctx.client.console(api, data); if (verbose) process.stderr.write(`< ${JSON.stringify(resp)}\n`); return resp; } catch (err) { - if (verbose) process.stderr.write(`< ERROR: ${err instanceof Error ? err.message : err}\n`); + if (verbose) { + const message = + err instanceof BailianError ? (err.rawResponse ?? err.message) : String(err); + process.stderr.write(`< ERROR: ${message}\n`); + } throw err; } }; @@ -99,12 +109,25 @@ export default defineCommand({ emitBare("Workspace already initialized."); } - // Step 3-5: Order & commodity flow + // Step 3: Create console user + const uid = loginData?.aliyun?.uid; + if (typeof uid !== "string" || uid.length === 0) { + throw new BailianError("Console login info did not include aliyun.uid.", ExitCode.GENERAL); + } + await callApi(API.createUser, { + reqDTO: { + outerKey: uid, + nickName: uid, + userName: uid, + }, + }); + + // Step 4-6: Order & commodity flow await ensureCommoditiesActive(callApi, format); }, }); -type ApiCall = (api: string) => Promise; +type ApiCall = (api: string, data?: Record) => Promise; async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise { emitBare("Checking service activation status..."); diff --git a/packages/commands/tests/bootstrap.test.ts b/packages/commands/tests/bootstrap.test.ts new file mode 100644 index 0000000..18c7861 --- /dev/null +++ b/packages/commands/tests/bootstrap.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from "vite-plus/test"; +import bootstrapCommand from "../src/commands/bootstrap/index.ts"; + +const API = { + loginInfo: "zeldaEasy.cornerstone-portal.cs-console.loginInfo", + initSpace: "zeldaEasy.bailian-dash-workspace.space.initSpace", + createUser: "zeldaEasy.bailian-dash-workspace.account.createUser", + queryBuyResult: "zeldaEasy.bailian-commerce.bill.queryBuyPostpaidResult", + commodityOrderInfo: "zeldaEasy.bailian-commerce.bill.postpaidCommodityOrderInfo", + buyCommodity: "zeldaEasy.bailian-commerce.bill.buyPostpaidCommodity", +} as const; + +interface ConsoleCall { + api: string; + data: Record; +} + +function captureStdout(): { read: () => string; restore: () => void } { + const originalWrite = process.stdout.write.bind(process.stdout); + let stdout = ""; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += String(chunk); + return true; + }) as typeof process.stdout.write; + return { + read: () => stdout, + restore: () => { + process.stdout.write = originalWrite; + }, + }; +} + +function gatewayResponse(data: unknown): unknown { + return { + data: { + DataV2: { + data: { + data, + }, + }, + success: true, + }, + }; +} + +function createContext( + consoleImpl: (api: string, data: Record) => Promise, +) { + return { + settings: { + dryRun: false, + output: "json", + verbose: false, + }, + client: { + console: consoleImpl, + }, + }; +} + +test("bootstrap --dry-run lists createUser after initSpace", async () => { + const stdout = captureStdout(); + try { + await bootstrapCommand.run({ + ...createContext(async () => ({})), + settings: { dryRun: true, output: "json", verbose: false }, + } as any); + } finally { + stdout.restore(); + } + + const data = JSON.parse(stdout.read()) as { + apis: Array<{ step: number; api: string }>; + }; + expect(data.apis.map((item) => item.api)).toEqual([ + API.loginInfo, + API.initSpace, + API.createUser, + API.queryBuyResult, + API.commodityOrderInfo, + API.buyCommodity, + ]); + expect(data.apis.map((item) => item.step)).toEqual([1, 2, 3, 4, 5, 6]); +}); + +test("bootstrap creates user from loginInfo uid when workspace is not initialized", async () => { + const uid = "AssumedRoleUser300715349082471133"; + const calls: ConsoleCall[] = []; + const stdout = captureStdout(); + const ctx = createContext(async (api, data) => { + calls.push({ api, data }); + if (api === API.loginInfo) { + return gatewayResponse({ spaceInited: false, aliyun: { uid } }); + } + if (api === API.queryBuyResult) { + return gatewayResponse("success"); + } + if (api === API.commodityOrderInfo) { + return gatewayResponse([{ commodityCode: "postpaid", status: 10 }]); + } + return gatewayResponse({}); + }); + + try { + await bootstrapCommand.run(ctx as any); + } finally { + stdout.restore(); + } + + expect(calls.map((call) => call.api)).toEqual([ + API.loginInfo, + API.initSpace, + API.createUser, + API.queryBuyResult, + API.commodityOrderInfo, + ]); + expect(calls.find((call) => call.api === API.createUser)?.data).toEqual({ + reqDTO: { + outerKey: uid, + nickName: uid, + userName: uid, + }, + }); +}); + +test("bootstrap skips initSpace but still creates user when workspace is already initialized", async () => { + const uid = "AssumedRoleUser300715349082471133"; + const calls: ConsoleCall[] = []; + const stdout = captureStdout(); + const ctx = createContext(async (api, data) => { + calls.push({ api, data }); + if (api === API.loginInfo) { + return gatewayResponse({ + spaceInited: true, + aliyun: { uid }, + }); + } + if (api === API.queryBuyResult) { + return gatewayResponse("success"); + } + if (api === API.commodityOrderInfo) { + return gatewayResponse([{ commodityCode: "postpaid", status: 10 }]); + } + return gatewayResponse({}); + }); + + try { + await bootstrapCommand.run(ctx as any); + } finally { + stdout.restore(); + } + + expect(calls.map((call) => call.api)).toEqual([ + API.loginInfo, + API.createUser, + API.queryBuyResult, + API.commodityOrderInfo, + ]); + expect(calls.some((call) => call.api === API.initSpace)).toBe(false); + expect(calls.find((call) => call.api === API.createUser)?.data).toEqual({ + reqDTO: { + outerKey: uid, + nickName: uid, + userName: uid, + }, + }); +}); diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index b674560..38c7d9b 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -13,7 +13,10 @@ interface ConsoleGatewayInfo { const REGION_GATEWAYS: Record> = { "cn-beijing": { - domestic: { csGateway: "bailian-cs.console.aliyun.com", action: "BroadScopeAspnGateway" }, + domestic: { + csGateway: "bailian-cs.console.aliyun.com", + action: "BroadScopeAspnGateway", + }, international: { csGateway: "bailian-cs.console.alibabacloud.com", action: "BroadScopeAspnGateway", @@ -74,6 +77,7 @@ function buildGatewayParams( protocol: "V2", console: "ONE_CONSOLE", productCode: "p_efm", + switchUserType: 3, consoleSite: "BAILIAN_ALIYUN", ...(switchAgent != null ? { switchAgent } : {}), ...(typeof data.cornerstoneParam === "object" && data.cornerstoneParam !== null @@ -119,6 +123,9 @@ export async function callConsoleGateway( const endpoint = `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`; if (settings?.verbose) { process.stderr.write(`> POST ${endpoint}\n`); + process.stderr.write( + `> payload ${JSON.stringify({ params: JSON.parse(params), region: target.region }, null, 2)}\n`, + ); } const res = await fetch(endpoint, { @@ -142,17 +149,14 @@ export async function callConsoleGateway( } const json = (await res.json()) as Record; - if (settings?.verbose) { - process.stderr.write(`< ${JSON.stringify(json)}\n`); - } const innerData = json.data as Record | undefined; if (innerData?.success === false && innerData.errorCode) { + const rawResponse = JSON.stringify(json); const rawErrorCode = innerData.errorCode; const errorCode = typeof rawErrorCode === "string" ? rawErrorCode : JSON.stringify(rawErrorCode); const notLogined = errorCode.includes("NotLogined"); - const errorMsg = typeof innerData.errorMsg === "string" ? innerData.errorMsg : undefined; throw new BailianError( notLogined ? "Console session is not logged in or has expired." @@ -160,9 +164,8 @@ export async function callConsoleGateway( notLogined ? ExitCode.AUTH : ExitCode.GENERAL, notLogined ? "Run `bl auth login --console` to sign in or refresh your console session." - : errorMsg && errorMsg !== errorCode - ? errorMsg - : undefined, + : undefined, + { rawResponse }, ); } diff --git a/packages/core/src/errors/base.ts b/packages/core/src/errors/base.ts index 69d9abb..b3ed692 100644 --- a/packages/core/src/errors/base.ts +++ b/packages/core/src/errors/base.ts @@ -9,12 +9,14 @@ export interface ApiErrorContext { export interface BailianErrorOptions { cause?: unknown; api?: ApiErrorContext; + rawResponse?: string; } export class BailianError extends Error { readonly exitCode: ExitCode; readonly hint?: string; readonly api?: ApiErrorContext; + readonly rawResponse?: string; constructor( message: string, @@ -27,6 +29,7 @@ export class BailianError extends Error { this.exitCode = exitCode; this.hint = hint; this.api = options?.api; + this.rawResponse = options?.rawResponse; } toJSON() { diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index b6da358..1e4e021 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -1,6 +1,13 @@ import { expect, test } from "vite-plus/test"; import type { Identity, Settings } from "../src/index.ts"; -import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts"; +import { + BailianError, + ExitCode, + McpClient, + callConsoleGateway, + mapApiError, + request, +} from "../src/index.ts"; import { parseConfigFile } from "../src/config/schema.ts"; import { parseBooleanValue, @@ -8,7 +15,10 @@ import { resolveWatermark, } from "../src/utils/boolean-flag.ts"; -function testDeps(identity: Partial = {}): { identity: Identity; settings: Settings } { +function testDeps(identity: Partial = {}): { + identity: Identity; + settings: Settings; +} { return { identity: { binName: "bl", @@ -83,7 +93,10 @@ test("BailianError propagates cause via options-bag and exposes it in toJSON", ( test("toJSON splits service-error metadata into structured fields", () => { const err = mapApiError(404, { - error: { message: "The model `qwen3.7` does not exist", type: "invalid_request_error" }, + error: { + message: "The model `qwen3.7` does not exist", + type: "invalid_request_error", + }, request_id: "c55e1acc", }); expect(err.toJSON()).toEqual({ @@ -97,6 +110,96 @@ test("toJSON splits service-error metadata into structured fields", () => { }); }); +test("callConsoleGateway verbose prints structured request payload", async () => { + const originalFetch = globalThis.fetch; + const originalWrite = process.stderr.write.bind(process.stderr); + let stderr = ""; + let requestBody: string | undefined; + + globalThis.fetch = async (_url, init) => { + requestBody = init?.body as string | undefined; + return new Response(JSON.stringify({ data: { success: true, value: "response-body" } }), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }); + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + try { + await callConsoleGateway( + { + region: "ap-southeast-1", + site: "international", + switchAgent: 123, + token: "token", + }, + 30, + { + api: "test.api", + data: { workspaceId: "ws-1", cornerstoneParam: { custom: "value" } }, + }, + { verbose: true }, + ); + } finally { + globalThis.fetch = originalFetch; + process.stderr.write = originalWrite; + } + + expect(requestBody).toBeDefined(); + expect(stderr).toContain('> payload {\n "params": {'); + expect(stderr).toContain(' "region": "ap-southeast-1"'); + expect(stderr).toContain(' "Api": "test.api"'); + expect(stderr).toContain(' "workspaceId": "ws-1"'); + expect(stderr).toContain(' "switchUserType": 3'); + expect(stderr).toContain(' "switchAgent": 123'); + expect(stderr).toContain(' "custom": "value"'); + expect(stderr).toContain("< 200 OK"); + expect(stderr).not.toContain("response-body"); +}); + +test("callConsoleGateway keeps readable message and raw gateway response separately", async () => { + const originalFetch = globalThis.fetch; + const originalWrite = process.stderr.write.bind(process.stderr); + const responseBody = { + data: { + success: false, + errorCode: "BailianGateway.Team.NotAuthorised", + errorMsg: "team not authorised", + }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(responseBody), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }); + + process.stderr.write = (() => true) as typeof process.stderr.write; + + try { + await expect( + callConsoleGateway( + { region: "cn-beijing", site: "domestic", token: "token" }, + 30, + { api: "test.api", data: {} }, + { verbose: true }, + ), + ).rejects.toMatchObject({ + message: "Console gateway error: BailianGateway.Team.NotAuthorised", + rawResponse: JSON.stringify(responseBody), + exitCode: ExitCode.GENERAL, + }); + } finally { + globalThis.fetch = originalFetch; + process.stderr.write = originalWrite; + } +}); + test("request uses injected client identity for User-Agent", async () => { const originalFetch = globalThis.fetch; let userAgent: string | undefined; @@ -160,7 +263,9 @@ test("McpClient uses injected client identity for initialize and User-Agent", as userAgents.push(headers?.["User-Agent"] ?? ""); const body = init?.body; if (typeof body === "string") bodies.push(JSON.parse(body)); - return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), { status: 200 }); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), { + status: 200, + }); }; try {