mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(bootstrap): 新增创建控制台用户步骤,完善服务激活流程
- 在 bootstrap 命令流程中添加第 3 步,调用 createUser 接口创建控制台账号用户 - 修改 callApi 函数支持传入请求体参数 - 增强错误日志输出,显示更完整的错误响应内容 - 在服务启动时校验登录信息包含 aliyun.uid,否则抛出错误 - 优化商品检查与激活逻辑,更清晰的状态输出及错误处理 - 在 CLI-core 控制台网关请求中添加日志,打印结构化请求负载,支持 verbose 模式 - BailianError 新增 rawResponse 字段,保存原始错误响应数据 - 增加 bootstrap 命令单元测试,覆盖用户创建及不同初始化场景 - 调整测试框架用例,增加调用控制台网关及错误处理的测试覆盖
This commit is contained in:
@@ -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...");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user