mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(tokenplan): 重构 Token Plan 命令以支持新功能
对 `tokenplan` 相关命令进行了重构,新增了 `ak-sign` 模块以支持 ACS3-HMAC-SHA256 签名,优化了参数处理逻辑,简化了对凭证的处理。更新了 `add-member`、`assign-seats`、`create-key` 和 `seats` 命令,增强了对参数的验证和处理,确保代码的可读性和健壮性。同时,新增了类型定义和工具函数以支持更好的代码结构。
This commit is contained in:
@@ -1,21 +1,24 @@
|
||||
import {
|
||||
defineCommand,
|
||||
buildCanonicalQuery,
|
||||
signRequest,
|
||||
modelStudioHost,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
trackingHeaders,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type AddOrganizationMemberResponse,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { padEnd } from "../../output/cjk-width.ts";
|
||||
import type { AddOrganizationMemberResponse } from "./types.ts";
|
||||
import {
|
||||
TOKEN_PLAN_AK_OPTIONS,
|
||||
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
appendCommonQueryParams,
|
||||
callTokenPlanApi,
|
||||
prepareTokenPlanRequest,
|
||||
resolveTokenPlanCredentials,
|
||||
type TokenPlanQueryParams,
|
||||
} from "./utils.ts";
|
||||
|
||||
const API_VERSION = "2026-02-10";
|
||||
const API_ACTION = "AddOrganizationMember";
|
||||
const API_PATH = "/tokenplan/organization/member-additions";
|
||||
|
||||
@@ -36,19 +39,8 @@ export default defineCommand({
|
||||
flag: "--spec-type <type>",
|
||||
description: "Seat tier to assign on creation: standard, pro, or max",
|
||||
},
|
||||
{
|
||||
flag: "--caller-uac-account-id <id>",
|
||||
description: "Caller UAC account ID",
|
||||
},
|
||||
{
|
||||
flag: "--namespace-id <id>",
|
||||
description: "Product namespace ID (Token Plan default: namespace-1)",
|
||||
},
|
||||
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
|
||||
{
|
||||
flag: "--access-key-secret <key>",
|
||||
description: "Alibaba Cloud Access Key Secret (deprecated)",
|
||||
},
|
||||
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
...TOKEN_PLAN_AK_OPTIONS,
|
||||
],
|
||||
examples: [
|
||||
"bl tokenplan add-member --account-name dev_user --org-id org_123",
|
||||
@@ -57,16 +49,7 @@ export default defineCommand({
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
|
||||
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"No credentials found.\n" +
|
||||
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
|
||||
ExitCode.AUTH,
|
||||
);
|
||||
}
|
||||
const credentials = resolveTokenPlanCredentials(config, flags);
|
||||
|
||||
const accountName = flags.accountName as string | undefined;
|
||||
const orgId = flags.orgId as string | undefined;
|
||||
@@ -78,52 +61,26 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const queryParams = buildQueryParams(flags);
|
||||
const queryString = buildCanonicalQuery(queryParams);
|
||||
const host = modelStudioHost(config.region);
|
||||
const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ endpoint, query: queryParams }, format);
|
||||
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
|
||||
config,
|
||||
API_PATH,
|
||||
queryParams,
|
||||
);
|
||||
emitResult({ endpoint, query }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = signRequest({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
|
||||
config,
|
||||
credentials,
|
||||
action: API_ACTION,
|
||||
version: API_VERSION,
|
||||
body: "",
|
||||
host,
|
||||
pathname: API_PATH,
|
||||
path: API_PATH,
|
||||
method: "POST",
|
||||
queryString,
|
||||
queryParams,
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`> POST ${endpoint}\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as AddOrganizationMemberResponse;
|
||||
|
||||
if (!res.ok || data.Success === false) {
|
||||
throw new BailianError(
|
||||
`${data.Code || res.status} - ${data.Message || res.statusText}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.quiet || format === "text") {
|
||||
emitTextMember(data);
|
||||
} else {
|
||||
@@ -132,8 +89,8 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
function buildQueryParams(flags: GlobalFlags): Record<string, string | string[] | undefined> {
|
||||
const params: Record<string, string | string[] | undefined> = {};
|
||||
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
|
||||
const params: TokenPlanQueryParams = {};
|
||||
|
||||
if (flags.accountName) params.AccountName = flags.accountName as string;
|
||||
if (flags.orgId) params.OrgId = flags.orgId as string;
|
||||
@@ -142,8 +99,7 @@ function buildQueryParams(flags: GlobalFlags): Record<string, string | string[]
|
||||
? flags.orgRoleCode
|
||||
: DEFAULT_ORG_ROLE;
|
||||
if (flags.specType) params.SpecType = flags.specType as string;
|
||||
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
|
||||
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
|
||||
appendCommonQueryParams(params, flags);
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
|
||||
*
|
||||
* Extends the core ROA signer with canonical query string support required by
|
||||
* Token Plan endpoints that pass parameters in the URL query.
|
||||
*/
|
||||
|
||||
import { createHmac, createHash, randomUUID } from "crypto";
|
||||
|
||||
export interface TokenPlanAkSignConfig {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
action: string;
|
||||
version: string;
|
||||
body: string;
|
||||
host: string;
|
||||
pathname: string;
|
||||
method?: string;
|
||||
/** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */
|
||||
queryString?: string;
|
||||
}
|
||||
|
||||
/** Build ACS3 canonical query string from POP query parameters. */
|
||||
export function buildCanonicalQuery(params: Record<string, string | string[] | undefined>): string {
|
||||
const pairs: Array<[string, string]> = [];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === "") continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const v = value[i];
|
||||
if (v !== "") pairs.push([`${key}.${i + 1}`, v]);
|
||||
}
|
||||
} else {
|
||||
pairs.push([key, value]);
|
||||
}
|
||||
}
|
||||
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
|
||||
}
|
||||
|
||||
function encodeRFC3986(str: string): string {
|
||||
return encodeURIComponent(str).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function signTokenPlanRequest(cfg: TokenPlanAkSignConfig): Record<string, string> {
|
||||
const method = cfg.method ?? "POST";
|
||||
const now = new Date();
|
||||
const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const nonce = randomUUID();
|
||||
|
||||
const hashedBody = sha256Hex(cfg.body);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
host: cfg.host,
|
||||
"x-acs-action": cfg.action,
|
||||
"x-acs-version": cfg.version,
|
||||
"x-acs-date": dateISO,
|
||||
"x-acs-signature-nonce": nonce,
|
||||
"x-acs-content-sha256": hashedBody,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const signedHeaderKeys = Object.keys(headers)
|
||||
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
|
||||
.sort();
|
||||
|
||||
const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
|
||||
|
||||
const signedHeadersStr = signedHeaderKeys.join(";");
|
||||
|
||||
const queryString = cfg.queryString ?? "";
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
cfg.pathname,
|
||||
queryString,
|
||||
canonicalHeaders,
|
||||
signedHeadersStr,
|
||||
hashedBody,
|
||||
].join("\n");
|
||||
|
||||
const algorithm = "ACS3-HMAC-SHA256";
|
||||
const hashedCanonical = sha256Hex(canonicalRequest);
|
||||
const stringToSign = `${algorithm}\n${hashedCanonical}`;
|
||||
|
||||
const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign);
|
||||
|
||||
headers["authorization"] =
|
||||
`${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function sha256Hex(data: string): string {
|
||||
return createHash("sha256").update(data, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function hmacSHA256Hex(key: string, data: string): string {
|
||||
return createHmac("sha256", key).update(data, "utf8").digest("hex");
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import {
|
||||
defineCommand,
|
||||
buildCanonicalQuery,
|
||||
signRequest,
|
||||
modelStudioHost,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
trackingHeaders,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type BatchAssignSeatsResponse,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import type { BatchAssignSeatsResponse } from "./types.ts";
|
||||
import {
|
||||
TOKEN_PLAN_AK_OPTIONS,
|
||||
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
TOKEN_PLAN_WORKSPACE_OPTION,
|
||||
appendCommonQueryParams,
|
||||
callTokenPlanApi,
|
||||
prepareTokenPlanRequest,
|
||||
requireWorkspaceId,
|
||||
resolveTokenPlanCredentials,
|
||||
type TokenPlanQueryParams,
|
||||
} from "./utils.ts";
|
||||
|
||||
const API_VERSION = "2026-02-10";
|
||||
const API_ACTION = "BatchAssignSeats";
|
||||
const API_PATH = "/tokenplan/subscription/seat-assignments";
|
||||
|
||||
@@ -24,10 +29,7 @@ export default defineCommand({
|
||||
usage:
|
||||
"bl tokenplan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--workspace-id <id>",
|
||||
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
|
||||
},
|
||||
TOKEN_PLAN_WORKSPACE_OPTION,
|
||||
{
|
||||
flag: "--seat-type <type>",
|
||||
description: "Seat tier: standard, pro, or max",
|
||||
@@ -38,23 +40,12 @@ export default defineCommand({
|
||||
description: "Target member account ID (repeatable)",
|
||||
type: "array",
|
||||
},
|
||||
{
|
||||
flag: "--caller-uac-account-id <id>",
|
||||
description: "Caller UAC account ID",
|
||||
},
|
||||
{
|
||||
flag: "--namespace-id <id>",
|
||||
description: "Product namespace ID (Token Plan default: namespace-1)",
|
||||
},
|
||||
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
{
|
||||
flag: "--locale <locale>",
|
||||
description: "Language: zh-CN or en-US",
|
||||
},
|
||||
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
|
||||
{
|
||||
flag: "--access-key-secret <key>",
|
||||
description: "Alibaba Cloud Access Key Secret (deprecated)",
|
||||
},
|
||||
...TOKEN_PLAN_AK_OPTIONS,
|
||||
],
|
||||
examples: [
|
||||
"bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123",
|
||||
@@ -62,26 +53,10 @@ export default defineCommand({
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
|
||||
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
|
||||
const credentials = resolveTokenPlanCredentials(config, flags);
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"No credentials found.\n" +
|
||||
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
|
||||
ExitCode.AUTH,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
|
||||
const workspaceId = requireWorkspaceId(config, flags);
|
||||
const seatType = flags.seatType as string | undefined;
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Missing workspace ID.\n" +
|
||||
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
if (!seatType) {
|
||||
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
|
||||
}
|
||||
@@ -92,52 +67,26 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const queryParams = buildQueryParams(flags, workspaceId);
|
||||
const queryString = buildCanonicalQuery(queryParams);
|
||||
const host = modelStudioHost(config.region);
|
||||
const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ endpoint, query: queryParams }, format);
|
||||
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
|
||||
config,
|
||||
API_PATH,
|
||||
queryParams,
|
||||
);
|
||||
emitResult({ endpoint, query }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = signRequest({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
|
||||
config,
|
||||
credentials,
|
||||
action: API_ACTION,
|
||||
version: API_VERSION,
|
||||
body: "",
|
||||
host,
|
||||
pathname: API_PATH,
|
||||
path: API_PATH,
|
||||
method: "POST",
|
||||
queryString,
|
||||
queryParams,
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`> POST ${endpoint}\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BatchAssignSeatsResponse;
|
||||
|
||||
if (!res.ok || data.Success === false) {
|
||||
throw new BailianError(
|
||||
`${data.Code || res.status} - ${data.Message || res.statusText}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.quiet || format === "text") {
|
||||
emitBare("Seats assigned successfully.");
|
||||
} else {
|
||||
@@ -146,16 +95,12 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
function buildQueryParams(
|
||||
flags: GlobalFlags,
|
||||
workspaceId: string,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const params: Record<string, string | string[] | undefined> = {};
|
||||
function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
|
||||
const params: TokenPlanQueryParams = {};
|
||||
|
||||
params.WorkspaceId = workspaceId;
|
||||
if (flags.seatType) params.SeatType = flags.seatType as string;
|
||||
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
|
||||
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
|
||||
appendCommonQueryParams(params, flags);
|
||||
if (flags.locale) params.Locale = flags.locale as string;
|
||||
|
||||
const accountIds = flags.accountId as string[] | undefined;
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import {
|
||||
defineCommand,
|
||||
buildCanonicalQuery,
|
||||
signRequest,
|
||||
modelStudioHost,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
trackingHeaders,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type CreateTokenPlanKeyResponse,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { padEnd } from "../../output/cjk-width.ts";
|
||||
import type { CreateTokenPlanKeyResponse } from "./types.ts";
|
||||
import {
|
||||
TOKEN_PLAN_AK_OPTIONS,
|
||||
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
TOKEN_PLAN_WORKSPACE_OPTION,
|
||||
appendCommonQueryParams,
|
||||
callTokenPlanApi,
|
||||
prepareTokenPlanRequest,
|
||||
requireWorkspaceId,
|
||||
resolveTokenPlanCredentials,
|
||||
type TokenPlanQueryParams,
|
||||
} from "./utils.ts";
|
||||
|
||||
const API_VERSION = "2026-02-10";
|
||||
const API_ACTION = "CreateTokenPlanKey";
|
||||
const API_PATH = "/tokenplan/api-keys";
|
||||
|
||||
@@ -25,24 +30,10 @@ export default defineCommand({
|
||||
usage: "bl tokenplan create-key --account-id <id> --workspace-id <id> [flags]",
|
||||
options: [
|
||||
{ flag: "--account-id <id>", description: "Target member account ID", required: true },
|
||||
{
|
||||
flag: "--workspace-id <id>",
|
||||
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
|
||||
},
|
||||
TOKEN_PLAN_WORKSPACE_OPTION,
|
||||
{ flag: "--description <text>", description: "API key description" },
|
||||
{
|
||||
flag: "--caller-uac-account-id <id>",
|
||||
description: "Caller UAC account ID",
|
||||
},
|
||||
{
|
||||
flag: "--namespace-id <id>",
|
||||
description: "Product namespace ID (Token Plan default: namespace-1)",
|
||||
},
|
||||
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
|
||||
{
|
||||
flag: "--access-key-secret <key>",
|
||||
description: "Alibaba Cloud Access Key Secret (deprecated)",
|
||||
},
|
||||
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
...TOKEN_PLAN_AK_OPTIONS,
|
||||
],
|
||||
examples: [
|
||||
"bl tokenplan create-key --account-id acc_123 --workspace-id ws_456",
|
||||
@@ -50,77 +41,35 @@ export default defineCommand({
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
|
||||
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"No credentials found.\n" +
|
||||
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
|
||||
ExitCode.AUTH,
|
||||
);
|
||||
}
|
||||
const credentials = resolveTokenPlanCredentials(config, flags);
|
||||
|
||||
const accountId = flags.accountId as string | undefined;
|
||||
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
|
||||
const workspaceId = requireWorkspaceId(config, flags);
|
||||
if (!accountId) {
|
||||
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
|
||||
}
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Missing workspace ID.\n" +
|
||||
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const queryParams = buildQueryParams(flags, { accountId, workspaceId });
|
||||
const queryString = buildCanonicalQuery(queryParams);
|
||||
const host = modelStudioHost(config.region);
|
||||
const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ endpoint, query: queryParams }, format);
|
||||
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
|
||||
config,
|
||||
API_PATH,
|
||||
queryParams,
|
||||
);
|
||||
emitResult({ endpoint, query }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = signRequest({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
const data = await callTokenPlanApi<CreateTokenPlanKeyResponse>({
|
||||
config,
|
||||
credentials,
|
||||
action: API_ACTION,
|
||||
version: API_VERSION,
|
||||
body: "",
|
||||
host,
|
||||
pathname: API_PATH,
|
||||
path: API_PATH,
|
||||
method: "POST",
|
||||
queryString,
|
||||
queryParams,
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`> POST ${endpoint}\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as CreateTokenPlanKeyResponse;
|
||||
|
||||
if (!res.ok || data.Success === false) {
|
||||
throw new BailianError(
|
||||
`${data.Code || res.status} - ${data.Message || res.statusText}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.quiet || format === "text") {
|
||||
emitTextKey(data);
|
||||
} else {
|
||||
@@ -132,14 +81,13 @@ export default defineCommand({
|
||||
function buildQueryParams(
|
||||
flags: GlobalFlags,
|
||||
resolved: { accountId: string; workspaceId: string },
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const params: Record<string, string | string[] | undefined> = {};
|
||||
): TokenPlanQueryParams {
|
||||
const params: TokenPlanQueryParams = {};
|
||||
|
||||
params.AccountId = resolved.accountId;
|
||||
params.WorkspaceId = resolved.workspaceId;
|
||||
if (flags.description) params.Description = flags.description as string;
|
||||
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
|
||||
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
|
||||
appendCommonQueryParams(params, flags);
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import {
|
||||
defineCommand,
|
||||
buildCanonicalQuery,
|
||||
signRequest,
|
||||
modelStudioHost,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
trackingHeaders,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type GetSubscriptionSeatDetailsResponse,
|
||||
type TokenPlanSeatDetail,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { padEnd } from "../../output/cjk-width.ts";
|
||||
import type { GetSubscriptionSeatDetailsResponse, TokenPlanSeatDetail } from "./types.ts";
|
||||
import {
|
||||
TOKEN_PLAN_AK_OPTIONS,
|
||||
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
appendCommonQueryParams,
|
||||
callTokenPlanApi,
|
||||
prepareTokenPlanRequest,
|
||||
resolveTokenPlanCredentials,
|
||||
type TokenPlanQueryParams,
|
||||
} from "./utils.ts";
|
||||
|
||||
const API_VERSION = "2026-02-10";
|
||||
const API_ACTION = "GetSubscriptionSeatDetails";
|
||||
const API_PATH = "/tokenplan/subscription/seat-detail";
|
||||
|
||||
@@ -27,14 +29,7 @@ export default defineCommand({
|
||||
options: [
|
||||
{ flag: "--page-no <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{ flag: "--page-size <n>", description: "Page size (default: 10)", type: "number" },
|
||||
{
|
||||
flag: "--caller-uac-account-id <id>",
|
||||
description: "Caller UAC account ID",
|
||||
},
|
||||
{
|
||||
flag: "--namespace-id <id>",
|
||||
description: "Product namespace ID (Token Plan default: namespace-1)",
|
||||
},
|
||||
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
|
||||
{
|
||||
flag: "--status <status>",
|
||||
description:
|
||||
@@ -54,11 +49,7 @@ export default defineCommand({
|
||||
flag: "--query-assigned <bool>",
|
||||
description: "Filter by assignment: true=assigned, false=unassigned",
|
||||
},
|
||||
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
|
||||
{
|
||||
flag: "--access-key-secret <key>",
|
||||
description: "Alibaba Cloud Access Key Secret (deprecated)",
|
||||
},
|
||||
...TOKEN_PLAN_AK_OPTIONS,
|
||||
],
|
||||
examples: [
|
||||
"bl tokenplan seats",
|
||||
@@ -67,64 +58,28 @@ export default defineCommand({
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
|
||||
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"No credentials found.\n" +
|
||||
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
|
||||
ExitCode.AUTH,
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = resolveTokenPlanCredentials(config, flags);
|
||||
const queryParams = buildQueryParams(flags);
|
||||
const queryString = buildCanonicalQuery(queryParams);
|
||||
const host = modelStudioHost(config.region);
|
||||
const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ endpoint, query: queryParams }, format);
|
||||
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
|
||||
config,
|
||||
API_PATH,
|
||||
queryParams,
|
||||
);
|
||||
emitResult({ endpoint, query }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = signRequest({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
const data = await callTokenPlanApi<GetSubscriptionSeatDetailsResponse>({
|
||||
config,
|
||||
credentials,
|
||||
action: API_ACTION,
|
||||
version: API_VERSION,
|
||||
body: "",
|
||||
host,
|
||||
pathname: API_PATH,
|
||||
path: API_PATH,
|
||||
method: "GET",
|
||||
queryString,
|
||||
queryParams,
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`> GET ${endpoint}\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method: "GET",
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as GetSubscriptionSeatDetailsResponse;
|
||||
|
||||
if (!res.ok || data.Success === false) {
|
||||
throw new BailianError(
|
||||
`${data.Code || res.status} - ${data.Message || res.statusText}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
const items = data.Data?.Items ?? [];
|
||||
if (config.quiet || format === "text") {
|
||||
emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize);
|
||||
@@ -134,13 +89,12 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
function buildQueryParams(flags: GlobalFlags): Record<string, string | string[] | undefined> {
|
||||
const params: Record<string, string | string[] | undefined> = {};
|
||||
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
|
||||
const params: TokenPlanQueryParams = {};
|
||||
|
||||
if (flags.pageNo !== undefined) params.PageNo = String(flags.pageNo as number);
|
||||
if (flags.pageSize !== undefined) params.PageSize = String(flags.pageSize as number);
|
||||
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
|
||||
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
|
||||
appendCommonQueryParams(params, flags);
|
||||
if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string;
|
||||
|
||||
const status = flags.status as string[] | undefined;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// ---- Token Plan / ModelStudio POP (2026-02-10) ----
|
||||
|
||||
export interface TokenPlanSeatEquity {
|
||||
EquityType?: string;
|
||||
CycleInstanceId?: string;
|
||||
CycleStartTime?: number;
|
||||
CycleEndTime?: number;
|
||||
CycleTotalValue?: number;
|
||||
CycleSurplusValue?: number;
|
||||
CycleVersion?: number;
|
||||
}
|
||||
|
||||
export interface TokenPlanSeatDetail {
|
||||
InstanceCode?: string;
|
||||
EquityList?: TokenPlanSeatEquity[];
|
||||
EndTime?: number;
|
||||
SeatId?: string;
|
||||
SpecType?: string;
|
||||
StartTime?: number;
|
||||
AssignedStatus?: string;
|
||||
AccountId?: string;
|
||||
AccountName?: string;
|
||||
AccountEmail?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
export interface GetSubscriptionSeatDetailsResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
Data?: {
|
||||
Items?: TokenPlanSeatDetail[];
|
||||
Total?: number;
|
||||
PageNo?: number;
|
||||
PageSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTokenPlanKeyResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
Data?: {
|
||||
ApiKeyId?: string;
|
||||
PlainApiKey?: string;
|
||||
MaskedApiKey?: string;
|
||||
Description?: string;
|
||||
CreatedAt?: string;
|
||||
SourceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchAssignSeatsResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
}
|
||||
|
||||
export interface AddOrganizationMemberResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
RequestId?: string;
|
||||
HttpStatusCode?: number;
|
||||
Data?: {
|
||||
AccountId?: string;
|
||||
SeatAssigned?: boolean;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
REGIONS,
|
||||
maskToken,
|
||||
trackingHeaders,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type OptionDef,
|
||||
type Region,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { buildCanonicalQuery, signTokenPlanRequest } from "./ak-sign.ts";
|
||||
|
||||
export const TOKEN_PLAN_API_VERSION = "2026-02-10";
|
||||
|
||||
export const TOKEN_PLAN_AK_OPTIONS: OptionDef[] = [
|
||||
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
|
||||
{
|
||||
flag: "--access-key-secret <key>",
|
||||
description: "Alibaba Cloud Access Key Secret (deprecated)",
|
||||
},
|
||||
];
|
||||
|
||||
export const TOKEN_PLAN_COMMON_QUERY_OPTIONS: OptionDef[] = [
|
||||
{
|
||||
flag: "--caller-uac-account-id <id>",
|
||||
description: "Caller UAC account ID",
|
||||
},
|
||||
{
|
||||
flag: "--namespace-id <id>",
|
||||
description: "Product namespace ID (Token Plan default: namespace-1)",
|
||||
},
|
||||
];
|
||||
|
||||
export const TOKEN_PLAN_WORKSPACE_OPTION: OptionDef = {
|
||||
flag: "--workspace-id <id>",
|
||||
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
|
||||
};
|
||||
|
||||
const MODEL_STUDIO_HOSTS: Partial<Record<Region, string>> = {
|
||||
cn: "modelstudio.cn-beijing.aliyuncs.com",
|
||||
intl: "modelstudio.ap-southeast-1.aliyuncs.com",
|
||||
};
|
||||
|
||||
function resolveRegion(baseUrl: string): Region {
|
||||
for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) {
|
||||
if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region;
|
||||
}
|
||||
return "cn";
|
||||
}
|
||||
|
||||
/** ModelStudio POP OpenAPI host for the given DashScope base URL preset. */
|
||||
function modelStudioHost(baseUrl: string): string {
|
||||
const region = resolveRegion(baseUrl);
|
||||
return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!;
|
||||
}
|
||||
|
||||
export interface TokenPlanApiResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
}
|
||||
|
||||
export type TokenPlanQueryParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
export function resolveTokenPlanCredentials(
|
||||
config: Config,
|
||||
flags: GlobalFlags,
|
||||
): { accessKeyId: string; accessKeySecret: string } {
|
||||
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
|
||||
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"No credentials found.\n" +
|
||||
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
|
||||
ExitCode.AUTH,
|
||||
);
|
||||
}
|
||||
|
||||
return { accessKeyId, accessKeySecret };
|
||||
}
|
||||
|
||||
export function requireWorkspaceId(config: Config, flags: GlobalFlags): string {
|
||||
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Missing workspace ID.\n" +
|
||||
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
export function appendCommonQueryParams(params: TokenPlanQueryParams, flags: GlobalFlags): void {
|
||||
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
|
||||
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
|
||||
}
|
||||
|
||||
export function prepareTokenPlanRequest(
|
||||
config: Config,
|
||||
path: string,
|
||||
queryParams: TokenPlanQueryParams,
|
||||
): { host: string; endpoint: string; queryString: string; queryParams: TokenPlanQueryParams } {
|
||||
const queryString = buildCanonicalQuery(queryParams);
|
||||
const host = modelStudioHost(config.baseUrl);
|
||||
const endpoint = `https://${host}${path}${queryString ? `?${queryString}` : ""}`;
|
||||
return { host, endpoint, queryString, queryParams };
|
||||
}
|
||||
|
||||
export async function callTokenPlanApi<T extends TokenPlanApiResponse>(opts: {
|
||||
config: Config;
|
||||
credentials: { accessKeyId: string; accessKeySecret: string };
|
||||
action: string;
|
||||
path: string;
|
||||
method: "GET" | "POST";
|
||||
queryParams: TokenPlanQueryParams;
|
||||
}): Promise<T> {
|
||||
const { config, credentials, action, path, method, queryParams } = opts;
|
||||
const { host, endpoint, queryString } = prepareTokenPlanRequest(config, path, queryParams);
|
||||
|
||||
const headers = signTokenPlanRequest({
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
accessKeySecret: credentials.accessKeySecret,
|
||||
action,
|
||||
version: TOKEN_PLAN_API_VERSION,
|
||||
body: "",
|
||||
host,
|
||||
pathname: path,
|
||||
method,
|
||||
queryString,
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`> ${method} ${endpoint}\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(credentials.accessKeyId)}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (config.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as T;
|
||||
|
||||
if (!res.ok || data.Success === false) {
|
||||
throw new BailianError(
|
||||
`${data.Code || res.status} - ${data.Message || res.statusText}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -136,15 +136,6 @@ export function isKnowledgeAkSkReady(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Token Plan POP commands (AK/SK only). */
|
||||
export function isTokenPlanAkSkReady(): boolean {
|
||||
return (
|
||||
isBailianE2EEnabled() &&
|
||||
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID &&
|
||||
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export interface RunCliResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isTokenPlanAkSkReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
interface DryRunBody {
|
||||
endpoint?: string;
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const noCredsEnv = {
|
||||
DASHSCOPE_API_KEY: undefined,
|
||||
DASHSCOPE_ACCESS_TOKEN: undefined,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
|
||||
BAILIAN_CONFIG_DIR: tmpdir(),
|
||||
};
|
||||
|
||||
const fakeAkEnv = {
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret",
|
||||
};
|
||||
|
||||
describe("e2e: tokenplan", () => {
|
||||
test("tokenplan 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["tokenplan"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/tokenplan|seats/i);
|
||||
expect(out).toMatch(/create-key/i);
|
||||
expect(out).toMatch(/assign-seats/i);
|
||||
expect(out).toMatch(/add-member/i);
|
||||
});
|
||||
|
||||
test("tokenplan seats --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["tokenplan", "seats", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--page-no/i);
|
||||
expect(stderr).toMatch(/--page-size/i);
|
||||
expect(stderr).toMatch(/--seat-id/i);
|
||||
expect(stderr).toMatch(/--status/i);
|
||||
expect(stderr).toMatch(/--query-assigned/i);
|
||||
});
|
||||
|
||||
test("tokenplan create-key --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["tokenplan", "create-key", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--account-id/i);
|
||||
expect(stderr).toMatch(/--workspace-id/i);
|
||||
});
|
||||
|
||||
test("tokenplan assign-seats --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["tokenplan", "assign-seats", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--workspace-id/i);
|
||||
expect(stderr).toMatch(/--seat-type/i);
|
||||
expect(stderr).toMatch(/--account-id/i);
|
||||
});
|
||||
|
||||
test("tokenplan add-member --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["tokenplan", "add-member", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--account-name/i);
|
||||
expect(stderr).toMatch(/--org-id/i);
|
||||
expect(stderr).toMatch(/--org-role-code/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: tokenplan errors", () => {
|
||||
test("seats 无任何凭证时提示 No credentials found 并非零退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
["tokenplan", "seats", "--non-interactive", "--output", "json"],
|
||||
noCredsEnv,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/no credentials found/i);
|
||||
});
|
||||
|
||||
test("create-key 无任何凭证时提示 No credentials found 并非零退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"create-key",
|
||||
"--account-id",
|
||||
"acc_1",
|
||||
"--workspace-id",
|
||||
"ws_1",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
noCredsEnv,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/no credentials found/i);
|
||||
});
|
||||
|
||||
test("assign-seats 无任何凭证时提示 No credentials found 并非零退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"assign-seats",
|
||||
"--workspace-id",
|
||||
"ws_1",
|
||||
"--seat-type",
|
||||
"standard",
|
||||
"--account-id",
|
||||
"acc_1",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
noCredsEnv,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/no credentials found/i);
|
||||
});
|
||||
|
||||
test("add-member 无任何凭证时提示 No credentials found 并非零退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"add-member",
|
||||
"--account-name",
|
||||
"user1",
|
||||
"--org-id",
|
||||
"org_1",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
noCredsEnv,
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/no credentials found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan missing args", () => {
|
||||
test("create-key 缺少 --account-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"create-key",
|
||||
"--workspace-id",
|
||||
"ws_1",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--account-id|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("create-key 缺少 --workspace-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"create-key",
|
||||
"--account-id",
|
||||
"acc_1",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/workspace-id|Missing workspace ID/i);
|
||||
});
|
||||
|
||||
test("assign-seats 缺少 --workspace-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"assign-seats",
|
||||
"--seat-type",
|
||||
"standard",
|
||||
"--account-id",
|
||||
"acc_1",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/workspace-id|Missing workspace ID/i);
|
||||
});
|
||||
|
||||
test("assign-seats 缺少 --seat-type 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"assign-seats",
|
||||
"--workspace-id",
|
||||
"ws_1",
|
||||
"--account-id",
|
||||
"acc_1",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--seat-type|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("assign-seats 缺少 account id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"assign-seats",
|
||||
"--workspace-id",
|
||||
"ws_1",
|
||||
"--seat-type",
|
||||
"standard",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--account-id|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("add-member 缺少 --account-name 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"add-member",
|
||||
"--org-id",
|
||||
"org_1",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--account-name|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("add-member 缺少 --org-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"add-member",
|
||||
"--account-name",
|
||||
"user1",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--org-id|Missing required argument/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: tokenplan dry-run", () => {
|
||||
test("seats --dry-run 输出 endpoint 和 query 参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"seats",
|
||||
"--dry-run",
|
||||
"--page-no",
|
||||
"1",
|
||||
"--page-size",
|
||||
"10",
|
||||
"--status",
|
||||
"NORMAL",
|
||||
"--query-assigned",
|
||||
"true",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
fakeAkEnv,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-detail/);
|
||||
expect(data.query?.PageNo).toBe("1");
|
||||
expect(data.query?.PageSize).toBe("10");
|
||||
expect(data.query?.QueryAssigned).toBe("true");
|
||||
expect(data.query?.StatusList).toEqual(["NORMAL"]);
|
||||
});
|
||||
|
||||
test("create-key --dry-run 从 BAILIAN_WORKSPACE_ID 读取 workspace", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"create-key",
|
||||
"--dry-run",
|
||||
"--account-id",
|
||||
"acc_123",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ ...fakeAkEnv, BAILIAN_WORKSPACE_ID: "ws-from-env" },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.query?.WorkspaceId).toBe("ws-from-env");
|
||||
});
|
||||
|
||||
test("create-key --dry-run 输出 endpoint 和 query 参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"create-key",
|
||||
"--dry-run",
|
||||
"--account-id",
|
||||
"acc_123",
|
||||
"--workspace-id",
|
||||
"ws_456",
|
||||
"--description",
|
||||
"test key",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
fakeAkEnv,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/\/tokenplan\/api-keys/);
|
||||
expect(data.query?.AccountId).toBe("acc_123");
|
||||
expect(data.query?.WorkspaceId).toBe("ws_456");
|
||||
expect(data.query?.Description).toBe("test key");
|
||||
});
|
||||
|
||||
test("assign-seats --dry-run 输出 endpoint 和 query 参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"assign-seats",
|
||||
"--dry-run",
|
||||
"--workspace-id",
|
||||
"ws_456",
|
||||
"--seat-type",
|
||||
"standard",
|
||||
"--account-id",
|
||||
"acc_1",
|
||||
"--account-id",
|
||||
"acc_2",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
fakeAkEnv,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-assignments/);
|
||||
expect(data.query?.WorkspaceId).toBe("ws_456");
|
||||
expect(data.query?.SeatType).toBe("standard");
|
||||
expect(data.query?.AccountIds).toEqual(["acc_1", "acc_2"]);
|
||||
expect(data.endpoint).toMatch(/AccountIds\.1=acc_1/);
|
||||
expect(data.endpoint).toMatch(/AccountIds\.2=acc_2/);
|
||||
});
|
||||
|
||||
test("add-member --dry-run 输出 endpoint 和 query 参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
[
|
||||
"tokenplan",
|
||||
"add-member",
|
||||
"--dry-run",
|
||||
"--account-name",
|
||||
"dev_user",
|
||||
"--org-id",
|
||||
"org_123",
|
||||
"--spec-type",
|
||||
"standard",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
fakeAkEnv,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/\/tokenplan\/organization\/member-additions/);
|
||||
expect(data.query?.AccountName).toBe("dev_user");
|
||||
expect(data.query?.OrgId).toBe("org_123");
|
||||
expect(data.query?.OrgRoleCode).toBe("ORG_MEMBER");
|
||||
expect(data.query?.SpecType).toBe("standard");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan seats(AK/SK)", () => {
|
||||
test("GetSubscriptionSeatDetails 真实调用", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"tokenplan",
|
||||
"seats",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ Success?: boolean; Data?: { Items?: unknown[] } }>(stdout);
|
||||
expect(data.Success).toBe(true);
|
||||
expect(Array.isArray(data.Data?.Items)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,33 +18,6 @@ export interface AkSignConfig {
|
||||
host: string;
|
||||
pathname: string;
|
||||
method?: string;
|
||||
/** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */
|
||||
queryString?: string;
|
||||
}
|
||||
|
||||
/** Build ACS3 canonical query string from POP query parameters. */
|
||||
export function buildCanonicalQuery(params: Record<string, string | string[] | undefined>): string {
|
||||
const pairs: Array<[string, string]> = [];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === "") continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const v = value[i];
|
||||
if (v !== "") pairs.push([`${key}.${i + 1}`, v]);
|
||||
}
|
||||
} else {
|
||||
pairs.push([key, value]);
|
||||
}
|
||||
}
|
||||
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
|
||||
}
|
||||
|
||||
function encodeRFC3986(str: string): string {
|
||||
return encodeURIComponent(str).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function signRequest(cfg: AkSignConfig): Record<string, string> {
|
||||
@@ -74,13 +47,11 @@ export function signRequest(cfg: AkSignConfig): Record<string, string> {
|
||||
|
||||
const signedHeadersStr = signedHeaderKeys.join(";");
|
||||
|
||||
const queryString = cfg.queryString ?? "";
|
||||
|
||||
// Build canonical request
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
cfg.pathname,
|
||||
queryString,
|
||||
"", // query string (empty for POST)
|
||||
canonicalHeaders,
|
||||
signedHeadersStr,
|
||||
hashedBody,
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
import type { Region } from "../config/schema.ts";
|
||||
|
||||
const MODEL_STUDIO_HOSTS: Record<Region, string> = {
|
||||
cn: "modelstudio.cn-beijing.aliyuncs.com",
|
||||
us: "modelstudio.cn-beijing.aliyuncs.com",
|
||||
intl: "modelstudio.ap-southeast-1.aliyuncs.com",
|
||||
};
|
||||
|
||||
/** ModelStudio POP OpenAPI host for the given DashScope region preset. */
|
||||
export function modelStudioHost(region: Region): string {
|
||||
return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn;
|
||||
}
|
||||
|
||||
// ---- Chat (OpenAI Compatible) ----
|
||||
|
||||
export function chatEndpoint(baseUrl: string): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type { AkSignConfig } from "./ak-sign.ts";
|
||||
export { buildCanonicalQuery, signRequest } from "./ak-sign.ts";
|
||||
export { signRequest } from "./ak-sign.ts";
|
||||
export {
|
||||
appCompletionEndpoint,
|
||||
chatEndpoint,
|
||||
@@ -10,7 +10,6 @@ export {
|
||||
memoryListEndpoint,
|
||||
memoryNodeEndpoint,
|
||||
memorySearchEndpoint,
|
||||
modelStudioHost,
|
||||
mcpWebSearchEndpoint,
|
||||
profileSchemaEndpoint,
|
||||
speechRecognizeEndpoint,
|
||||
|
||||
@@ -417,76 +417,6 @@ export interface DashScopeKnowledgeRetrieveResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Token Plan / ModelStudio POP (2026-02-10) ----
|
||||
|
||||
export interface TokenPlanSeatEquity {
|
||||
EquityType?: string;
|
||||
CycleInstanceId?: string;
|
||||
CycleStartTime?: number;
|
||||
CycleEndTime?: number;
|
||||
CycleTotalValue?: number;
|
||||
CycleSurplusValue?: number;
|
||||
CycleVersion?: number;
|
||||
}
|
||||
|
||||
export interface TokenPlanSeatDetail {
|
||||
InstanceCode?: string;
|
||||
EquityList?: TokenPlanSeatEquity[];
|
||||
EndTime?: number;
|
||||
SeatId?: string;
|
||||
SpecType?: string;
|
||||
StartTime?: number;
|
||||
AssignedStatus?: string;
|
||||
AccountId?: string;
|
||||
AccountName?: string;
|
||||
AccountEmail?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
export interface GetSubscriptionSeatDetailsResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
Data?: {
|
||||
Items?: TokenPlanSeatDetail[];
|
||||
Total?: number;
|
||||
PageNo?: number;
|
||||
PageSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTokenPlanKeyResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
Data?: {
|
||||
ApiKeyId?: string;
|
||||
PlainApiKey?: string;
|
||||
MaskedApiKey?: string;
|
||||
Description?: string;
|
||||
CreatedAt?: string;
|
||||
SourceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchAssignSeatsResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
}
|
||||
|
||||
export interface AddOrganizationMemberResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Message?: string;
|
||||
RequestId?: string;
|
||||
HttpStatusCode?: number;
|
||||
Data?: {
|
||||
AccountId?: string;
|
||||
SeatAssigned?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Speech Synthesis / TTS (DashScope) ----
|
||||
|
||||
export interface DashScopeTTSRequest {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { buildCanonicalQuery } from "../src/client/ak-sign.ts";
|
||||
|
||||
test("buildCanonicalQuery flattens arrays as indexed keys", () => {
|
||||
expect(
|
||||
buildCanonicalQuery({
|
||||
WorkspaceId: "ws_1",
|
||||
SeatType: "pro",
|
||||
AccountIds: ["acc_1", "acc_2"],
|
||||
}),
|
||||
).toBe("AccountIds.1=acc_1&AccountIds.2=acc_2&SeatType=pro&WorkspaceId=ws_1");
|
||||
});
|
||||
|
||||
test("buildCanonicalQuery uses single indexed key for one-element arrays", () => {
|
||||
expect(
|
||||
buildCanonicalQuery({
|
||||
AccountIds: ["acc_2bd88814c31743d9aa5833dc16b3b8e0"],
|
||||
}),
|
||||
).toBe("AccountIds.1=acc_2bd88814c31743d9aa5833dc16b3b8e0");
|
||||
});
|
||||
Reference in New Issue
Block a user