diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd91e89..c45f8b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,13 @@ name: Publish on: workflow_dispatch: inputs: + package: + description: "Which package set to publish" + required: true + type: choice + options: + - bailian-cli + - knowledge-studio-cli mode: description: "Publish mode" required: true @@ -16,13 +23,13 @@ on: type: string concurrency: - group: publish-${{ inputs.mode }}-${{ inputs.channel }} + group: publish-${{ inputs.package }}-${{ inputs.mode }}-${{ inputs.channel }} cancel-in-progress: false jobs: publish-stable: if: inputs.mode == 'stable' - name: publish stable to npm + tag + name: publish stable (${{ inputs.package }}) to npm + tag runs-on: ubuntu-latest environment: production # Required Reviewers gate permissions: @@ -51,11 +58,11 @@ jobs: - run: pnpm install --frozen-lockfile - name: publish-stable - run: node tools/release/publish-stable.mjs + run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} publish-channel: if: inputs.mode == 'channel' - name: publish beta to npm + name: publish channel (${{ inputs.package }}) to npm runs-on: ubuntu-latest permissions: contents: read # no tag, no Release; just publish @@ -83,4 +90,4 @@ jobs: - run: pnpm install --frozen-lockfile - name: publish-channel - run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}" + run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}" diff --git a/.node-version b/.node-version index b832e40..a45fd52 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.16.0 +24 diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index fa1fa78..237e0b9 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -122,6 +122,12 @@ bl quota check # 查看当前用量 vs bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量 bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额 bl quota history # 查看提额历史记录 + +# Token Plan 团队版管理(需 AK/SK,见下方认证说明) +bl token-plan list-seats # 查看订阅席位明细 +bl token-plan add-member --account-name dev --org-id org_xxx +bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx +bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx ``` > 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) @@ -151,9 +157,9 @@ bl text chat --api-key sk-xxxxx --message "你好" bl auth login --console ``` -### 阿里云 AK/SK(仅知识库检索) +### 阿里云 AK/SK(知识库检索与 Token Plan) -`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 +`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 > 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。 diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index ae48fcc..b42488c 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -46,6 +46,10 @@ import quotaList from "./quota/list.ts"; import quotaRequest from "./quota/request.ts"; import quotaHistory from "./quota/history.ts"; import quotaCheck from "./quota/check.ts"; +import tokenPlanListSeats from "./token-plan/list-seats.ts"; +import tokenPlanCreateKey from "./token-plan/create-key.ts"; +import tokenPlanAssignSeats from "./token-plan/assign-seats.ts"; +import tokenPlanAddMember from "./token-plan/add-member.ts"; /** Command registry map (no dependency on registry.ts — safe for build-time import). */ export const commands: Record = { @@ -94,5 +98,9 @@ export const commands: Record = { "quota request": quotaRequest, "quota history": quotaHistory, "quota check": quotaCheck, + "token-plan list-seats": tokenPlanListSeats, + "token-plan create-key": tokenPlanCreateKey, + "token-plan assign-seats": tokenPlanAssignSeats, + "token-plan add-member": tokenPlanAddMember, update: update, }; diff --git a/packages/cli/src/commands/token-plan/add-member.ts b/packages/cli/src/commands/token-plan/add-member.ts new file mode 100644 index 0000000..6bbe64c --- /dev/null +++ b/packages/cli/src/commands/token-plan/add-member.ts @@ -0,0 +1,116 @@ +import { + defineCommand, + detectOutputFormat, + type Config, + type GlobalFlags, + 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_ACTION = "AddOrganizationMember"; +const API_PATH = "/tokenplan/organization/member-additions"; + +const DEFAULT_ORG_ROLE = "ORG_MEMBER"; + +export default defineCommand({ + name: "token-plan add-member", + description: "Add a member to a Token Plan organization", + usage: "bl token-plan add-member --account-name --org-id [flags]", + options: [ + { flag: "--account-name ", description: "Member display name", required: true }, + { flag: "--org-id ", description: "Organization ID", required: true }, + { + flag: "--org-role-code ", + description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)", + }, + { + flag: "--spec-type ", + description: "Seat tier to assign on creation: standard, pro, or max", + }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, + ...TOKEN_PLAN_AK_OPTIONS, + ], + examples: [ + "bl token-plan add-member --account-name dev_user --org-id org_123", + "bl token-plan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN", + "bl token-plan add-member --account-name member1 --org-id org_123 --spec-type standard", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const credentials = resolveTokenPlanCredentials(config, flags); + + const accountName = flags.accountName as string | undefined; + const orgId = flags.orgId as string | undefined; + if (!accountName) { + throw new BailianError("Missing required argument --account-name.", ExitCode.USAGE); + } + if (!orgId) { + throw new BailianError("Missing required argument --org-id.", ExitCode.USAGE); + } + + const queryParams = buildQueryParams(flags); + + if (config.dryRun) { + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); + return; + } + + const data = await callTokenPlanApi({ + config, + credentials, + action: API_ACTION, + path: API_PATH, + method: "POST", + queryParams, + }); + + if (config.quiet || format === "text") { + emitTextMember(data); + } else { + emitResult(data, format); + } + }, +}); + +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; + params.OrgRoleCode = + typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0 + ? flags.orgRoleCode + : DEFAULT_ORG_ROLE; + if (flags.specType) params.SpecType = flags.specType as string; + appendCommonQueryParams(params, flags); + + return params; +} + +function emitTextMember(data: AddOrganizationMemberResponse): void { + const item = data.Data; + if (!item) { + emitBare("Member added."); + return; + } + + emitBare(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`); + emitBare(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`); +} diff --git a/packages/cli/src/commands/token-plan/ak-sign.ts b/packages/cli/src/commands/token-plan/ak-sign.ts new file mode 100644 index 0000000..1e63cc0 --- /dev/null +++ b/packages/cli/src/commands/token-plan/ak-sign.ts @@ -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 { + 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 { + 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 = { + 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"); +} diff --git a/packages/cli/src/commands/token-plan/assign-seats.ts b/packages/cli/src/commands/token-plan/assign-seats.ts new file mode 100644 index 0000000..ec283d9 --- /dev/null +++ b/packages/cli/src/commands/token-plan/assign-seats.ts @@ -0,0 +1,112 @@ +import { + defineCommand, + detectOutputFormat, + type Config, + type GlobalFlags, + 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_ACTION = "BatchAssignSeats"; +const API_PATH = "/tokenplan/subscription/seat-assignments"; + +export default defineCommand({ + name: "token-plan assign-seats", + description: "Batch assign Token Plan seats to members", + usage: + "bl token-plan assign-seats --workspace-id --seat-type --account-id [flags]", + options: [ + TOKEN_PLAN_WORKSPACE_OPTION, + { + flag: "--seat-type ", + description: "Seat tier: standard, pro, or max", + required: true, + }, + { + flag: "--account-id ", + description: "Target member account ID (repeatable)", + type: "array", + }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, + { + flag: "--locale ", + description: "Language: zh-CN or en-US", + }, + ...TOKEN_PLAN_AK_OPTIONS, + ], + examples: [ + "bl token-plan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123", + "bl token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const credentials = resolveTokenPlanCredentials(config, flags); + + const workspaceId = requireWorkspaceId(config, flags); + const seatType = flags.seatType as string | undefined; + if (!seatType) { + throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE); + } + + const accountIds = flags.accountId as string[] | undefined; + if (!accountIds || accountIds.length === 0) { + throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE); + } + + const queryParams = buildQueryParams(flags, workspaceId); + + if (config.dryRun) { + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); + return; + } + + const data = await callTokenPlanApi({ + config, + credentials, + action: API_ACTION, + path: API_PATH, + method: "POST", + queryParams, + }); + + if (config.quiet || format === "text") { + emitBare("Seats assigned successfully."); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams { + const params: TokenPlanQueryParams = {}; + + params.WorkspaceId = workspaceId; + if (flags.seatType) params.SeatType = flags.seatType as string; + appendCommonQueryParams(params, flags); + if (flags.locale) params.Locale = flags.locale as string; + + const accountIds = flags.accountId as string[] | undefined; + if (accountIds && accountIds.length > 0) { + params.AccountIds = accountIds; + } + + return params; +} diff --git a/packages/cli/src/commands/token-plan/create-key.ts b/packages/cli/src/commands/token-plan/create-key.ts new file mode 100644 index 0000000..932dacd --- /dev/null +++ b/packages/cli/src/commands/token-plan/create-key.ts @@ -0,0 +1,111 @@ +import { + defineCommand, + detectOutputFormat, + type Config, + type GlobalFlags, + 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_ACTION = "CreateTokenPlanKey"; +const API_PATH = "/tokenplan/api-keys"; + +export default defineCommand({ + name: "token-plan create-key", + description: "Create a Token Plan API key for a seat", + usage: "bl token-plan create-key --account-id --workspace-id [flags]", + options: [ + { flag: "--account-id ", description: "Target member account ID", required: true }, + TOKEN_PLAN_WORKSPACE_OPTION, + { flag: "--description ", description: "API key description" }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, + ...TOKEN_PLAN_AK_OPTIONS, + ], + examples: [ + "bl token-plan create-key --account-id acc_123 --workspace-id ws_456", + "bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key'", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const credentials = resolveTokenPlanCredentials(config, flags); + + const accountId = flags.accountId as string | undefined; + const workspaceId = requireWorkspaceId(config, flags); + if (!accountId) { + throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE); + } + + const queryParams = buildQueryParams(flags, { accountId, workspaceId }); + + if (config.dryRun) { + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); + return; + } + + const data = await callTokenPlanApi({ + config, + credentials, + action: API_ACTION, + path: API_PATH, + method: "POST", + queryParams, + }); + + if (config.quiet || format === "text") { + emitTextKey(data); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams( + flags: GlobalFlags, + resolved: { accountId: string; workspaceId: string }, +): TokenPlanQueryParams { + const params: TokenPlanQueryParams = {}; + + params.AccountId = resolved.accountId; + params.WorkspaceId = resolved.workspaceId; + if (flags.description) params.Description = flags.description as string; + appendCommonQueryParams(params, flags); + + return params; +} + +function emitTextKey(data: CreateTokenPlanKeyResponse): void { + const item = data.Data; + if (!item) { + emitBare("API key created."); + return; + } + + emitBare(`${padEnd("ApiKeyId", 14)} ${item.ApiKeyId ?? "-"}`); + emitBare(`${padEnd("MaskedApiKey", 14)} ${item.MaskedApiKey ?? "-"}`); + if (item.Description) { + emitBare(`${padEnd("Description", 14)} ${item.Description}`); + } + if (item.PlainApiKey) { + emitBare(""); + emitBare(`PlainApiKey (shown once): ${item.PlainApiKey}`); + } +} diff --git a/packages/cli/src/commands/token-plan/list-seats.ts b/packages/cli/src/commands/token-plan/list-seats.ts new file mode 100644 index 0000000..94122fe --- /dev/null +++ b/packages/cli/src/commands/token-plan/list-seats.ts @@ -0,0 +1,157 @@ +import { + defineCommand, + detectOutputFormat, + type Config, + type GlobalFlags, + 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_ACTION = "GetSubscriptionSeatDetails"; +const API_PATH = "/tokenplan/subscription/seat-detail"; + +export default defineCommand({ + name: "token-plan list-seats", + description: "List Token Plan subscription seat details", + usage: "bl token-plan list-seats [flags]", + options: [ + { flag: "--page-no ", description: "Page number (default: 1)", type: "number" }, + { flag: "--page-size ", description: "Page size (default: 10)", type: "number" }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, + { + flag: "--status ", + description: + "Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED", + type: "array", + }, + { + flag: "--status-list-str ", + description: "StatusList as JSON string, e.g. '[\"NORMAL\"]'", + }, + { flag: "--seat-id ", description: "Filter by seat ID" }, + { + flag: "--seat-type ", + description: "Seat tier: standard, pro, or max", + }, + { + flag: "--query-assigned ", + description: "Filter by assignment: true=assigned, false=unassigned", + }, + ...TOKEN_PLAN_AK_OPTIONS, + ], + examples: [ + "bl token-plan list-seats", + "bl token-plan list-seats --page-size 20 --status NORMAL", + "bl token-plan list-seats --query-assigned true --seat-type standard", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const credentials = resolveTokenPlanCredentials(config, flags); + const queryParams = buildQueryParams(flags); + + if (config.dryRun) { + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); + return; + } + + const data = await callTokenPlanApi({ + config, + credentials, + action: API_ACTION, + path: API_PATH, + method: "GET", + queryParams, + }); + + const items = data.Data?.Items ?? []; + if (config.quiet || format === "text") { + emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize); + } else { + emitResult(data, format); + } + }, +}); + +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); + appendCommonQueryParams(params, flags); + if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string; + + const status = flags.status as string[] | undefined; + if (status && status.length > 0) { + params.StatusList = status; + } + + if (flags.seatId) params.SeatId = flags.seatId as string; + if (flags.seatType) params.SeatType = flags.seatType as string; + + if (typeof flags.queryAssigned === "string" && flags.queryAssigned.length > 0) { + const val = flags.queryAssigned.toLowerCase(); + if (val !== "true" && val !== "false") { + throw new BailianError("--query-assigned must be 'true' or 'false'.", ExitCode.USAGE); + } + params.QueryAssigned = val; + } + + return params; +} + +function emitTextSeats( + items: TokenPlanSeatDetail[], + total?: number, + pageNo?: number, + pageSize?: number, +): void { + if (items.length === 0) { + emitBare("No seats found."); + return; + } + + const header = [ + padEnd("SeatId", 18), + padEnd("Type", 10), + padEnd("Status", 10), + padEnd("Assigned", 12), + padEnd("Account", 20), + ].join(" "); + emitBare(header); + emitBare("-".repeat(header.length)); + + for (const item of items) { + const row = [ + padEnd(item.SeatId ?? "-", 18), + padEnd(item.SpecType ?? "-", 10), + padEnd(item.Status ?? "-", 10), + padEnd(item.AssignedStatus ?? "-", 12), + padEnd(item.AccountName ?? item.AccountId ?? "-", 20), + ].join(" "); + emitBare(row); + } + + if (total !== undefined) { + emitBare(""); + emitBare( + `Total: ${total}${pageNo !== undefined ? ` | Page: ${pageNo}` : ""}${pageSize !== undefined ? ` | PageSize: ${pageSize}` : ""}`, + ); + } +} diff --git a/packages/cli/src/commands/token-plan/types.ts b/packages/cli/src/commands/token-plan/types.ts new file mode 100644 index 0000000..86fa661 --- /dev/null +++ b/packages/cli/src/commands/token-plan/types.ts @@ -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; + }; +} diff --git a/packages/cli/src/commands/token-plan/utils.ts b/packages/cli/src/commands/token-plan/utils.ts new file mode 100644 index 0000000..674295a --- /dev/null +++ b/packages/cli/src/commands/token-plan/utils.ts @@ -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 ", description: "Alibaba Cloud Access Key ID (deprecated)" }, + { + flag: "--access-key-secret ", + description: "Alibaba Cloud Access Key Secret (deprecated)", + }, +]; + +export const TOKEN_PLAN_COMMON_QUERY_OPTIONS: OptionDef[] = [ + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, +]; + +export const TOKEN_PLAN_WORKSPACE_OPTION: OptionDef = { + flag: "--workspace-id ", + description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)", +}; + +const MODEL_STUDIO_HOSTS: Partial> = { + 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; + +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 ", + 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(opts: { + config: Config; + credentials: { accessKeyId: string; accessKeySecret: string }; + action: string; + path: string; + method: "GET" | "POST"; + queryParams: TokenPlanQueryParams; +}): Promise { + 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; +} diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index fc2559c..49d7dab 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -8,80 +8,85 @@ Use this index for the full quick index and global flags. ## Quick index -| Command | Description | Detail | -| -------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------- | -| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | -| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) | -| `bl app list` | List Bailian applications | [app.md](app.md) | -| `bl auth login` | Authenticate with API key or console browser login (credentials can coexist) | [auth.md](auth.md) | -| `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | -| `bl auth status` | Show current authentication state | [auth.md](auth.md) | -| `bl config export-schema` | Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas | [config.md](config.md) | -| `bl config set` | Set a config value | [config.md](config.md) | -| `bl config show` | Display current configuration | [config.md](config.md) | -| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | -| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | -| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | -| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | -| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base | [knowledge.md](knowledge.md) | -| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) | -| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) | -| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) | -| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) | -| `bl memory delete` | Delete a memory node | [memory.md](memory.md) | -| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) | -| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) | -| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) | -| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) | -| `bl memory update` | Update a memory node content | [memory.md](memory.md) | -| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | -| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) | -| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) | -| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) | -| `bl quota history` | View quota change history | [quota.md](quota.md) | -| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | -| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | -| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | -| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | -| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | -| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | -| `bl update` | Update bl to the latest version | [update.md](update.md) | -| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | -| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | -| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | -| `bl video download` | Download a completed video by task ID | [video.md](video.md) | -| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | -| `bl video generate` | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | -| `bl video ref` | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | -| `bl video task get` | Query async task status | [video.md](video.md) | -| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) | -| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | +| Command | Description | Detail | +| ---------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------ | +| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | +| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) | +| `bl app list` | List Bailian applications | [app.md](app.md) | +| `bl auth login` | Authenticate with API key or console browser login (credentials can coexist) | [auth.md](auth.md) | +| `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | +| `bl auth status` | Show current authentication state | [auth.md](auth.md) | +| `bl config export-schema` | Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas | [config.md](config.md) | +| `bl config set` | Set a config value | [config.md](config.md) | +| `bl config show` | Display current configuration | [config.md](config.md) | +| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | +| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | +| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | +| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | +| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base | [knowledge.md](knowledge.md) | +| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) | +| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) | +| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) | +| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) | +| `bl memory delete` | Delete a memory node | [memory.md](memory.md) | +| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) | +| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) | +| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) | +| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) | +| `bl memory update` | Update a memory node content | [memory.md](memory.md) | +| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | +| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) | +| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) | +| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) | +| `bl quota history` | View quota change history | [quota.md](quota.md) | +| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | +| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | +| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | +| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | +| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | +| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | +| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) | +| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) | +| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) | +| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) | +| `bl update` | Update bl to the latest version | [update.md](update.md) | +| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | +| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | +| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | +| `bl video download` | Download a completed video by task ID | [video.md](video.md) | +| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | +| `bl video generate` | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | +| `bl video ref` | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | +| `bl video task get` | Query async task status | [video.md](video.md) | +| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) | +| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | ## By group -| Group | Commands | Reference | -| ----------- | ---------------------------------------------------------------------------- | ---------------------------- | -| `advisor` | `recommend` | [advisor.md](advisor.md) | -| `app` | `call`, `list` | [app.md](app.md) | -| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | -| `config` | `export-schema`, `set`, `show` | [config.md](config.md) | -| `console` | `call` | [console.md](console.md) | -| `file` | `upload` | [file.md](file.md) | -| `image` | `edit`, `generate` | [image.md](image.md) | -| `knowledge` | `retrieve` | [knowledge.md](knowledge.md) | -| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) | -| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) | -| `omni` | `(root)` | [omni.md](omni.md) | -| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) | -| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | -| `search` | `web` | [search.md](search.md) | -| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | -| `text` | `chat` | [text.md](text.md) | -| `update` | `(root)` | [update.md](update.md) | -| `usage` | `free`, `freetier`, `stats` | [usage.md](usage.md) | -| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | -| `vision` | `describe` | [vision.md](vision.md) | -| `workspace` | `list` | [workspace.md](workspace.md) | +| Group | Commands | Reference | +| ------------ | ---------------------------------------------------------------------------- | ------------------------------ | +| `advisor` | `recommend` | [advisor.md](advisor.md) | +| `app` | `call`, `list` | [app.md](app.md) | +| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | +| `config` | `export-schema`, `set`, `show` | [config.md](config.md) | +| `console` | `call` | [console.md](console.md) | +| `file` | `upload` | [file.md](file.md) | +| `image` | `edit`, `generate` | [image.md](image.md) | +| `knowledge` | `retrieve` | [knowledge.md](knowledge.md) | +| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) | +| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) | +| `omni` | `(root)` | [omni.md](omni.md) | +| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) | +| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | +| `search` | `web` | [search.md](search.md) | +| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | +| `text` | `chat` | [text.md](text.md) | +| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) | +| `update` | `(root)` | [update.md](update.md) | +| `usage` | `free`, `freetier`, `stats` | [usage.md](usage.md) | +| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | +| `vision` | `describe` | [vision.md](vision.md) | +| `workspace` | `list` | [workspace.md](workspace.md) | ## Global flags diff --git a/skills/bailian-cli/reference/token-plan.md b/skills/bailian-cli/reference/token-plan.md new file mode 100644 index 0000000..e201cbf --- /dev/null +++ b/skills/bailian-cli/reference/token-plan.md @@ -0,0 +1,151 @@ +# `bl token-plan` commands + +> Auto-generated from `packages/cli/src/commands/catalog.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Description | +| ---------------------------- | ----------------------------------------- | +| `bl token-plan add-member` | Add a member to a Token Plan organization | +| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | +| `bl token-plan create-key` | Create a Token Plan API key for a seat | +| `bl token-plan list-seats` | List Token Plan subscription seat details | + +## Command details + +### `bl token-plan add-member` + +| Field | Value | +| --------------- | ---------------------------------------------------------------------- | +| **Name** | `token-plan add-member` | +| **Description** | Add a member to a Token Plan organization | +| **Usage** | `bl token-plan add-member --account-name --org-id [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------- | +| `--account-name ` | string | yes | Member display name | +| `--org-id ` | string | yes | Organization ID | +| `--org-role-code ` | string | no | Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER) | +| `--spec-type ` | string | no | Seat tier to assign on creation: standard, pro, or max | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl token-plan add-member --account-name dev_user --org-id org_123 +``` + +```bash +bl token-plan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN +``` + +```bash +bl token-plan add-member --account-name member1 --org-id org_123 --spec-type standard +``` + +### `bl token-plan assign-seats` + +| Field | Value | +| --------------- | --------------------------------------------------------------------------------------------- | +| **Name** | `token-plan assign-seats` | +| **Description** | Batch assign Token Plan seats to members | +| **Usage** | `bl token-plan assign-seats --workspace-id --seat-type --account-id [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id) | +| `--seat-type ` | string | yes | Seat tier: standard, pro, or max | +| `--account-id ` | array | no | Target member account ID (repeatable) | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--locale ` | string | no | Language: zh-CN or en-US | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl token-plan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123 +``` + +```bash +bl token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2 +``` + +### `bl token-plan create-key` + +| Field | Value | +| --------------- | ------------------------------------------------------------------------ | +| **Name** | `token-plan create-key` | +| **Description** | Create a Token Plan API key for a seat | +| **Usage** | `bl token-plan create-key --account-id --workspace-id [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------------- | +| `--account-id ` | string | yes | Target member account ID | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id) | +| `--description ` | string | no | API key description | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl token-plan create-key --account-id acc_123 --workspace-id ws_456 +``` + +```bash +bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key' +``` + +### `bl token-plan list-seats` + +| Field | Value | +| --------------- | ----------------------------------------- | +| **Name** | `token-plan list-seats` | +| **Description** | List Token Plan subscription seat details | +| **Usage** | `bl token-plan list-seats [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | --------------------------------------------------------------------------------- | +| `--page-no ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Page size (default: 10) | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--status ` | array | no | Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED | +| `--status-list-str ` | string | no | StatusList as JSON string, e.g. '["NORMAL"]' | +| `--seat-id ` | string | no | Filter by seat ID | +| `--seat-type ` | string | no | Seat tier: standard, pro, or max | +| `--query-assigned ` | string | no | Filter by assignment: true=assigned, false=unassigned | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl token-plan list-seats +``` + +```bash +bl token-plan list-seats --page-size 20 --status NORMAL +``` + +```bash +bl token-plan list-seats --query-assigned true --seat-type standard +``` diff --git a/tools/release/check.mjs b/tools/release/check.mjs index aad327f..f5f61a0 100644 --- a/tools/release/check.mjs +++ b/tools/release/check.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "url"; import { packAndScan } from "./lib/pack-scan.mjs"; import { run } from "./lib/proc.mjs"; import { assertReadmeSync, loadAndValidatePackages } from "./lib/validate.mjs"; +import { ALL_PACKAGES, PACKAGES } from "./lib/packages.mjs"; function log(msg = "") { process.stdout.write(`${msg}\n`); @@ -17,20 +18,24 @@ function step(msg) { * Pure-validation pipeline. Reusable from publish-stable / publish-channel. * Returns { coreJson, cliJson } for callers that need the parsed package.jsons. * - * @param {{ channel?: boolean }} [options] + * @param {{ channel?: boolean, knowledge?: boolean }} [options] * @param {boolean} [options.channel] — When true (publish-channel): regenerate * `reference/` and assert it matches git, but do not sync `SKILL.md` from the * temporary beta `package.json` version (repo skill stays aligned with stable). + * @param {boolean} [options.knowledge] — When true: also build and validate + * knowledge-studio-cli alongside the base packages. */ export async function runCheck(options = {}) { const channel = options.channel === true; + const knowledge = options.knowledge === true; + const packages = knowledge ? ALL_PACKAGES : PACKAGES; step("pnpm install --frozen-lockfile"); run("pnpm", ["install", "--frozen-lockfile"]); step("metadata: README sync, version consistency, workspace:* dep"); assertReadmeSync(); - const { coreJson, cliJson } = loadAndValidatePackages(); + const { coreJson, cliJson } = loadAndValidatePackages({ packages }); log(`bailian-cli-core@${coreJson.version}`); log(`bailian-cli@${cliJson.version}`); @@ -63,8 +68,13 @@ export async function runCheck(options = {}) { step("build bailian-cli"); run("pnpm", ["--filter", "bailian-cli", "run", "build"]); + if (knowledge) { + step("build knowledge-studio-cli"); + run("pnpm", ["--filter", "knowledge-studio-cli", "run", "build"]); + } + step("pack + scan (publint, gitleaks)"); - packAndScan({ log }); + packAndScan({ log, packages }); log("\nrelease check passed."); return { coreJson, cliJson }; diff --git a/tools/release/lib/pack-scan.mjs b/tools/release/lib/pack-scan.mjs index 5825d7b..b7b0d8e 100644 --- a/tools/release/lib/pack-scan.mjs +++ b/tools/release/lib/pack-scan.mjs @@ -13,10 +13,11 @@ function extractTarball(tarball, tempDir, key) { return extractDir; } -export function packAndScan({ log }) { +export function packAndScan({ log, packages }) { + const pkgs = packages ?? PACKAGES; const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-")); try { - for (const pkg of PACKAGES) { + for (const pkg of pkgs) { const json = readPackageJson(pkg); log(`packing ${pkg.name}@${json.version}`); const tarball = pnpmPack(pkg, tempDir, json); diff --git a/tools/release/lib/packages.mjs b/tools/release/lib/packages.mjs index 6416ea1..d7118f5 100644 --- a/tools/release/lib/packages.mjs +++ b/tools/release/lib/packages.mjs @@ -9,6 +9,11 @@ export const PACKAGES = [ { key: "cli", dir: "packages/cli", name: "bailian-cli" }, ]; +// knowledge-studio-cli shares the same library deps as bailian-cli. +// Published via publish.yml with package=knowledge-studio-cli (passes --knowledge flag). +export const KSCLI_PACKAGE = { key: "kscli", dir: "packages/kscli", name: "knowledge-studio-cli" }; +export const ALL_PACKAGES = [...PACKAGES, KSCLI_PACKAGE]; + export function readJson(path) { return JSON.parse(readFileSync(path, "utf-8")); } diff --git a/tools/release/lib/validate.mjs b/tools/release/lib/validate.mjs index 27b51ae..bb0fe92 100644 --- a/tools/release/lib/validate.mjs +++ b/tools/release/lib/validate.mjs @@ -18,9 +18,11 @@ export function assertReadmeSync() { } } -export function loadAndValidatePackages() { +export function loadAndValidatePackages({ packages } = {}) { + const pkgs = packages ?? PACKAGES; + const internalNames = new Set(pkgs.map((p) => p.name)); const jsonByKey = new Map(); - for (const pkg of PACKAGES) { + for (const pkg of pkgs) { const json = readPackageJson(pkg); if (json.name !== pkg.name) { throw new Error(`${pkg.dir} name must be ${pkg.name}, got ${json.name}`); @@ -30,18 +32,21 @@ export function loadAndValidatePackages() { const coreJson = jsonByKey.get("core"); const cliJson = jsonByKey.get("cli"); + const version = coreJson.version; - if (cliJson.version !== coreJson.version) { - throw new Error( - `core and cli versions must match, got ${coreJson.version} and ${cliJson.version}.`, - ); - } - - const cliCoreDep = cliJson.dependencies?.["bailian-cli-core"]; - if (cliCoreDep !== "workspace:*") { - throw new Error( - `packages/cli source dependency on bailian-cli-core must be "workspace:*", got ${cliCoreDep}.`, - ); + for (const pkg of pkgs) { + const json = jsonByKey.get(pkg.key); + if (json.version !== version) { + throw new Error( + `all package versions must match ${version} (bailian-cli-core), ` + + `but ${pkg.name} is ${json.version}.`, + ); + } + for (const [dep, range] of Object.entries(json.dependencies ?? {})) { + if (internalNames.has(dep) && range !== "workspace:*") { + throw new Error(`${pkg.name} dependency on ${dep} must be "workspace:*", got ${range}.`); + } + } } return { coreJson, cliJson }; diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index 7253d7e..9c24f49 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -6,7 +6,8 @@ import { runCheck } from "./check.mjs"; import { headSha7, utcDateStamp } from "./lib/git.mjs"; import { npmViewExists, pnpmPublish } from "./lib/npm.mjs"; import { - findPackage, + ALL_PACKAGES, + PACKAGES, packageJsonPath, readPackageJson, writePackageJson, @@ -25,11 +26,14 @@ const { values } = parseArgs({ options: { channel: { type: "string" }, "dry-run": { type: "boolean", default: false }, + knowledge: { type: "boolean", default: false }, }, allowPositionals: false, }); const channel = values.channel; const dryRun = values["dry-run"]; +const knowledge = values.knowledge; +const packages = knowledge ? ALL_PACKAGES : PACKAGES; assertChannel(channel); if (!dryRun && !process.env.CI) { @@ -37,16 +41,15 @@ if (!dryRun && !process.env.CI) { process.exit(1); } -const core = findPackage("core"); -const cli = findPackage("cli"); -const corePath = packageJsonPath(core); -const cliPath = packageJsonPath(cli); -const coreOriginal = readFileSync(corePath, "utf-8"); -const cliOriginal = readFileSync(cliPath, "utf-8"); +// Snapshot every package.json so the temporary version bump is reverted in +// `finally`, even when the release fails midway. +const originals = packages.map((pkg) => { + const path = packageJsonPath(pkg); + return { pkg, path, content: readFileSync(path, "utf-8") }; +}); function restoreOriginals() { - writeFileSync(corePath, coreOriginal); - writeFileSync(cliPath, cliOriginal); + for (const { path, content } of originals) writeFileSync(path, content); } try { @@ -57,32 +60,29 @@ try { log(`channel=${channel} version=${betaVersion}`); step("temporarily bump package.json (not committed)"); - const coreJson = readPackageJson(core); - const cliJson = readPackageJson(cli); - coreJson.version = betaVersion; - cliJson.version = betaVersion; - writePackageJson(core, coreJson); - writePackageJson(cli, cliJson); - // pnpm pack resolves `workspace:*` to the in-tree version, so CLI tarball - // will depend on bailian-cli-core@ after this bump. + for (const pkg of packages) { + const json = readPackageJson(pkg); + json.version = betaVersion; + writePackageJson(pkg, json); + } - await runCheck({ channel: true }); + await runCheck({ channel: true, knowledge }); step(`idempotency: check ${betaVersion} against registry`); - const corePublished = npmViewExists(core.name, betaVersion); - const cliPublished = npmViewExists(cli.name, betaVersion); - log(`${core.name}@${betaVersion}: ${corePublished ? "already published" : "to publish"}`); - log(`${cli.name}@${betaVersion}: ${cliPublished ? "already published" : "to publish"}`); - if (corePublished && cliPublished) { - log("\nboth packages already published; nothing to do."); + const published = new Map(); + for (const pkg of packages) { + const exists = npmViewExists(pkg.name, betaVersion); + published.set(pkg.key, exists); + log(`${pkg.name}@${betaVersion}: ${exists ? "already published" : "to publish"}`); + } + if (packages.every((pkg) => published.get(pkg.key))) { + log("\nall packages already published; nothing to do."); } else { - if (!corePublished) { - step(`publish ${core.name}@${betaVersion} (tag=${channel}, provenance)`); - pnpmPublish(core, { tag: channel, provenance: true, dryRun }); - } - if (!cliPublished) { - step(`publish ${cli.name}@${betaVersion} (tag=${channel}, provenance)`); - pnpmPublish(cli, { tag: channel, provenance: true, dryRun }); + // Publish in dependency order. + for (const pkg of packages) { + if (published.get(pkg.key)) continue; + step(`publish ${pkg.name}@${betaVersion} (tag=${channel}, provenance)`); + pnpmPublish(pkg, { tag: channel, provenance: true, dryRun }); } } diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs index 4891276..13c6359 100644 --- a/tools/release/publish-stable.mjs +++ b/tools/release/publish-stable.mjs @@ -4,7 +4,7 @@ import { parseArgs } from "util"; import { runCheck } from "./check.mjs"; import { createTag, currentBranch, isWorkingTreeClean, pushTag, tagExists } from "./lib/git.mjs"; import { npmViewExists, pnpmPublish } from "./lib/npm.mjs"; -import { findPackage } from "./lib/packages.mjs"; +import { ALL_PACKAGES, findPackage, PACKAGES } from "./lib/packages.mjs"; function log(msg = "") { process.stdout.write(`${msg}\n`); @@ -17,10 +17,13 @@ function step(msg) { const { values } = parseArgs({ options: { "dry-run": { type: "boolean", default: false }, + knowledge: { type: "boolean", default: false }, }, allowPositionals: false, }); const dryRun = values["dry-run"]; +const knowledge = values.knowledge; +const packages = knowledge ? ALL_PACKAGES : PACKAGES; try { if (!dryRun && !process.env.CI) { @@ -40,28 +43,26 @@ try { log("[dry-run] skipping working-tree + branch preflight"); } - const { coreJson } = await runCheck(); - const version = coreJson.version; // === cliJson.version, asserted by runCheck + const { coreJson } = await runCheck({ knowledge }); + const version = coreJson.version; // all packages share this, asserted by runCheck step(`idempotency: check ${version} against registry`); - const core = findPackage("core"); - const cli = findPackage("cli"); - const corePublished = npmViewExists(core.name, version); - const cliPublished = npmViewExists(cli.name, version); - log(`${core.name}@${version}: ${corePublished ? "already published" : "to publish"}`); - log(`${cli.name}@${version}: ${cliPublished ? "already published" : "to publish"}`); - if (corePublished && cliPublished) { - log("\nboth packages already published; nothing to do."); + const published = new Map(); + for (const pkg of packages) { + const exists = npmViewExists(pkg.name, version); + published.set(pkg.key, exists); + log(`${pkg.name}@${version}: ${exists ? "already published" : "to publish"}`); + } + if (packages.every((pkg) => published.get(pkg.key))) { + log("\nall packages already published; nothing to do."); process.exit(0); } - if (!corePublished) { - step(`publish ${core.name}@${version} (tag=latest, provenance)`); - pnpmPublish(core, { tag: "latest", provenance: true, dryRun }); - } - if (!cliPublished) { - step(`publish ${cli.name}@${version} (tag=latest, provenance)`); - pnpmPublish(cli, { tag: "latest", provenance: true, dryRun }); + // Publish in dependency order. + for (const pkg of packages) { + if (published.get(pkg.key)) continue; + step(`publish ${pkg.name}@${version} (tag=latest, provenance)`); + pnpmPublish(pkg, { tag: "latest", provenance: true, dryRun }); } if (dryRun) {