From 208b1ce459293fed9f7e0d9718144a547207f22f Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 29 May 2026 17:50:07 +0800 Subject: [PATCH 01/21] feat(cli): update the default vision model to qwen3-vl-plus --- packages/cli/src/commands/file/upload.ts | 2 +- packages/cli/src/commands/vision/describe.ts | 4 ++-- packages/cli/src/pipeline/steps/bl-api.ts | 2 +- packages/cli/tests/e2e/file-upload.e2e.test.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/file/upload.ts b/packages/cli/src/commands/file/upload.ts index edc4c12..62ec72d 100644 --- a/packages/cli/src/commands/file/upload.ts +++ b/packages/cli/src/commands/file/upload.ts @@ -27,7 +27,7 @@ export default defineCommand({ }, ], examples: [ - "bl file upload --file photo.jpg --model qwen-vl-max", + "bl file upload --file photo.jpg --model qwen3-vl-plus", "bl file upload --file video.mp4 --model wan2.1-t2v-plus", "bl file upload --file audio.wav --model qwen3-asr-flash", "bl file upload --file cat.png --model qwen-image-2.0", diff --git a/packages/cli/src/commands/vision/describe.ts b/packages/cli/src/commands/vision/describe.ts index f0121f2..153ae9a 100644 --- a/packages/cli/src/commands/vision/describe.ts +++ b/packages/cli/src/commands/vision/describe.ts @@ -68,7 +68,7 @@ export default defineCommand({ type: "array", }, { flag: "--prompt ", description: "Question about the content (default: auto-detected)" }, - { flag: "--model ", description: "Vision model (default: qwen-vl-max)" }, + { flag: "--model ", description: "Vision model (default: qwen3-vl-plus)" }, ], examples: [ "bl vision describe --image photo.jpg", @@ -82,7 +82,7 @@ export default defineCommand({ | string | undefined; const videoInputs = (flags.video as string[] | undefined) ?? []; - const model = (flags.model as string) || "qwen-vl-max"; + const model = (flags.model as string) || "qwen3-vl-plus"; // Auto-detect: if --image was given a video file, treat it as --video if (image && isVideoInput(image)) { diff --git a/packages/cli/src/pipeline/steps/bl-api.ts b/packages/cli/src/pipeline/steps/bl-api.ts index ee1aa85..b6f7e03 100644 --- a/packages/cli/src/pipeline/steps/bl-api.ts +++ b/packages/cli/src/pipeline/steps/bl-api.ts @@ -104,7 +104,7 @@ export async function visionDescribe( input: VisionDescribeInput, ctx: StepContext, ): Promise { - const model = input.model || "qwen-vl-max"; + const model = input.model || "qwen3-vl-plus"; const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : []; const hasVideo = !!input.video; const defaultPrompt = hasVideo ? "Describe the video." : "Describe the image."; diff --git a/packages/cli/tests/e2e/file-upload.e2e.test.ts b/packages/cli/tests/e2e/file-upload.e2e.test.ts index fed7c1f..da6c611 100644 --- a/packages/cli/tests/e2e/file-upload.e2e.test.ts +++ b/packages/cli/tests/e2e/file-upload.e2e.test.ts @@ -30,7 +30,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => "file", "upload", "--model", - "qwen-vl-max", + "qwen3-vl-plus", "--non-interactive", ]); expect(exitCode).toBe(0); @@ -58,7 +58,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => "--file", testFile, "--model", - "qwen-vl-max", + "qwen3-vl-plus", "--non-interactive", "--output", "json", @@ -67,7 +67,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => const data = parseStdoutJson<{ url?: string; model?: string; expires_in?: string }>(stdout); expect(data.url).toBeDefined(); expect(data.url).toMatch(/^oss:\/\//); - expect(data.model).toBe("qwen-vl-max"); + expect(data.model).toBe("qwen3-vl-plus"); expect(data.expires_in).toBe("48 hours"); }, 120_000); }); From 58f2cce555636c210524f7f1dc3cbfbb349af66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 29 May 2026 18:03:44 +0800 Subject: [PATCH 02/21] chore(release): 1.1.2 --- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 12b1690..020f91f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "1.1.1", + "version": "1.1.2", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/core/package.json b/packages/core/package.json index 85e23f4..173924c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "1.1.1", + "version": "1.1.2", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { From 3e4f1f0ebf0d37347fe38bcdb21fb7a07c24cde0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:34:19 +0000 Subject: [PATCH 03/21] fix(security): stop leaking credentials and tighten on-disk permissions - config set: mask api_key/access_token/access_key_id/access_key_secret in the confirmation echo. It previously printed the stored secret verbatim to stdout (CI logs, pipes, screen shares), unlike `config show` / `auth status` which already maskToken(). - http / knowledge retrieve: use maskToken() in --verbose request logs instead of printing the first 8 chars of the bearer token / AccessKey id. - telemetry: write telemetry.jsonl with mode 0600 (was created world-readable by default), matching the other credential-area writers. - ensureConfigDir: chmod 0700 after mkdir, so a pre-existing ~/.bailian created by an older build/another tool (where mkdir's mode is ignored) holding cleartext credentials gets locked down too. Best-effort; never fatal. https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1 --- packages/cli/src/commands/config/set.ts | 11 ++++++++++- packages/cli/src/commands/knowledge/retrieve.ts | 3 ++- packages/core/src/client/http.ts | 3 ++- packages/core/src/config/paths.ts | 9 +++++++++ packages/core/src/telemetry/sink.ts | 2 +- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts index 004be0d..9a0288e 100644 --- a/packages/cli/src/commands/config/set.ts +++ b/packages/cli/src/commands/config/set.ts @@ -1,6 +1,7 @@ import { defineCommand, detectOutputFormat, + maskToken, readConfigFile, writeConfigFile, BailianError, @@ -28,6 +29,11 @@ const VALID_KEYS = [ "workspace_id", ]; +// Keys whose values are secrets. Their stored value must never be echoed back in +// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the +// same policy `config show` and `auth status` already follow. +const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]); + // Allow hyphen-style keys (e.g. default-text-model → default_text_model) const KEY_ALIASES: Record = { "base-url": "base_url", @@ -120,7 +126,10 @@ export default defineCommand({ await writeConfigFile(existing); if (!config.quiet) { - emitResult({ [resolvedKey]: existing[resolvedKey] }, format); + const shown = SECRET_KEYS.has(resolvedKey) + ? maskToken(String(existing[resolvedKey])) + : existing[resolvedKey]; + emitResult({ [resolvedKey]: shown }, format); } }, }); diff --git a/packages/cli/src/commands/knowledge/retrieve.ts b/packages/cli/src/commands/knowledge/retrieve.ts index 349fb64..37dd073 100644 --- a/packages/cli/src/commands/knowledge/retrieve.ts +++ b/packages/cli/src/commands/knowledge/retrieve.ts @@ -2,6 +2,7 @@ import { defineCommand, signRequest, detectOutputFormat, + maskToken, type Config, type GlobalFlags, type KnowledgeRetrieveRequest, @@ -105,7 +106,7 @@ export default defineCommand({ if (config.verbose) { process.stderr.write(`> POST ${url}\n`); - process.stderr.write(`> AK: ${accessKeyId.slice(0, 8)}...\n`); + process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); } const timeoutMs = config.timeout * 1000; diff --git a/packages/core/src/client/http.ts b/packages/core/src/client/http.ts index 1a30f30..22490b3 100644 --- a/packages/core/src/client/http.ts +++ b/packages/core/src/client/http.ts @@ -4,6 +4,7 @@ import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { resolveCredential } from "../auth/resolver.ts"; import { mapApiError } from "../errors/api.ts"; +import { maskToken } from "../utils/token.ts"; import { SOURCE_CONFIG, trackingHeaders } from "./headers.ts"; export interface RequestOpts { @@ -58,7 +59,7 @@ export async function request(config: Config, opts: RequestOpts): Promise ${opts.method ?? "GET"} ${opts.url}`); - console.error(`> Auth: ${credential.token.slice(0, 8)}...`); + console.error(`> Auth: ${maskToken(credential.token)}`); console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`); } } diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts index 9e0de01..78e3f45 100644 --- a/packages/core/src/config/paths.ts +++ b/packages/core/src/config/paths.ts @@ -20,4 +20,13 @@ export async function ensureConfigDir(): Promise { const dir = getConfigDir(); const fs = await import("fs/promises"); await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + // `mkdir`'s `mode` only applies to directories it creates (and is masked by + // umask). A config dir created by an older build or another tool may still be + // world/group-readable while holding cleartext credentials, so tighten it + // explicitly. Best-effort: never let a chmod failure break the command. + try { + await fs.chmod(dir, 0o700); + } catch { + /* best effort */ + } } diff --git a/packages/core/src/telemetry/sink.ts b/packages/core/src/telemetry/sink.ts index d7ecc16..7ecf529 100644 --- a/packages/core/src/telemetry/sink.ts +++ b/packages/core/src/telemetry/sink.ts @@ -90,7 +90,7 @@ export async function localSink(event: TrackingEvent): Promise { // 文件还不存在,忽略 } - appendFileSync(path, JSON.stringify(event) + "\n"); + appendFileSync(path, JSON.stringify(event) + "\n", { mode: 0o600 }); } catch { // 埋点逻辑任何异常都不能影响 CLI 主流程 } From d24f203d68ba620abe8895850dd82923f257297f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:34:22 +0000 Subject: [PATCH 04/21] fix(security): encode URL path segments and bound SSE buffering - endpoints: encodeURIComponent the id segments (task_id, app_id, node_id, schema_id) interpolated into request URLs. task_id in particular comes from the server's async-submit response and is fetched back with the bearer token attached, so an unencoded value could steer the authenticated follow-up request to a different path on the host. - stream (SSE parser): cap the in-memory buffer (16 MiB). A stream that never emits a newline, or that builds one enormous event from many data: lines, could otherwise grow the buffer without bound and exhaust process memory. https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1 --- packages/core/src/client/endpoints.ts | 8 ++++---- packages/core/src/client/stream.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index fec9f0b..2c8e0df 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -24,13 +24,13 @@ export function videoGenerateEndpoint(baseUrl: string): string { // ---- Async Task Query ---- export function taskEndpoint(baseUrl: string, taskId: string): string { - return `${baseUrl}/api/v1/tasks/${taskId}`; + return `${baseUrl}/api/v1/tasks/${encodeURIComponent(taskId)}`; } // ---- Application (Agent / Workflow) ---- export function appCompletionEndpoint(baseUrl: string, appId: string): string { - return `${baseUrl}/api/v1/apps/${appId}/completion`; + return `${baseUrl}/api/v1/apps/${encodeURIComponent(appId)}/completion`; } // ---- Memory (DashScope v2) ---- @@ -48,7 +48,7 @@ export function memoryListEndpoint(baseUrl: string): string { } export function memoryNodeEndpoint(baseUrl: string, nodeId: string): string { - return `${baseUrl}/api/v2/apps/memory/memory_nodes/${nodeId}`; + return `${baseUrl}/api/v2/apps/memory/memory_nodes/${encodeURIComponent(nodeId)}`; } // ---- Speech Synthesis (TTS) ---- @@ -70,7 +70,7 @@ export function profileSchemaEndpoint(baseUrl: string): string { } export function userProfileEndpoint(baseUrl: string, schemaId: string): string { - return `${baseUrl}/api/v2/apps/memory/profile_schemas/${schemaId}/profiles`; + return `${baseUrl}/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}/profiles`; } // ---- MCP Services (Streamable HTTP) ---- diff --git a/packages/core/src/client/stream.ts b/packages/core/src/client/stream.ts index 56904aa..6fe6ac2 100644 --- a/packages/core/src/client/stream.ts +++ b/packages/core/src/client/stream.ts @@ -1,3 +1,6 @@ +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + export interface ServerSentEvent { event?: string; data: string; @@ -11,12 +14,20 @@ export async function* parseSSE(response: Response): AsyncGenerator MAX_SSE_BUFFER) { + throw new BailianError("SSE stream exceeded the maximum buffer size.", ExitCode.GENERAL); + } const lines = buffer.split("\n"); buffer = lines.pop() || ""; @@ -43,6 +54,12 @@ export async function* parseSSE(response: Response): AsyncGenerator MAX_SSE_BUFFER) { + throw new BailianError( + "SSE event exceeded the maximum buffer size.", + ExitCode.GENERAL, + ); + } break; case "event": event.event = value; From 8b9986bb4740844d984c1af5e1915ce85e34ad9f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:34:24 +0000 Subject: [PATCH 05/21] fix(security): harden pipeline planning, pointer traversal, and concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expressions: never execute $js during planning/dry-run. `pipeline run --dry-run` is the command a cautious user runs to preview an unfamiliar pipeline; it must not run embedded JavaScript. Planning now returns the expression placeholder instead of calling new Function. - schema (getByJsonPointer): block __proto__/constructor/prototype and require own properties, so a crafted $from/$input path cannot pull object internals (e.g. constructor) out of step output and feed them downstream. - scheduler: clamp --concurrency to a maximum (64) to bound fan-out so a single run cannot launch an unbounded number of concurrent API calls / downloads. Note: the runtime new Function sinks in script/js and $js (arbitrary host code execution) are intentionally left unchanged here — remediating them is a design decision (sandbox vs. literal-only code) for the maintainers; see PR notes. https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1 --- packages/cli/src/pipeline/expressions.ts | 23 ++++------------------- packages/cli/src/pipeline/scheduler.ts | 6 +++++- packages/cli/src/pipeline/schema.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/pipeline/expressions.ts b/packages/cli/src/pipeline/expressions.ts index ef11cf1..4e313af 100644 --- a/packages/cli/src/pipeline/expressions.ts +++ b/packages/cli/src/pipeline/expressions.ts @@ -289,25 +289,10 @@ function resolvePlannedExpression( return combineResolved(undefined, undefined, false, false); } if ("$js" in expression) { - const argsExpressions = (expression.args ?? {}) as Record; - const hasFrom = Object.values(argsExpressions).some((v) => isRecord(v) && "$from" in v); - if (hasFrom) return combineResolved({ ...expression }, { ...expression }, false); - const code = expression.$js as string; - const resolvedArgs: Record = {}; - let sensitive = false; - for (const [key, argExpr] of Object.entries(argsExpressions)) { - const resolved = resolvePlannedExpression(argExpr, pipeline, runtimeInput); - resolvedArgs[key] = resolved.value; - sensitive = sensitive || resolved.sensitive; - } - try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval - const fn = new Function("args", `return (${code})`); - const value = fn(resolvedArgs); - return combineResolved(value, sensitive ? REDACTED : value, sensitive); - } catch { - return combineResolved({ ...expression }, { ...expression }, false); - } + // Planning / dry-run must be a non-executing preview: never run user + // JavaScript here. Surface the expression as an unresolved placeholder so a + // `--dry-run` of an untrusted pipeline cannot trigger code execution. + return combineResolved({ ...expression }, { ...expression }, false); } return combineResolved(expression, expression, false); } diff --git a/packages/cli/src/pipeline/scheduler.ts b/packages/cli/src/pipeline/scheduler.ts index 8a7ab7a..5a2458c 100644 --- a/packages/cli/src/pipeline/scheduler.ts +++ b/packages/cli/src/pipeline/scheduler.ts @@ -70,6 +70,8 @@ export function orderReports( return [...reports].sort((a, b) => (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0)); } +const MAX_CONCURRENCY = 64; + export function normalizeConcurrency(value: number | undefined): number { if (value === undefined) return 1; if (!Number.isInteger(value) || value < 1) { @@ -77,5 +79,7 @@ export function normalizeConcurrency(value: number | undefined): number { details: { issues: ["concurrency must be a positive integer"] }, }); } - return value; + // Cap fan-out so a single run cannot launch an unbounded number of concurrent + // API calls / downloads and exhaust sockets, file descriptors, or memory. + return Math.min(value, MAX_CONCURRENCY); } diff --git a/packages/cli/src/pipeline/schema.ts b/packages/cli/src/pipeline/schema.ts index 744d826..6ad4b2b 100644 --- a/packages/cli/src/pipeline/schema.ts +++ b/packages/cli/src/pipeline/schema.ts @@ -91,6 +91,14 @@ export function getByJsonPointer(value: unknown, pointer: string): unknown { continue; } if (isRecord(current)) { + // A JSON pointer over data must not reach object internals. Block + // prototype-polluting keys and only follow own properties so a crafted + // `$from`/`$input` path cannot pull out `constructor`/`__proto__` and feed + // it into downstream consumers. + if (segment === "__proto__" || segment === "constructor" || segment === "prototype") { + return undefined; + } + if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined; current = current[segment]; continue; } From bb9f941849cd8cf0eb8b9ce76b235c32ba109ec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:44:48 +0000 Subject: [PATCH 06/21] fix(security): require script/js `code` to be a literal (block untrusted-code RCE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit script/js executes its `code` as host JavaScript (via new Function), and a step's `code` is a *resolved* input — so it could be written as `{ $from: }`, turning model/API output into the body of the executed function (untrusted data -> arbitrary host code execution). Pipeline validation now requires script/js `code` to be a literal string: any $from/expression-sourced code is rejected. Authoring a literal script/js step remains supported (the pipeline file is the trust boundary, like a shell/npm script). Combined with "dry-run never executes $js", this closes the path where untrusted text reaches the JS sink. Adds regression tests: $from-sourced code rejected, literal code accepted, dry-run does not execute $js, getByJsonPointer blocks prototype/inherited keys, and concurrency clamps to the maximum. https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1 --- packages/cli/src/pipeline/validation.ts | 15 +++++ packages/cli/tests/index.test.ts | 73 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/cli/src/pipeline/validation.ts b/packages/cli/src/pipeline/validation.ts index 87512d2..642dfc4 100644 --- a/packages/cli/src/pipeline/validation.ts +++ b/packages/cli/src/pipeline/validation.ts @@ -125,6 +125,21 @@ function collectPipelineSemanticIssues( `semantic: step "${stepLabel}" timeout must be a positive number of seconds or duration string`, ); } + + // `script/js` executes its `code` as host JavaScript. Require it to be a + // literal string in the pipeline definition: code sourced from another step + // ($from) or any expression is rejected, so untrusted/model-generated text + // can never become the body of the executed function. + if (step.type === "script/js") { + const code = isRecord(step.input) ? step.input.code : undefined; + if (typeof code !== "string") { + issues.push( + `semantic: step "${stepLabel}" (script/js) requires a literal string "code"; ` + + `code sourced from another step ($from) or an expression is not allowed, ` + + `since it would execute untrusted text as host code`, + ); + } + } } // Check dependency references diff --git a/packages/cli/tests/index.test.ts b/packages/cli/tests/index.test.ts index 507a100..f6853c0 100644 --- a/packages/cli/tests/index.test.ts +++ b/packages/cli/tests/index.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "vite-plus/test"; import { createStepDispatcher } from "../src/pipeline/dispatcher.ts"; import { executePipeline } from "../src/pipeline/executor.ts"; +import { collectPipelineIssues } from "../src/pipeline/validation.ts"; +import { getByJsonPointer } from "../src/pipeline/schema.ts"; +import { normalizeConcurrency } from "../src/pipeline/scheduler.ts"; import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts"; test("cli package skeleton", () => { @@ -34,3 +37,73 @@ test("pipeline execution can use an isolated step dispatcher", async () => { hasSignal: true, }); }); + +test("dry-run never executes $js expressions (preview must not run code)", async () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input) => ({ data: input })); + const flag = "__bailian_dryrun_should_not_run__"; + delete (globalThis as Record)[flag]; + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [ + { + id: "s1", + type: "test/echo", + input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } }, + }, + ], + }; + + const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true }); + expect(report.status).toBe("planned"); + expect((globalThis as Record)[flag]).toBeUndefined(); +}); + +test("script/js rejects non-literal code sourced from another step ($from)", () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input) => ({ data: input })); + dispatcher.registerStep("script/js", () => ({ data: {} })); + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [ + { id: "gen", type: "test/echo", input: { message: "x" } }, + { + id: "run", + type: "script/js", + input: { code: { $from: "gen", path: "/data/message" } as never }, + }, + ], + }; + + const issues = collectPipelineIssues(pipeline, dispatcher); + expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true); +}); + +test("script/js accepts a literal string code", () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("script/js", () => ({ data: {} })); + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }], + }; + + expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]); +}); + +test("getByJsonPointer refuses prototype keys and inherited properties", () => { + const obj = { a: { b: 1 } }; + expect(getByJsonPointer(obj, "/a/b")).toBe(1); + expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined(); + expect(getByJsonPointer(obj, "/constructor")).toBeUndefined(); + expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined(); + expect(getByJsonPointer(obj, "/toString")).toBeUndefined(); +}); + +test("normalizeConcurrency clamps to a safe maximum", () => { + expect(normalizeConcurrency(undefined)).toBe(1); + expect(normalizeConcurrency(4)).toBe(4); + expect(normalizeConcurrency(100000)).toBe(64); +}); From ba074f566d7df0483ada2daa21b920c3be4a7ee0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:44:50 +0000 Subject: [PATCH 07/21] fix(security): validate base_url / console_gateway_url as real http(s) URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config file accepted any value that merely starts with "http" (so even "httpfoo://evil" passed) for base_url and console_gateway_url — origins the client sends the Bearer token to. Validate them with `new URL()` and an http:/https: protocol check instead, rejecting malformed values. Valid http(s) URLs (including custom proxies and local http) are unaffected. https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1 --- packages/core/src/config/schema.ts | 20 +++++++++++++++++--- packages/core/tests/index.test.ts | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index c568a2e..7fad89f 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -38,6 +38,21 @@ export interface ConfigFile { const VALID_REGIONS = new Set(["cn", "us", "intl"]); const VALID_OUTPUTS = new Set(["text", "json"]); +/** + * A syntactically valid absolute http(s) URL. Used to validate `base_url` and + * `console_gateway_url` from the config file: the credential-bearing client + * sends the Bearer token to these origins, so a bare `startsWith("http")` check + * (which also accepts e.g. "httpfoo://…") is too loose. + */ +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + export function parseConfigFile(raw: unknown): ConfigFile { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const obj = raw as Record; @@ -50,8 +65,7 @@ export function parseConfigFile(raw: unknown): ConfigFile { out.access_token = obj.accessToken; if (typeof obj.region === "string" && VALID_REGIONS.has(obj.region)) out.region = obj.region as Region; - if (typeof obj.base_url === "string" && obj.base_url.startsWith("http")) - out.base_url = obj.base_url; + if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url; if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile["output"]; if (typeof obj.output_dir === "string" && obj.output_dir.length > 0) @@ -73,7 +87,7 @@ export function parseConfigFile(raw: unknown): ConfigFile { out.access_key_secret = obj.access_key_secret; if (typeof obj.workspace_id === "string" && obj.workspace_id.length > 0) out.workspace_id = obj.workspace_id; - if (typeof obj.console_gateway_url === "string" && obj.console_gateway_url.startsWith("http")) + if (typeof obj.console_gateway_url === "string" && isHttpUrl(obj.console_gateway_url)) out.console_gateway_url = obj.console_gateway_url; if (typeof obj.telemetry === "boolean") out.telemetry = obj.telemetry; diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index a81d1d0..613a529 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "vite-plus/test"; import type { Config } from "../src/index.ts"; import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts"; +import { parseConfigFile } from "../src/config/schema.ts"; function testConfig(overrides: Partial = {}): Config { return { @@ -173,3 +174,16 @@ test("McpClient uses injected client identity for initialize and User-Agent", as params: { clientInfo: { name: "test-client", version: "9.8.7" } }, }); }); + +test("parseConfigFile accepts only well-formed http(s) base_url / console_gateway_url", () => { + expect(parseConfigFile({ base_url: "https://dashscope.aliyuncs.com" }).base_url).toBe( + "https://dashscope.aliyuncs.com", + ); + expect(parseConfigFile({ base_url: "http://localhost:8080" }).base_url).toBe( + "http://localhost:8080", + ); + // Previously accepted because the value merely "starts with http". + expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined(); + expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined(); + expect(parseConfigFile({ console_gateway_url: "ftp://x" }).console_gateway_url).toBeUndefined(); +}); From 0543d27463135d831e6f7f670bbb03f885e96845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Mon, 1 Jun 2026 15:04:49 +0800 Subject: [PATCH 08/21] docs: add UTM tracking params to bailian console links in READMEs --- README_CN.md | 10 +++++----- packages/cli/README_CN.md | 10 +++++----- packages/core/README.md | 2 +- packages/core/README_CN.md | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README_CN.md b/README_CN.md index d6fb8a1..9133e8e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -9,7 +9,7 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org) [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) -[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) +[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&) --- @@ -100,13 +100,13 @@ bl app list bl usage free --model qwen3-max ``` -> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) +> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) ## 认证方式 ### DashScope API Key -大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。 +大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。 ```bash # 方式一:环境变量 @@ -160,11 +160,11 @@ bl update | 资源 | 地址 | | :---------------------- | :-------------------------------------------------------------- | -| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli | +| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& | | DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ | | 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models | | 阿里云百炼控制台 | https://bailian.console.aliyun.com/ | -| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key | +| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& | | 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | ## 更新日志 diff --git a/packages/cli/README_CN.md b/packages/cli/README_CN.md index d6fb8a1..9133e8e 100644 --- a/packages/cli/README_CN.md +++ b/packages/cli/README_CN.md @@ -9,7 +9,7 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org) [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) -[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) +[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&) --- @@ -100,13 +100,13 @@ bl app list bl usage free --model qwen3-max ``` -> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) +> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) ## 认证方式 ### DashScope API Key -大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。 +大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。 ```bash # 方式一:环境变量 @@ -160,11 +160,11 @@ bl update | 资源 | 地址 | | :---------------------- | :-------------------------------------------------------------- | -| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli | +| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& | | DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ | | 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models | | 阿里云百炼控制台 | https://bailian.console.aliyun.com/ | -| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key | +| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& | | 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | ## 更新日志 diff --git a/packages/core/README.md b/packages/core/README.md index 6d2bd60..8102182 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -7,7 +7,7 @@ Internal SDK for [`bailian-cli`](https://www.npmjs.com/package/bailian-cli). Not intended for direct use. For installation, usage, and documentation, see the CLI package: - npm: -- Homepage: +- Homepage: ## License diff --git a/packages/core/README_CN.md b/packages/core/README_CN.md index bd80b57..70f87cd 100644 --- a/packages/core/README_CN.md +++ b/packages/core/README_CN.md @@ -7,7 +7,7 @@ 不建议直接使用。安装、使用方式与文档请参考 CLI: - npm: -- 主页: +- 主页: ## License From c04deceb2798278c65d1e26264c6ac5ccb2b91fc Mon Sep 17 00:00:00 2001 From: lishengzxc <306009337@qq.com> Date: Mon, 1 Jun 2026 16:59:02 +0800 Subject: [PATCH 09/21] feat(auth): split console login into separate file and auto-request apikey Extract console login logic to login-console.ts for better separation of concerns. When no api_key is configured, append needapikey=true to the console login URL so the frontend returns an apiKey in the callback. Co-Authored-By: Claude Opus 4.6 --- .../cli/src/commands/auth/login-console.ts | 388 ++++++++++++++++++ packages/cli/src/commands/auth/login.ts | 301 +------------- 2 files changed, 391 insertions(+), 298 deletions(-) create mode 100644 packages/cli/src/commands/auth/login-console.ts diff --git a/packages/cli/src/commands/auth/login-console.ts b/packages/cli/src/commands/auth/login-console.ts new file mode 100644 index 0000000..e350d1b --- /dev/null +++ b/packages/cli/src/commands/auth/login-console.ts @@ -0,0 +1,388 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import http from "node:http"; + +import { + BailianError, + ExitCode, + getConfigPath, + readConfigFile, + writeConfigFile, +} from "bailian-cli-core"; + +const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; +const MAX_AUTH_CALLBACK_BODY = 65536; + +const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com"; + +export function resolveConsoleOrigin(): string { + return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN; +} + +function readBodyBounded(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_AUTH_CALLBACK_BODY) { + reject(new Error("payload too large")); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function requestContentType(req: http.IncomingMessage): string { + const h = req.headers["content-type"]; + if (Array.isArray(h)) return h[0] ?? ""; + return typeof h === "string" ? h : ""; +} + +function multipartBoundary(contentType: string): string | null { + const parts = contentType.split(";"); + for (const p of parts) { + const s = p.trim(); + if (!s.toLowerCase().startsWith("boundary=")) continue; + let b = s.slice("boundary=".length).trim(); + if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) { + b = b.slice(1, -1); + } + return b.length > 0 ? b : null; + } + return null; +} + +function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null { + const delim = `--${boundaryValue}`; + const segments = raw.split(delim); + for (let i = 1; i < segments.length; i++) { + const part = segments[i]!; + if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue; + const sep = part.match(/\r\n\r\n|\n\n/); + if (!sep || sep.index === undefined) continue; + let value = part.slice(sep.index + sep[0].length); + value = value + .replace(/(?:\r\n)+$/g, "") + .replace(/\n+$/g, "") + .trim(); + if (value) return value; + } + return null; +} + +function tokenFieldFromRecord(o: Record): string | null { + for (const k of ["access_token", "accessToken"]) { + const v = o[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return null; +} + +function apiKeyFieldFromRecord(o: Record): string | null { + for (const k of ["api_key", "apiKey"]) { + const v = o[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return null; +} + +function parseAccessTokenFromJsonText(text: string): string | null { + let t = text.trim(); + if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); + if (!t) return null; + let j: unknown; + try { + j = JSON.parse(t); + } catch { + return null; + } + if (!j || typeof j !== "object" || Array.isArray(j)) return null; + const o = j as Record; + const direct = tokenFieldFromRecord(o); + if (direct) return direct; + const data = o.data; + if (data && typeof data === "object" && !Array.isArray(data)) { + const inner = tokenFieldFromRecord(data as Record); + if (inner) return inner; + } + return null; +} + +function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null { + const ct = contentType.toLowerCase(); + if (!raw.trim()) return null; + + if (ct.includes("multipart/form-data")) { + const b = multipartBoundary(contentType); + if (b) { + const tok = parseAccessTokenFromMultipart(raw, b); + if (tok) return tok; + } + } + + if (ct.includes("application/json") || ct.includes("text/json")) { + const t = parseAccessTokenFromJsonText(raw); + if (t) return t; + } + + if (ct.includes("application/x-www-form-urlencoded")) { + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("access_token") ?? params.get("accessToken"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + } + + // Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it). + const jsonTok = parseAccessTokenFromJsonText(raw); + if (jsonTok) return jsonTok; + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("access_token") ?? params.get("accessToken"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + const b = multipartBoundary(contentType); + if (b) { + const tok = parseAccessTokenFromMultipart(raw, b); + if (tok) return tok; + } + return null; +} + +function parseApiKeyFromJsonText(text: string): string | null { + let t = text.trim(); + if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); + if (!t) return null; + let j: unknown; + try { + j = JSON.parse(t); + } catch { + return null; + } + if (!j || typeof j !== "object" || Array.isArray(j)) return null; + const o = j as Record; + const direct = apiKeyFieldFromRecord(o); + if (direct) return direct; + const data = o.data; + if (data && typeof data === "object" && !Array.isArray(data)) { + const inner = apiKeyFieldFromRecord(data as Record); + if (inner) return inner; + } + return null; +} + +function parseApiKeyFromRawBody(raw: string, contentType: string): string | null { + const ct = contentType.toLowerCase(); + if (!raw.trim()) return null; + + if (ct.includes("application/json") || ct.includes("text/json")) { + const t = parseApiKeyFromJsonText(raw); + if (t) return t; + } + + if (ct.includes("application/x-www-form-urlencoded")) { + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("api_key") ?? params.get("apiKey"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + } + + const jsonTok = parseApiKeyFromJsonText(raw); + if (jsonTok) return jsonTok; + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("api_key") ?? params.get("apiKey"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + return null; +} + +interface CallbackCredentials { + accessToken: string | null; + apiKey: string | null; +} + +async function extractCredentialsFromRequest( + req: http.IncomingMessage, +): Promise { + const u = new URL(req.url ?? "/", "http://127.0.0.1"); + const accessTokenFromQuery = + u.searchParams.get("access_token") ?? u.searchParams.get("accessToken"); + const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey"); + + const m = req.method ?? "GET"; + if (m !== "POST" && m !== "PUT" && m !== "PATCH") { + return { + accessToken: accessTokenFromQuery?.trim() || null, + apiKey: apiKeyFromQuery?.trim() || null, + }; + } + + const contentType = requestContentType(req); + let raw: string; + try { + raw = await readBodyBounded(req); + } catch { + return { + accessToken: accessTokenFromQuery?.trim() || null, + apiKey: apiKeyFromQuery?.trim() || null, + }; + } + + const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType); + const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType); + return { accessToken, apiKey }; +} + +function listenServerOnFreeLocalPort(server: http.Server): Promise { + return new Promise((resolve, reject) => { + const onErr = (e: Error) => reject(e); + server.once("error", onErr); + server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => { + server.off("error", onErr); + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Expected TCP socket address")); + return; + } + resolve(addr.port); + }); + }); +} + +function openInBrowser(url: string): Promise { + const platform = process.platform; + const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + + return new Promise((resolve, reject) => { + execFile(cmd, args, { windowsHide: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export async function runConsoleLogin( + consoleOrigin: string, + opts?: { needApiKey?: boolean }, +): Promise { + const state = randomBytes(16).toString("hex"); + const server = http.createServer(async (req, res) => { + try { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return; + } + + const u = new URL(req.url ?? "/", "http://127.0.0.1"); + if (u.searchParams.get("state") !== state) { + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("bad state\n"); + return; + } + + const { accessToken, apiKey } = await extractCredentialsFromRequest(req); + + if (accessToken || apiKey) { + try { + const existing = readConfigFile() as Record; + if (accessToken) existing.access_token = accessToken; + if (apiKey) existing.api_key = apiKey; + await writeConfigFile(existing); + if (accessToken) process.stderr.write(`access_token saved to ${getConfigPath()}\n`); + if (apiKey) process.stderr.write(`api_key saved to ${getConfigPath()}\n`); + } catch { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Failed to save credentials\n"); + return; + } + } + + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + res.end("OK\n"); + + if (accessToken || apiKey) { + server.close(); + } + } catch { + res.statusCode = 500; + res.end(); + } + }); + + let port: number; + try { + port = await listenServerOnFreeLocalPort(server); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new BailianError( + `Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`, + ExitCode.USAGE, + ); + } + + let loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`; + if (opts?.needApiKey) { + loginUrl += "&needapikey=true"; + } + + try { + await openInBrowser(loginUrl); + process.stderr.write( + "Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write( + `Could not open the default browser (${msg}). Open this URL manually:\n\n`, + ); + process.stdout.write(`${loginUrl}\n`); + process.stderr.write( + "\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", + ); + } + + await new Promise((resolve, reject) => { + let finished = false; + const done = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + server.close(); + }, CONSOLE_LOGIN_TIMEOUT_MS); + + server.once("close", done); + server.once("error", (err) => { + clearTimeout(timer); + if (!finished) { + finished = true; + reject(err); + } + }); + }); +} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 8110705..246858b 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,10 +1,4 @@ -import { execFile } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import http from "node:http"; - import { - BailianError, - ExitCode, chatEndpoint, defineCommand, getConfigPath, @@ -20,193 +14,7 @@ import { printQuickStart } from "../../output/banner.ts"; import { emitBare } from "../../output/output.ts"; import { promptConfirm } from "../../output/prompt.ts"; import { printCurrentCommandHelp } from "../../utils/command-help.ts"; - -const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; -const MAX_AUTH_CALLBACK_BODY = 65536; - -const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com"; - -function resolveConsoleOrigin(): string { - return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN; -} - -function readBodyBounded(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let size = 0; - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_AUTH_CALLBACK_BODY) { - reject(new Error("payload too large")); - return; - } - chunks.push(chunk); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} - -function requestContentType(req: http.IncomingMessage): string { - const h = req.headers["content-type"]; - if (Array.isArray(h)) return h[0] ?? ""; - return typeof h === "string" ? h : ""; -} - -function multipartBoundary(contentType: string): string | null { - const parts = contentType.split(";"); - for (const p of parts) { - const s = p.trim(); - if (!s.toLowerCase().startsWith("boundary=")) continue; - let b = s.slice("boundary=".length).trim(); - if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) { - b = b.slice(1, -1); - } - return b.length > 0 ? b : null; - } - return null; -} - -/** multipart/form-data: find part with name=access_token and return its body. */ -function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null { - const delim = `--${boundaryValue}`; - const segments = raw.split(delim); - for (let i = 1; i < segments.length; i++) { - const part = segments[i]!; - if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue; - const sep = part.match(/\r\n\r\n|\n\n/); - if (!sep || sep.index === undefined) continue; - let value = part.slice(sep.index + sep[0].length); - value = value - .replace(/(?:\r\n)+$/g, "") - .replace(/\n+$/g, "") - .trim(); - if (value) return value; - } - return null; -} - -function tokenFieldFromRecord(o: Record): string | null { - for (const k of ["access_token", "accessToken"]) { - const v = o[k]; - if (typeof v === "string" && v.trim()) return v.trim(); - } - return null; -} - -function parseAccessTokenFromJsonText(text: string): string | null { - let t = text.trim(); - if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); - if (!t) return null; - let j: unknown; - try { - j = JSON.parse(t); - } catch { - return null; - } - if (!j || typeof j !== "object" || Array.isArray(j)) return null; - const o = j as Record; - const direct = tokenFieldFromRecord(o); - if (direct) return direct; - const data = o.data; - if (data && typeof data === "object" && !Array.isArray(data)) { - const inner = tokenFieldFromRecord(data as Record); - if (inner) return inner; - } - return null; -} - -function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null { - const ct = contentType.toLowerCase(); - if (!raw.trim()) return null; - - if (ct.includes("multipart/form-data")) { - const b = multipartBoundary(contentType); - if (b) { - const tok = parseAccessTokenFromMultipart(raw, b); - if (tok) return tok; - } - } - - if (ct.includes("application/json") || ct.includes("text/json")) { - const t = parseAccessTokenFromJsonText(raw); - if (t) return t; - } - - if (ct.includes("application/x-www-form-urlencoded")) { - try { - const params = new URLSearchParams(raw.trim()); - const v = params.get("access_token") ?? params.get("accessToken"); - if (v?.trim()) return v.trim(); - } catch { - /* */ - } - } - - // Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it). - const jsonTok = parseAccessTokenFromJsonText(raw); - if (jsonTok) return jsonTok; - try { - const params = new URLSearchParams(raw.trim()); - const v = params.get("access_token") ?? params.get("accessToken"); - if (v?.trim()) return v.trim(); - } catch { - /* */ - } - const b = multipartBoundary(contentType); - if (b) { - const tok = parseAccessTokenFromMultipart(raw, b); - if (tok) return tok; - } - return null; -} - -async function extractAccessTokenFromRequest(req: http.IncomingMessage): Promise { - const u = new URL(req.url ?? "/", "http://127.0.0.1"); - const fromQuery = u.searchParams.get("access_token") ?? u.searchParams.get("accessToken"); - if (fromQuery?.trim()) return fromQuery.trim(); - - const m = req.method ?? "GET"; - if (m !== "POST" && m !== "PUT" && m !== "PATCH") return null; - - const contentType = requestContentType(req); - try { - const raw = await readBodyBounded(req); - return parseAccessTokenFromRawBody(raw, contentType); - } catch { - return null; - } -} - -/** Binds to an ephemeral port on loopback; the OS only assigns ports that are free at bind time. */ -function listenServerOnFreeLocalPort(server: http.Server): Promise { - return new Promise((resolve, reject) => { - const onErr = (e: Error) => reject(e); - server.once("error", onErr); - server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => { - server.off("error", onErr); - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Expected TCP socket address")); - return; - } - resolve(addr.port); - }); - }); -} - -function openInBrowser(url: string): Promise { - const platform = process.platform; - const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - - return new Promise((resolve, reject) => { - execFile(cmd, args, { windowsHide: true }, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} +import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; async function validateKeyAndPersist(config: Config, key: string): Promise { process.stderr.write("Testing key... "); @@ -228,110 +36,6 @@ async function validateKeyAndPersist(config: Config, key: string): Promise process.stderr.write(`Saved to ${getConfigPath()}\n`); } -/** Listens on 127.0.0.1: so the console can reach the address passed to the browser. */ -async function runConsoleLogin(consoleOrigin: string): Promise { - const state = randomBytes(16).toString("hex"); - const server = http.createServer(async (req, res) => { - try { - if (req.method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(); - return; - } - - const u = new URL(req.url ?? "/", "http://127.0.0.1"); - if (u.searchParams.get("state") !== state) { - res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("bad state\n"); - return; - } - - const accessToken = await extractAccessTokenFromRequest(req); - - if (accessToken) { - try { - const existing = readConfigFile() as Record; - existing.access_token = accessToken; - await writeConfigFile(existing); - process.stderr.write(`access_token saved to ${getConfigPath()}\n`); - } catch { - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Failed to save access_token\n"); - return; - } - } - - res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Access-Control-Allow-Origin": "*", - }); - res.end("OK\n"); - - if (accessToken) { - server.close(); - } - } catch { - res.statusCode = 500; - res.end(); - } - }); - - let port: number; - try { - port = await listenServerOnFreeLocalPort(server); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - throw new BailianError( - `Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`, - ExitCode.USAGE, - ); - } - - const loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`; - - try { - await openInBrowser(loginUrl); - process.stderr.write( - "Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", - ); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write( - `Could not open the default browser (${msg}). Open this URL manually:\n\n`, - ); - process.stdout.write(`${loginUrl}\n`); - process.stderr.write( - "\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", - ); - } - - await new Promise((resolve, reject) => { - let finished = false; - const done = () => { - if (finished) return; - finished = true; - clearTimeout(timer); - resolve(); - }; - const timer = setTimeout(() => { - server.close(); - }, CONSOLE_LOGIN_TIMEOUT_MS); - - server.once("close", done); - server.once("error", (err) => { - clearTimeout(timer); - if (!finished) { - finished = true; - reject(err); - } - }); - }); -} - export default defineCommand({ name: "auth login", description: "Authenticate with API key or console browser login (credentials can coexist)", @@ -353,7 +57,8 @@ export default defineCommand({ ); return; } - await runConsoleLogin(resolveConsoleOrigin()); + const hasApiKey = !!(config.apiKey || config.fileApiKey); + await runConsoleLogin(resolveConsoleOrigin(), { needApiKey: !hasApiKey }); return; } From 4485e67ff4f25d68fe021131916806b9781db01c Mon Sep 17 00:00:00 2001 From: inhai Date: Mon, 1 Jun 2026 18:08:58 +0800 Subject: [PATCH 10/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 521cacd..12f5588 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co

- Click to play the demo video + Click to play the demo video

From 765fa23c5baa97db6d897274b51bc7d1c8c45a60 Mon Sep 17 00:00:00 2001 From: inhai Date: Mon, 1 Jun 2026 18:09:24 +0800 Subject: [PATCH 11/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 12f5588..521cacd 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co

- Click to play the demo video + Click to play the demo video

From 64f7e71783518307a3b7498dab6f1fb06279bc43 Mon Sep 17 00:00:00 2001 From: lishengzxc <306009337@qq.com> Date: Tue, 2 Jun 2026 10:53:31 +0800 Subject: [PATCH 12/21] =?UTF-8?q?feat(auth):=20=E6=94=AF=E6=8C=81=E5=9C=A8?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=E7=99=BB=E5=BD=95=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A4=84=E7=90=86=20API=20=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/commands/auth/login-console.ts | 17 ++++++++++------- packages/cli/src/commands/auth/login.ts | 5 ++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/auth/login-console.ts b/packages/cli/src/commands/auth/login-console.ts index e350d1b..df13b01 100644 --- a/packages/cli/src/commands/auth/login-console.ts +++ b/packages/cli/src/commands/auth/login-console.ts @@ -278,7 +278,7 @@ function openInBrowser(url: string): Promise { export async function runConsoleLogin( consoleOrigin: string, - opts?: { needApiKey?: boolean }, + opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise }, ): Promise { const state = randomBytes(16).toString("hex"); const server = http.createServer(async (req, res) => { @@ -304,12 +304,15 @@ export async function runConsoleLogin( if (accessToken || apiKey) { try { - const existing = readConfigFile() as Record; - if (accessToken) existing.access_token = accessToken; - if (apiKey) existing.api_key = apiKey; - await writeConfigFile(existing); - if (accessToken) process.stderr.write(`access_token saved to ${getConfigPath()}\n`); - if (apiKey) process.stderr.write(`api_key saved to ${getConfigPath()}\n`); + if (accessToken) { + const existing = readConfigFile() as Record; + existing.access_token = accessToken; + await writeConfigFile(existing); + process.stderr.write(`access_token saved to ${getConfigPath()}\n`); + } + if (apiKey && opts?.onApiKey) { + await opts.onApiKey(apiKey); + } } catch { res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Failed to save credentials\n"); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 246858b..86cb7d1 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -58,7 +58,10 @@ export default defineCommand({ return; } const hasApiKey = !!(config.apiKey || config.fileApiKey); - await runConsoleLogin(resolveConsoleOrigin(), { needApiKey: !hasApiKey }); + await runConsoleLogin(resolveConsoleOrigin(), { + needApiKey: !hasApiKey, + onApiKey: (key) => validateKeyAndPersist(config, key), + }); return; } From a490969d586db26cd97122bc3f4ee925416891e7 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Tue, 2 Jun 2026 16:29:43 +0800 Subject: [PATCH 13/21] feat(auth): add retry logic and error handling for API key validation --- .../cli/src/commands/auth/login-console.ts | 9 +++- packages/cli/src/commands/auth/login.ts | 46 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/auth/login-console.ts b/packages/cli/src/commands/auth/login-console.ts index df13b01..f212325 100644 --- a/packages/cli/src/commands/auth/login-console.ts +++ b/packages/cli/src/commands/auth/login-console.ts @@ -281,6 +281,7 @@ export async function runConsoleLogin( opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise }, ): Promise { const state = randomBytes(16).toString("hex"); + let callbackError: unknown; const server = http.createServer(async (req, res) => { try { if (req.method === "OPTIONS") { @@ -313,9 +314,11 @@ export async function runConsoleLogin( if (apiKey && opts?.onApiKey) { await opts.onApiKey(apiKey); } - } catch { + } catch (err: unknown) { + callbackError = err; res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Failed to save credentials\n"); + server.close(); return; } } @@ -388,4 +391,8 @@ export async function runConsoleLogin( } }); }); + + if (callbackError) { + throw callbackError; + } } diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 86cb7d1..0fe05ed 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,4 +1,6 @@ import { + BailianError, + ExitCode, chatEndpoint, defineCommand, getConfigPath, @@ -16,18 +18,58 @@ import { promptConfirm } from "../../output/prompt.ts"; import { printCurrentCommandHelp } from "../../utils/command-help.ts"; import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; +const RETRY_DELAY_BASE_MS = 500; + +function canRetry(err: unknown): boolean { + if (err instanceof BailianError) { + if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) { + return true; + } + const status = err.api?.httpStatus; + return status === 401 || (status !== undefined && status >= 500); + } + if (err instanceof Error) { + return ( + err.name === "AbortError" || + err.name === "TimeoutError" || + err.message.includes("timed out") || + err.message === "fetch failed" + ); + } + return false; +} + async function validateKeyAndPersist(config: Config, key: string): Promise { process.stderr.write("Testing key... "); const testConfig = { ...config, apiKey: key }; - await requestJson(testConfig, { + const requestOpts = { url: chatEndpoint(testConfig.baseUrl), method: "POST", + timeout: Math.min(config.timeout, 30), body: { model: "qwen3.7-max", messages: [{ role: "user", content: "hi" }], max_tokens: 1, }, - }); + }; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await requestJson(testConfig, requestOpts); + break; + } catch (err) { + if (attempt >= 3 || !canRetry(err)) { + process.stderr.write("\n"); + throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", { + cause: err, + }); + } + // retry delay: 500ms, 1000ms, 2000ms + const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + process.stderr.write("Valid\n"); const existing = readConfigFile() as Record; From 375e5608044f3e4b4e4520ef587505cf1b12ef4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Tue, 2 Jun 2026 18:02:39 +0800 Subject: [PATCH 14/21] chore(release): 1.1.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bump bailian-cli and bailian-cli-core to 1.1.3 - changelog: add 1.1.3 entry (console login auto-configures API key, validation retry) - changelog: backfill missing 1.1.2 entry (default vision model → qwen3-vl-plus) --- CHANGELOG.md | 20 ++++++++++++++++++++ CHANGELOG_CN.md | 20 ++++++++++++++++++++ packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba6742..a2ae6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG_CN.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [1.1.3] - 2026-06-02 + +### Added + +- `bl auth login --console` now also obtains and saves a DashScope API key when none is configured, so a single browser login covers both OAuth and API-key setup. + +### Changed + +- API-key validation is more resilient: retries on transient network / 401 / 5xx errors and caps each attempt at 30s. + +## [1.1.2] - 2026-05-29 + +### Changed + +- Default vision model upgraded from `qwen-vl` to `qwen3-vl-plus` for stronger visual reasoning and chart/document parsing. + +### Fixed + +- TypeScript / lint issues surfaced after the 1.1.0 open-source cut. + ## [1.1.1] - 2026-05-29 Documentation-only release. No CLI or SDK behavior changes. diff --git a/CHANGELOG_CN.md b/CHANGELOG_CN.md index ef3cc6b..99866c4 100644 --- a/CHANGELOG_CN.md +++ b/CHANGELOG_CN.md @@ -6,6 +6,26 @@ [English](CHANGELOG.md) · [README](README_CN.md) · [参与贡献](CONTRIBUTING_CN.md) +## [1.1.3] - 2026-06-02 + +### 新增 + +- `bl auth login --console` 在未配置 DashScope API Key 时会自动获取并保存,一次浏览器登录即可完成 OAuth 与 API Key 配置。 + +### 变更 + +- API Key 校验更稳健:网络 / 401 / 5xx 等瞬时错误会自动重试,单次请求超时上限收紧为 30 秒。 + +## [1.1.2] - 2026-05-29 + +### 变更 + +- 默认视觉模型由 `qwen-vl` 升级为 `qwen3-vl-plus`,视觉推理与图表/文档解析能力更强。 + +### 修复 + +- 修复 1.1.0 开源切换后暴露的 TypeScript / lint 问题。 + ## [1.1.1] - 2026-05-29 仅文档更新,CLI 与 SDK 行为无变化。 diff --git a/packages/cli/package.json b/packages/cli/package.json index 020f91f..20ec073 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "1.1.2", + "version": "1.1.3", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/core/package.json b/packages/core/package.json index 173924c..bee0efe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "1.1.2", + "version": "1.1.3", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { From 1499a5de2d292e99f3e26cbb6ba2e7dde6dd7e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Tue, 2 Jun 2026 18:05:03 +0800 Subject: [PATCH 15/21] chore(docs): fix ts problems --- README_CN.md | 12 ++++++------ packages/cli/README_CN.md | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README_CN.md b/README_CN.md index 9133e8e..f76abd7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -158,14 +158,14 @@ bl update ## 相关链接 -| 资源 | 地址 | -| :---------------------- | :-------------------------------------------------------------- | +| 资源 | 地址 | +| :---------------------- | :---------------------------------------------------------------- | | 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& | -| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ | -| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models | -| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ | +| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ | +| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models | +| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ | | 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& | -| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | +| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | ## 更新日志 diff --git a/packages/cli/README_CN.md b/packages/cli/README_CN.md index 9133e8e..f76abd7 100644 --- a/packages/cli/README_CN.md +++ b/packages/cli/README_CN.md @@ -158,14 +158,14 @@ bl update ## 相关链接 -| 资源 | 地址 | -| :---------------------- | :-------------------------------------------------------------- | +| 资源 | 地址 | +| :---------------------- | :---------------------------------------------------------------- | | 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& | -| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ | -| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models | -| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ | +| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ | +| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models | +| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ | | 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& | -| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | +| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | ## 更新日志 From 7e85bdd36c9828a2771f27eb65f5584a9241c813 Mon Sep 17 00:00:00 2001 From: inhai Date: Tue, 2 Jun 2026 19:41:05 +0800 Subject: [PATCH 16/21] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 521cacd..b46377b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,10 @@ npx skills add modelstudioai/skills --all -g ## Quick Start ```bash -# Authenticate +# Authenticate, recommended +bl auth login --console + +# Or authenticate with an API key bl auth login --api-key sk-xxxxx # Chat with Qwen From 3a8121328cc2d551702d7c77c63afcf9d4b81160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Tue, 2 Jun 2026 21:57:59 +0800 Subject: [PATCH 17/21] docs(install): promote `bl auth login --console` as recommended auth method --- INSTALL.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 1314744..cd7edff 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -59,18 +59,25 @@ npx skills add modelstudioai/skills --all -g ## 3. 鉴权(安装后必做才能调 API) -用户需具备阿里云百炼 **API Key**。 +### 推荐:浏览器登录(控制台会话) + +适用于本机交互式安装,无需用户手动复制 API Key: + +1. 执行 `bl auth status --output json`,判断是否已配置。 +2. 若未配置,在**用户本机终端**执行 `bl auth login --console`;命令会拉起浏览器完成阿里云控制台登录授权。 +3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。 + +> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。 + +### 备选:由 Agent 引导用户输入 API Key 后登录 + +适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等): - 获取入口:[百炼控制台 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) -### 推荐:由 Agent 引导用户输入后登录 - -适用于对话式安装(Cursor、Claude 等): - 1. 执行 `bl auth status --output json`,判断是否已配置。 2. 若未配置或后续 API 校验失败,**请用户粘贴 API Key**(可说明从上述控制台复制;勿要求用户发到公开渠道)。 3. 用户提供了 Key 之后,在**用户本机终端**执行(Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>` - 4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。 ### 其他方式 @@ -78,7 +85,6 @@ npx skills add modelstudioai/skills --all -g - **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。 - **写入配置文件**(持久化,与 `auth login` 落盘相同):`bl config set --key api_key --value `(`--key api-key` 亦可)。**不会**像 `bl auth login --api-key` 那样先校验 Key 是否可用;Agent 引导安装时仍**优先**用 `auth login`。 - **命令行临时传入**:需要 API Key 的 `bl` 子命令可在**当次**执行附加全局 `--api-key `,仅本次生效、不落盘(例:`bl text chat --api-key sk-xxx --message "你好"`)。与上文持久化方式不是同一用途。 -- **浏览器登录(控制台会话)**:`bl auth login --console`,用于 `app list`、`usage free` 等控制台能力;与 API Key 登录可并存,详见 `bl auth login --help` ### Agent 安全约束 From 3693f7dacb02aeb79fdbf70a740243d53ea416ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 4 Jun 2026 23:02:38 +0800 Subject: [PATCH 18/21] feat(release): CI-driven publish pipeline via GitHub Actions + npm OIDC Replace tools/release.mjs with two workflow_dispatch flows: - stable: production environment gate (Required Reviewers) + lightweight git tag. Trusted Publishing (OIDC) removes the need for an npm token. - channel beta: disposable 0.0.0-beta-- versions on the corresponding dist-tag, no tag, no commit. Any collaborator can dispatch without npm credentials. Pack-time scans via publint, attw, gitleaks; weekly Dependabot for npm + actions. --- .github/dependabot.yml | 18 ++ .github/workflows/ci.yml | 30 +++ .github/workflows/release-channel.yml | 47 ++++ .github/workflows/release-stable.yml | 43 +++ package.json | 3 +- tools/release.mjs | 370 -------------------------- tools/release/check.mjs | 51 ++++ tools/release/lib/git.mjs | 40 +++ tools/release/lib/npm.mjs | 34 +++ tools/release/lib/pack-scan.mjs | 43 +++ tools/release/lib/packages.mjs | 36 +++ tools/release/lib/proc.mjs | 35 +++ tools/release/lib/validate.mjs | 67 +++++ tools/release/publish-channel.mjs | 95 +++++++ tools/release/publish-stable.mjs | 85 ++++++ 15 files changed, 625 insertions(+), 372 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-channel.yml create mode 100644 .github/workflows/release-stable.yml delete mode 100644 tools/release.mjs create mode 100644 tools/release/check.mjs create mode 100644 tools/release/lib/git.mjs create mode 100644 tools/release/lib/npm.mjs create mode 100644 tools/release/lib/pack-scan.mjs create mode 100644 tools/release/lib/packages.mjs create mode 100644 tools/release/lib/proc.mjs create mode 100644 tools/release/lib/validate.mjs create mode 100644 tools/release/publish-channel.mjs create mode 100644 tools/release/publish-stable.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bc84cdd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + dev-deps: + dependency-type: development + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40c7c80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: lint + typecheck + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm run check + + - run: pnpm test diff --git a/.github/workflows/release-channel.yml b/.github/workflows/release-channel.yml new file mode 100644 index 0000000..521158f --- /dev/null +++ b/.github/workflows/release-channel.yml @@ -0,0 +1,47 @@ +name: Release (channel) + +on: + workflow_dispatch: + inputs: + channel: + description: "dist-tag (kebab-case, e.g. mcp/plugin/advisor). Reserved: latest/beta/alpha/next/rc/canary/dev." + required: true + type: string + +# Serialize channel releases per channel name to avoid racing tarball uploads. +concurrency: + group: release-channel-${{ inputs.channel }} + cancel-in-progress: false + +permissions: + contents: read # no tag, no Release; just publish + id-token: write # OIDC for npm Trusted Publishing + provenance + +jobs: + publish: + name: publish beta to npm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + registry-url: "https://registry.npmjs.org/" + + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION=8.21.2 + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - run: pnpm install --frozen-lockfile + + - name: publish-channel + run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000..e913389 --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,43 @@ +name: Release (stable) + +on: + workflow_dispatch: + +# Only one stable release at a time. +concurrency: + group: release-stable + cancel-in-progress: false + +permissions: + contents: write # push lightweight tag to origin + id-token: write # OIDC for npm Trusted Publishing + provenance + +jobs: + publish: + name: publish to npm + tag + runs-on: ubuntu-latest + environment: production # Required Reviewers gate + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + registry-url: "https://registry.npmjs.org/" + + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION=8.21.2 + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - run: pnpm install --frozen-lockfile + + - name: publish-stable + run: node tools/release/publish-stable.mjs diff --git a/package.json b/package.json index 934c9c3..d46a5bf 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "dev": "pnpm -F bailian-cli-core dev", "bl": "pnpm -F bailian-cli dev", "test": "vp test", - "release:check": "node tools/release.mjs check", - "release:publish": "node tools/release.mjs publish", + "release:check": "node tools/release/check.mjs", "wiki:crawl": "node tools/wiki-crawler/index.mjs", "test:stress": "node packages/cli/tests/stress/run.mjs" }, diff --git a/tools/release.mjs b/tools/release.mjs deleted file mode 100644 index 252ebce..0000000 --- a/tools/release.mjs +++ /dev/null @@ -1,370 +0,0 @@ -import { mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "fs"; -import { tmpdir } from "os"; -import { dirname, join, relative, resolve } from "path"; -import { fileURLToPath } from "url"; -import { spawnSync } from "child_process"; -import { createInterface } from "readline/promises"; -import { stdin as input, stdout as output } from "process"; - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; -const PACKAGES = [ - { key: "core", dir: "packages/core", name: "bailian-cli-core" }, - { key: "cli", dir: "packages/cli", name: "bailian-cli" }, -]; - -function log(message = "") { - process.stdout.write(`${message}\n`); -} - -function step(message) { - log(`\n==> ${message}`); -} - -function fail(message) { - throw new Error(message); -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? ROOT, - stdio: options.stdio ?? "inherit", - encoding: "utf-8", - }); - - if (result.status !== 0) { - const detail = result.stderr?.trim() || result.stdout?.trim(); - fail(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`); - } - - return result.stdout ?? ""; -} - -function readJson(path) { - return JSON.parse(readFileSync(path, "utf-8")); -} - -function packageJson(pkg) { - return readJson(join(ROOT, pkg.dir, "package.json")); -} - -function tarballName(name, version) { - return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; -} - -/** - * Map semver version to npm dist-tag. - * 1.0.0 → latest - * 1.0.0-beta.0 → beta - * 1.0.0-rc.1 → rc - * 1.0.0-alpha.2 → alpha - * 1.0.0-next.5 → next - * Avoids accidentally tagging prereleases as latest. - */ -function deriveDistTag(version) { - const m = /-([a-z]+)\b/i.exec(version); - return m ? m[1].toLowerCase() : "latest"; -} - -function walkFiles(dir) { - const files = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) files.push(...walkFiles(path)); - else if (entry.isFile()) files.push(path); - } - return files; -} - -function assertPublishConfig(pkg, json) { - const registry = json.publishConfig?.registry; - if (registry !== PUBLIC_REGISTRY) { - fail(`${pkg.name} publishConfig.registry must be ${PUBLIC_REGISTRY}, got ${registry}`); - } -} - -function packPackage(pkg, tempDir) { - const json = packageJson(pkg); - const name = json.name; - const version = json.version; - - run("pnpm", ["--filter", name, "pack", "--pack-destination", tempDir]); - - const tarball = join(tempDir, tarballName(name, version)); - statSync(tarball); - return { pkg, json, tarball }; -} - -function extractTarball(tarball, tempDir, label) { - const extractDir = join(tempDir, `extract-${label}`); - run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" }); - const packageDir = join(tempDir, "package"); - renameSync(packageDir, extractDir); - return extractDir; -} - -function looksText(buffer) { - if (buffer.includes(0)) return false; - const sample = buffer.subarray(0, 4096).toString("utf-8"); - return !sample.includes("\uFFFD"); -} - -function scanPackageContents(label, extractDir) { - const denyPathPatterns = [ - /(^|\/)\.env($|\.)/, - /(^|\/)\.npmrc$/, - /(^|\/)\.yarnrc$/, - /(^|\/)\.pnpmfile\.cjs$/, - /(^|\/)\.DS_Store$/, - /(^|\/)npm-debug\.log$/, - /(^|\/)yarn-error\.log$/, - /\.(map|pem|key|crt|p12|pfx|log)$/i, - /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i, - ]; - const secretPatterns = [ - { name: "DashScope API key", re: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{18,}\b/g }, - { name: "Alibaba Cloud access key id", re: /\bLTAI[A-Za-z0-9]{12,}\b/g }, - { - name: "access key secret assignment", - re: /\b(?:access[_-]?key[_-]?secret|aliyun[_-]?access[_-]?key[_-]?secret|alibaba[_-]?cloud[_-]?access[_-]?key[_-]?secret)\b\s*[:=]\s*["'][^"']{12,}["']/gi, - }, - ]; - - const files = walkFiles(extractDir); - for (const file of files) { - const rel = relative(extractDir, file).replaceAll("\\", "/"); - if (denyPathPatterns.some((pattern) => pattern.test(rel))) { - fail(`${label} contains blocked file: ${rel}`); - } - - const size = statSync(file).size; - if (size > 2 * 1024 * 1024) continue; - - const buffer = readFileSync(file); - if (!looksText(buffer)) continue; - - const text = buffer.toString("utf-8"); - for (const pattern of secretPatterns) { - pattern.re.lastIndex = 0; - if (pattern.re.test(text)) { - fail(`${label} may contain ${pattern.name}: ${rel}`); - } - } - } -} - -function assertCliPackage(cliExtractDir, coreJson) { - const json = readJson(join(cliExtractDir, "package.json")); - const deps = json.dependencies ?? {}; - - if (deps["bailian-cli-core"] !== coreJson.version) { - fail(`CLI tarball must depend on bailian-cli-core@${coreJson.version}.`); - } - - if (JSON.stringify(json).includes("workspace:")) { - fail("CLI tarball package.json still contains workspace: dependency."); - } - - const binPath = json.bin?.bl; - if (binPath !== "dist/bailian.mjs") { - fail(`CLI bin.bl must be dist/bailian.mjs, got ${binPath}`); - } - - const bin = readFileSync(join(cliExtractDir, binPath), "utf-8"); - if (!bin.startsWith("#!/usr/bin/env node\n")) { - fail("CLI bin is missing #!/usr/bin/env node shebang."); - } - - if (!bin.includes('from"bailian-cli-core"') && !bin.includes('from "bailian-cli-core"')) { - fail("CLI bundle does not appear to import bailian-cli-core as an external package."); - } -} - -function assertCorePackage(coreExtractDir) { - for (const file of ["dist/index.mjs", "dist/index.d.mts"]) { - statSync(join(coreExtractDir, file)); - } -} - -function assertReadmeSync() { - step("Checking README sync between root and packages/cli"); - for (const file of ["README.md", "README_CN.md"]) { - const rootPath = join(ROOT, file); - const cliPath = join(ROOT, "packages/cli", file); - const rootBuf = readFileSync(rootPath); - const cliBuf = readFileSync(cliPath); - if (!rootBuf.equals(cliBuf)) { - fail( - `${file} differs between root and packages/cli. ` + - `Sync them manually (e.g. \`cp ${file} packages/cli/${file}\`).`, - ); - } - log(`${file}: in sync`); - } -} - -function validatePackages() { - const jsonByKey = new Map(); - - assertReadmeSync(); - - step("Checking package metadata"); - for (const pkg of PACKAGES) { - const json = packageJson(pkg); - if (json.name !== pkg.name) fail(`${pkg.dir} name must be ${pkg.name}`); - assertPublishConfig(pkg, json); - jsonByKey.set(pkg.key, json); - log(`${json.name}@${json.version}`); - } - - const coreJson = jsonByKey.get("core"); - const cliJson = jsonByKey.get("cli"); - if (cliJson.version !== coreJson.version) { - fail(`CLI and core versions should match, got ${cliJson.version} and ${coreJson.version}.`); - } - - const cliCoreDep = cliJson.dependencies?.["bailian-cli-core"]; - if (cliCoreDep !== "workspace:*") { - fail(`CLI source dependency should be "bailian-cli-core": "workspace:*", got ${cliCoreDep}`); - } - - return { coreJson, cliJson }; -} - -function packAndScan(coreJson) { - const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-")); - try { - step("Packing and scanning npm tarballs"); - - const packed = PACKAGES.map((pkg) => packPackage(pkg, tempDir)); - const extracted = new Map(); - - for (const item of packed) { - const extractDir = extractTarball(item.tarball, tempDir, item.pkg.key); - extracted.set(item.pkg.key, extractDir); - scanPackageContents(item.json.name, extractDir); - log(`${item.json.name}: ok`); - } - - assertCorePackage(extracted.get("core")); - assertCliPackage(extracted.get("cli"), coreJson); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} - -function buildPackages() { - run("pnpm", ["--filter", "bailian-cli-core", "run", "build"]); - run("pnpm", ["--filter", "bailian-cli", "run", "build"]); -} - -async function releaseCheck() { - const { coreJson } = validatePackages(); - - step("Installing dependencies with frozen lockfile"); - run("pnpm", ["install", "--frozen-lockfile"]); - - step("Building packages"); - buildPackages(); - - step("Running format, lint, and type checks"); - run("pnpm", ["run", "check"]); - - packAndScan(coreJson); - - log("\nRelease check passed."); -} - -function npmWhoami() { - const result = spawnSync("npm", ["whoami", `--registry=${PUBLIC_REGISTRY}`], { - cwd: ROOT, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - if (result.status !== 0) return null; - return result.stdout.trim() || null; -} - -function ensureNpmLogin() { - step("Checking npm login"); - - let user = npmWhoami(); - if (user) { - log(`Logged in as ${user}`); - return; - } - - log(`Not logged in to ${PUBLIC_REGISTRY}. Launching npm login...`); - const login = spawnSync("npm", ["login", `--registry=${PUBLIC_REGISTRY}`], { - cwd: ROOT, - stdio: "inherit", - }); - if (login.status !== 0) fail("npm login failed."); - - user = npmWhoami(); - if (!user) fail("npm login required before publishing."); - log(`Logged in as ${user}`); -} - -async function confirmPublish(coreJson, cliJson) { - log(""); - log(`About to publish ${coreJson.name}@${coreJson.version}`); - log(`Then publish ${cliJson.name}@${cliJson.version}`); - log(`Registry: ${PUBLIC_REGISTRY}`); - - const rl = createInterface({ input, output }); - const answer = await rl.question("\nType 'publish' to continue: "); - rl.close(); - - if (answer !== "publish") fail("Publish aborted."); -} - -async function releasePublish() { - const { coreJson, cliJson } = validatePackages(); - - step("Building packages"); - buildPackages(); - - packAndScan(coreJson); - - await confirmPublish(coreJson, cliJson); - - ensureNpmLogin(); - - // Derive dist-tag from version: 1.0.0 → latest, 1.0.0-beta.0 → beta, 1.0.0-rc.1 → rc - const distTag = deriveDistTag(coreJson.version); - log(`Publishing under dist-tag: ${distTag}`); - - step(`Publishing ${coreJson.name}`); - run("pnpm", [ - "--filter", - coreJson.name, - "publish", - `--registry=${PUBLIC_REGISTRY}`, - `--tag=${distTag}`, - "--no-git-checks", - ]); - - step(`Publishing ${cliJson.name}`); - run("pnpm", [ - "--filter", - cliJson.name, - "publish", - `--registry=${PUBLIC_REGISTRY}`, - `--tag=${distTag}`, - "--no-git-checks", - ]); - - log("\nPublish complete."); -} - -const command = process.argv[2]; - -try { - if (command === "check") await releaseCheck(); - else if (command === "publish") await releasePublish(); - else fail("Usage: node tools/release.mjs "); -} catch (error) { - process.stderr.write(`\nRelease failed: ${error.message}\n`); - process.exit(1); -} diff --git a/tools/release/check.mjs b/tools/release/check.mjs new file mode 100644 index 0000000..f05e956 --- /dev/null +++ b/tools/release/check.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { fileURLToPath } from "url"; + +import { packAndScan } from "./lib/pack-scan.mjs"; +import { run } from "./lib/proc.mjs"; +import { assertReadmeSync, loadAndValidatePackages } from "./lib/validate.mjs"; + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +/** + * Pure-validation pipeline. Reusable from publish-stable / publish-channel. + * Returns { coreJson, cliJson } for callers that need the parsed package.jsons. + */ +export async function runCheck() { + step("pnpm install --frozen-lockfile"); + run("pnpm", ["install", "--frozen-lockfile"]); + + step("metadata: README sync, version consistency, workspace:* dep"); + assertReadmeSync(); + const { coreJson, cliJson } = loadAndValidatePackages(); + log(`bailian-cli-core@${coreJson.version}`); + log(`bailian-cli@${cliJson.version}`); + + step("build bailian-cli-core"); + run("pnpm", ["--filter", "bailian-cli-core", "run", "build"]); + + step("build bailian-cli"); + run("pnpm", ["--filter", "bailian-cli", "run", "build"]); + + step("pack + scan (publint, attw, gitleaks)"); + packAndScan({ log }); + + log("\nrelease check passed."); + return { coreJson, cliJson }; +} + +const invokedDirectly = process.argv[1] === fileURLToPath(import.meta.url); +if (invokedDirectly) { + try { + await runCheck(); + } catch (error) { + process.stderr.write(`\nrelease check failed: ${error.message}\n`); + process.exit(1); + } +} diff --git a/tools/release/lib/git.mjs b/tools/release/lib/git.mjs new file mode 100644 index 0000000..eb4263c --- /dev/null +++ b/tools/release/lib/git.mjs @@ -0,0 +1,40 @@ +import { runCapture, tryRun, run } from "./proc.mjs"; + +export function headSha7() { + return runCapture("git", ["rev-parse", "--short=7", "HEAD"]); +} + +export function currentBranch() { + // GitHub Actions checks out a detached HEAD; `git rev-parse --abbrev-ref HEAD` + // returns "HEAD" there, so prefer GITHUB_REF_NAME when running in CI. + if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME; + return runCapture("git", ["rev-parse", "--abbrev-ref", "HEAD"]); +} + +export function isWorkingTreeClean() { + return runCapture("git", ["status", "--porcelain"]) === ""; +} + +export function tagExists(tag, { remote = "origin" } = {}) { + // local + if (tryRun("git", ["rev-parse", "--verify", `refs/tags/${tag}`]).status === 0) return true; + // remote — actions/checkout fetch-depth:0 usually fetches tags, but ls-remote + // is the only authoritative source. + const r = tryRun("git", ["ls-remote", "--tags", remote, `refs/tags/${tag}`]); + return r.status === 0 && r.stdout !== ""; +} + +export function createTag(tag) { + // Lightweight tag: just a ref pointing at HEAD. No tagger identity needed, + // so the workflow doesn't need `git config user.name/email`. + run("git", ["tag", tag]); +} + +export function pushTag(tag, remote = "origin") { + run("git", ["push", remote, tag]); +} + +export function utcDateStamp() { + const iso = runCapture("date", ["-u", "+%Y%m%d"]); + return iso; +} diff --git a/tools/release/lib/npm.mjs b/tools/release/lib/npm.mjs new file mode 100644 index 0000000..a8877c7 --- /dev/null +++ b/tools/release/lib/npm.mjs @@ -0,0 +1,34 @@ +import { statSync } from "fs"; +import { join } from "path"; + +import { ROOT, tarballFileName } from "./packages.mjs"; +import { run, tryRun } from "./proc.mjs"; + +/** Returns true if the exact name@version already exists on the registry. */ +export function npmViewExists(name, version) { + const result = tryRun("npm", ["view", `${name}@${version}`, "version"]); + if (result.status === 0 && result.stdout.includes(version)) return true; + // npm view returns non-zero ("E404") for unknown versions; treat as "does not exist" + if (result.stderr.includes("E404") || result.stderr.includes("404")) return false; + if (result.status !== 0) { + throw new Error(`npm view ${name}@${version} failed: ${result.stderr || result.stdout}`); + } + return false; +} + +export function pnpmPack(pkg, destDir, json) { + run("pnpm", ["--filter", pkg.name, "pack", "--pack-destination", destDir], { cwd: ROOT }); + const tarball = join(destDir, tarballFileName(pkg.name, json.version)); + statSync(tarball); + return tarball; +} + +export function pnpmPublish(pkg, { tag, provenance = true, dryRun = false } = {}) { + const args = ["--filter", pkg.name, "publish", "--no-git-checks"]; + if (tag) args.push("--tag", tag); + // --provenance requires OIDC; suppress in dry-run so local devs can test + // the pipeline without GitHub Actions credentials. + if (provenance && !dryRun) args.push("--provenance"); + if (dryRun) args.push("--dry-run"); + run("pnpm", args, { cwd: ROOT }); +} diff --git a/tools/release/lib/pack-scan.mjs b/tools/release/lib/pack-scan.mjs new file mode 100644 index 0000000..f673d00 --- /dev/null +++ b/tools/release/lib/pack-scan.mjs @@ -0,0 +1,43 @@ +import { mkdtempSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { PACKAGES, readPackageJson } from "./packages.mjs"; +import { pnpmPack } from "./npm.mjs"; +import { run } from "./proc.mjs"; + +function extractTarball(tarball, tempDir, key) { + run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" }); + const extractDir = join(tempDir, `extract-${key}`); + renameSync(join(tempDir, "package"), extractDir); + return extractDir; +} + +/** + * pnpm pack each package, then run publint / attw / gitleaks on the tarball + * or the extracted directory. attw only runs on packages that declare types. + */ +export function packAndScan({ log }) { + const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-")); + try { + for (const pkg of PACKAGES) { + const json = readPackageJson(pkg); + log(`packing ${pkg.name}@${json.version}`); + const tarball = pnpmPack(pkg, tempDir, json); + const extractDir = extractTarball(tarball, tempDir, pkg.key); + + log(`publint ${pkg.name}`); + run("npx", ["--yes", "publint", extractDir]); + + if (json.types) { + log(`attw ${pkg.name}`); + run("npx", ["--yes", "@arethetypeswrong/cli", "--pack", extractDir]); + } + + log(`gitleaks ${pkg.name}`); + run("gitleaks", ["detect", "--source", extractDir, "--no-git", "--redact"]); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/tools/release/lib/packages.mjs b/tools/release/lib/packages.mjs new file mode 100644 index 0000000..6416ea1 --- /dev/null +++ b/tools/release/lib/packages.mjs @@ -0,0 +1,36 @@ +import { readFileSync, writeFileSync } from "fs"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +export const PACKAGES = [ + { key: "core", dir: "packages/core", name: "bailian-cli-core" }, + { key: "cli", dir: "packages/cli", name: "bailian-cli" }, +]; + +export function readJson(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} + +export function packageJsonPath(pkg) { + return join(ROOT, pkg.dir, "package.json"); +} + +export function readPackageJson(pkg) { + return readJson(packageJsonPath(pkg)); +} + +export function writePackageJson(pkg, json) { + writeFileSync(packageJsonPath(pkg), `${JSON.stringify(json, null, 2)}\n`); +} + +export function tarballFileName(name, version) { + return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; +} + +export function findPackage(key) { + const pkg = PACKAGES.find((p) => p.key === key); + if (!pkg) throw new Error(`unknown package key: ${key}`); + return pkg; +} diff --git a/tools/release/lib/proc.mjs b/tools/release/lib/proc.mjs new file mode 100644 index 0000000..8c83fff --- /dev/null +++ b/tools/release/lib/proc.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from "child_process"; + +import { ROOT } from "./packages.mjs"; + +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT, + stdio: options.stdio ?? "inherit", + env: { ...process.env, ...options.env }, + encoding: "utf-8", + }); + if (result.status !== 0) { + const detail = result.stderr?.trim() || result.stdout?.trim(); + throw new Error(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`); + } + return result.stdout ?? ""; +} + +export function runCapture(command, args, options = {}) { + return run(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +export function tryRun(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT, + stdio: options.stdio ?? ["ignore", "pipe", "pipe"], + env: { ...process.env, ...options.env }, + encoding: "utf-8", + }); + return { + status: result.status, + stdout: (result.stdout ?? "").trim(), + stderr: (result.stderr ?? "").trim(), + }; +} diff --git a/tools/release/lib/validate.mjs b/tools/release/lib/validate.mjs new file mode 100644 index 0000000..b7b7e3c --- /dev/null +++ b/tools/release/lib/validate.mjs @@ -0,0 +1,67 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { PACKAGES, ROOT, readPackageJson } from "./packages.mjs"; + +const README_FILES = ["README.md", "README_CN.md"]; + +export function assertReadmeSync() { + for (const file of README_FILES) { + const rootBuf = readFileSync(join(ROOT, file)); + const cliBuf = readFileSync(join(ROOT, "packages/cli", file)); + if (!rootBuf.equals(cliBuf)) { + throw new Error( + `${file} differs between root and packages/cli. ` + + `Sync them manually (e.g. \`cp ${file} packages/cli/${file}\`).`, + ); + } + } +} + +export function loadAndValidatePackages() { + const jsonByKey = new Map(); + for (const pkg of PACKAGES) { + const json = readPackageJson(pkg); + if (json.name !== pkg.name) { + throw new Error(`${pkg.dir} name must be ${pkg.name}, got ${json.name}`); + } + jsonByKey.set(pkg.key, json); + } + + const coreJson = jsonByKey.get("core"); + const cliJson = jsonByKey.get("cli"); + + 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}.`, + ); + } + + return { coreJson, cliJson }; +} + +const RESERVED_CHANNELS = new Set(["latest", "beta", "alpha", "next", "rc", "canary", "dev"]); +const CHANNEL_FORMAT = /^[a-z][a-z0-9-]{1,30}$/; + +export function assertChannel(channel) { + if (!channel || typeof channel !== "string") { + throw new Error("channel is required"); + } + if (!CHANNEL_FORMAT.test(channel)) { + throw new Error( + `channel "${channel}" must match ${CHANNEL_FORMAT} (lowercase letters/digits/dashes, start with a letter, 2-31 chars).`, + ); + } + if (RESERVED_CHANNELS.has(channel)) { + throw new Error( + `channel "${channel}" is reserved (${[...RESERVED_CHANNELS].join(", ")}); pick a different name.`, + ); + } +} diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs new file mode 100644 index 0000000..4d30b89 --- /dev/null +++ b/tools/release/publish-channel.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "fs"; +import { parseArgs } from "util"; + +import { runCheck } from "./check.mjs"; +import { headSha7, utcDateStamp } from "./lib/git.mjs"; +import { npmViewExists, pnpmPublish } from "./lib/npm.mjs"; +import { + findPackage, + packageJsonPath, + readPackageJson, + writePackageJson, +} from "./lib/packages.mjs"; +import { assertChannel } from "./lib/validate.mjs"; + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +const { values } = parseArgs({ + options: { + channel: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + }, + allowPositionals: false, +}); +const channel = values.channel; +const dryRun = values["dry-run"]; +assertChannel(channel); + +if (!dryRun && !process.env.CI) { + process.stderr.write("publish-channel is CI-only. Pass --dry-run to test locally.\n"); + 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"); + +function restoreOriginals() { + writeFileSync(corePath, coreOriginal); + writeFileSync(cliPath, cliOriginal); +} + +try { + step("compute channel version"); + const sha = headSha7(); + const date = utcDateStamp(); + const betaVersion = `0.0.0-beta-${sha}-${date}`; + 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. + + await runCheck(); + + 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."); + } 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 }); + } + } + + log(`\nchannel release complete: ${channel}@${betaVersion}`); +} catch (error) { + process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`); + process.exitCode = 1; +} finally { + restoreOriginals(); +} diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs new file mode 100644 index 0000000..4891276 --- /dev/null +++ b/tools/release/publish-stable.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +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"; + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +const { values } = parseArgs({ + options: { + "dry-run": { type: "boolean", default: false }, + }, + allowPositionals: false, +}); +const dryRun = values["dry-run"]; + +try { + if (!dryRun && !process.env.CI) { + throw new Error("publish-stable is CI-only. Pass --dry-run to test locally."); + } + + if (!dryRun) { + step("preflight: working tree clean + on main"); + if (!isWorkingTreeClean()) { + throw new Error("git working tree is not clean; commit or stash first."); + } + const branch = currentBranch(); + if (branch !== "main") { + throw new Error(`must publish from main, currently on ${branch}.`); + } + } else { + log("[dry-run] skipping working-tree + branch preflight"); + } + + const { coreJson } = await runCheck(); + const version = coreJson.version; // === cliJson.version, 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."); + 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 }); + } + + if (dryRun) { + log("\n[dry-run] skipping git tag"); + process.exit(0); + } + + const tag = `v${version}`; + if (tagExists(tag)) { + log(`tag ${tag} already exists; skipping tag push`); + } else { + step(`tag ${tag} and push`); + createTag(tag); + pushTag(tag); + } + + log("\nstable release complete."); +} catch (error) { + process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`); + process.exit(1); +} From d6cbe981a8ebfc20ae63fec6a62fca042c779f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 4 Jun 2026 23:16:15 +0800 Subject: [PATCH 19/21] fix(ci): build core/cli before pnpm run check --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40c7c80..f4dd52f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: - run: pnpm install --frozen-lockfile + - run: pnpm -r --filter "./packages/*" build + - run: pnpm run check - run: pnpm test From 7a8c55dd4a68880608b77ffb0a63793429070949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 4 Jun 2026 23:29:03 +0800 Subject: [PATCH 20/21] chore: drop dependabot version-updates config --- .github/dependabot.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index bc84cdd..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: 2 -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - open-pull-requests-limit: 10 - groups: - dev-deps: - dependency-type: development - update-types: - - minor - - patch - - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly From 9cd4adb26c226ac2674aeb5934336d623a62f8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 4 Jun 2026 23:53:18 +0800 Subject: [PATCH 21/21] refactor(release): merge stable + channel into single release.yml npm Trusted Publishers only supports ONE (repo, workflow, environment) tuple per package, so the two split workflows cannot both be granted OIDC publish rights. Consolidate into release.yml with a mode input: - mode=stable -> publish-stable job, environment=production (reviewer gate), contents:write (push lightweight tag). - mode=channel -> publish-channel job, no environment, contents:read. Concurrency group keys on mode + channel so stable serializes globally and channels serialize per dist-tag. Trusted Publisher entry should now point at release.yml with environment left blank (matches both the production-gated stable job and the env-less channel job). --- .github/workflows/release-channel.yml | 47 -------------- .github/workflows/release-stable.yml | 43 ------------- .github/workflows/release.yml | 91 +++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 90 deletions(-) delete mode 100644 .github/workflows/release-channel.yml delete mode 100644 .github/workflows/release-stable.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-channel.yml b/.github/workflows/release-channel.yml deleted file mode 100644 index 521158f..0000000 --- a/.github/workflows/release-channel.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Release (channel) - -on: - workflow_dispatch: - inputs: - channel: - description: "dist-tag (kebab-case, e.g. mcp/plugin/advisor). Reserved: latest/beta/alpha/next/rc/canary/dev." - required: true - type: string - -# Serialize channel releases per channel name to avoid racing tarball uploads. -concurrency: - group: release-channel-${{ inputs.channel }} - cancel-in-progress: false - -permissions: - contents: read # no tag, no Release; just publish - id-token: write # OIDC for npm Trusted Publishing + provenance - -jobs: - publish: - name: publish beta to npm - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: pnpm - registry-url: "https://registry.npmjs.org/" - - - name: Install gitleaks - run: | - set -euo pipefail - GITLEAKS_VERSION=8.21.2 - curl -sSfL \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | sudo tar -xz -C /usr/local/bin gitleaks - gitleaks version - - - run: pnpm install --frozen-lockfile - - - name: publish-channel - run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml deleted file mode 100644 index e913389..0000000 --- a/.github/workflows/release-stable.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Release (stable) - -on: - workflow_dispatch: - -# Only one stable release at a time. -concurrency: - group: release-stable - cancel-in-progress: false - -permissions: - contents: write # push lightweight tag to origin - id-token: write # OIDC for npm Trusted Publishing + provenance - -jobs: - publish: - name: publish to npm + tag - runs-on: ubuntu-latest - environment: production # Required Reviewers gate - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: pnpm - registry-url: "https://registry.npmjs.org/" - - - name: Install gitleaks - run: | - set -euo pipefail - GITLEAKS_VERSION=8.21.2 - curl -sSfL \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | sudo tar -xz -C /usr/local/bin gitleaks - gitleaks version - - - run: pnpm install --frozen-lockfile - - - name: publish-stable - run: node tools/release/publish-stable.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8ffc095 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +# Consolidated workflow because npm Trusted Publishers only allows ONE +# (repo, workflow, environment) tuple per package — so stable + channel +# must share a single workflow file. + +on: + workflow_dispatch: + inputs: + mode: + description: "Release mode" + required: true + type: choice + options: + - stable + - channel + channel: + description: "dist-tag for channel mode (kebab-case, e.g. mcp/plugin/advisor). Reserved: latest/beta/alpha/next/rc/canary/dev. Ignored when mode=stable." + required: false + type: string + +# Serialize stable globally; serialize channel per dist-tag name. +concurrency: + group: release-${{ inputs.mode }}-${{ inputs.channel }} + cancel-in-progress: false + +jobs: + publish-stable: + if: inputs.mode == 'stable' + name: publish stable to npm + tag + runs-on: ubuntu-latest + environment: production # Required Reviewers gate + permissions: + contents: write # push lightweight tag to origin + id-token: write # OIDC for npm Trusted Publishing + provenance + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + registry-url: "https://registry.npmjs.org/" + + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION=8.21.2 + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - run: pnpm install --frozen-lockfile + + - name: publish-stable + run: node tools/release/publish-stable.mjs + + publish-channel: + if: inputs.mode == 'channel' + name: publish beta to npm + runs-on: ubuntu-latest + permissions: + contents: read # no tag, no Release; just publish + id-token: write # OIDC for npm Trusted Publishing + provenance + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + registry-url: "https://registry.npmjs.org/" + + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION=8.21.2 + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - run: pnpm install --frozen-lockfile + + - name: publish-channel + run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}"