diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4dd52f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +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 -r --filter "./packages/*" build + + - run: pnpm run check + + - run: pnpm test 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 }}" 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/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 安全约束 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 diff --git a/README_CN.md b/README_CN.md index d6fb8a1..f76abd7 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 # 方式一:环境变量 @@ -158,14 +158,14 @@ bl update ## 相关链接 -| 资源 | 地址 | -| :---------------------- | :-------------------------------------------------------------- | -| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli | -| 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 | -| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | +| 资源 | 地址 | +| :---------------------- | :---------------------------------------------------------------- | +| 阿里云百炼 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/cli?source_channel=key_github& | +| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | ## 更新日志 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/packages/cli/README_CN.md b/packages/cli/README_CN.md index d6fb8a1..f76abd7 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 # 方式一:环境变量 @@ -158,14 +158,14 @@ bl update ## 相关链接 -| 资源 | 地址 | -| :---------------------- | :-------------------------------------------------------------- | -| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli | -| 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 | -| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | +| 资源 | 地址 | +| :---------------------- | :---------------------------------------------------------------- | +| 阿里云百炼 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/cli?source_channel=key_github& | +| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak | ## 更新日志 diff --git a/packages/cli/package.json b/packages/cli/package.json index 12b1690..20ec073 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.3", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", 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..f212325 --- /dev/null +++ b/packages/cli/src/commands/auth/login-console.ts @@ -0,0 +1,398 @@ +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; 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") { + 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 { + 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 (err: unknown) { + callbackError = err; + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Failed to save credentials\n"); + server.close(); + 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); + } + }); + }); + + if (callbackError) { + throw callbackError; + } +} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 8110705..0fe05ed 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,7 +1,3 @@ -import { execFile } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import http from "node:http"; - import { BailianError, ExitCode, @@ -20,206 +16,60 @@ 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"; +import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; -const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; -const MAX_AUTH_CALLBACK_BODY = 65536; +const RETRY_DELAY_BASE_MS = 500; -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); +function canRetry(err: unknown): boolean { + if (err instanceof BailianError) { + if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) { + return true; } - return b.length > 0 ? b : null; + const status = err.api?.httpStatus; + return status === 401 || (status !== undefined && status >= 500); } - 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; + if (err instanceof Error) { + return ( + err.name === "AbortError" || + err.name === "TimeoutError" || + err.message.includes("timed out") || + err.message === "fetch failed" + ); } - 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(); - }); - }); + 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; @@ -228,110 +78,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 +99,11 @@ export default defineCommand({ ); return; } - await runConsoleLogin(resolveConsoleOrigin()); + const hasApiKey = !!(config.apiKey || config.fileApiKey); + await runConsoleLogin(resolveConsoleOrigin(), { + needApiKey: !hasApiKey, + onApiKey: (key) => validateKeyAndPersist(config, key), + }); return; } 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/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; } 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); +}); 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 diff --git a/packages/core/package.json b/packages/core/package.json index 85e23f4..bee0efe 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.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": { 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/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/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; 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/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/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 主流程 } 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(); +}); 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); +}