feat(bootstrap): 新增创建控制台用户步骤,完善服务激活流程

- 在 bootstrap 命令流程中添加第 3 步,调用 createUser 接口创建控制台账号用户
- 修改 callApi 函数支持传入请求体参数
- 增强错误日志输出,显示更完整的错误响应内容
- 在服务启动时校验登录信息包含 aliyun.uid,否则抛出错误
- 优化商品检查与激活逻辑,更清晰的状态输出及错误处理
- 在 CLI-core 控制台网关请求中添加日志,打印结构化请求负载,支持 verbose 模式
- BailianError 新增 rawResponse 字段,保存原始错误响应数据
- 增加 bootstrap 命令单元测试,覆盖用户创建及不同初始化场景
- 调整测试框架用例,增加调用控制台网关及错误处理的测试覆盖
This commit is contained in:
lisheng.lisheng
2026-07-10 17:57:06 +08:00
parent 3fb0c7211c
commit e1532bf35c
6 changed files with 331 additions and 19 deletions
+11
View File
@@ -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
```
@@ -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<string, unknown> = {}) => {
if (verbose) process.stderr.write(`> ${api}\n`);
try {
const resp = await ctx.client.console<any>(api, {});
const resp = await ctx.client.console<any>(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<any>;
type ApiCall = (api: string, data?: Record<string, unknown>) => Promise<any>;
async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise<void> {
emitBare("Checking service activation status...");
+167
View File
@@ -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<string, unknown>;
}
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<string, unknown>) => Promise<unknown>,
) {
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,
},
});
});
+11 -8
View File
@@ -13,7 +13,10 @@ interface ConsoleGatewayInfo {
const REGION_GATEWAYS: Record<string, Record<ConsoleSite, ConsoleGatewayInfo>> = {
"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<string, unknown>;
if (settings?.verbose) {
process.stderr.write(`< ${JSON.stringify(json)}\n`);
}
const innerData = json.data as Record<string, unknown> | 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 },
);
}
+3
View File
@@ -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() {
+109 -4
View File
@@ -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: Identity; settings: Settings } {
function testDeps(identity: Partial<Identity> = {}): {
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 {