commit 1533e2013e612820b2d80908c4bf9ffdc4f9e54a Author: 若麒 Date: Thu May 28 18:37:07 2026 +0800 Initial commit diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..689f288 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies & build output +node_modules +dist +dist-ssr +tools/generated + +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Crawled document metadata (generated) +*.meta.json + +# AI agent / IDE config (machine-local — keep out of the public repo) +.claude/worktrees/ +.claude/settings.json +.claude/settings.local.json +.cursor/ +.qwen/ +.playwright-mcp/ +.pnpm-store/ + +# Test & scene outputs +/test/ +packages/cli/scene/**/outputs/ + +# Environment variables (sensitive data) +.env diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100755 index 0000000..85fb65b --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..46cef3b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["VoidZero.vite-plus-extension-pack"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c68cc6e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# bailian-cli — AI 维护指南 + +本文件是 AI agent 维护本仓库时的契约。每次进入项目首先读这里,从下方"业务场景索引"挑一条,跳到对应的详细文档,按它的清单完成改动。 + +## 项目地图 + +monorepo 双包结构: + +- `packages/cli` — `bailian-cli` 包,CLI 命令、UI、入口 +- `packages/core` — `bailian-cli-core` 包,鉴权 / HTTP / 类型,纯逻辑层 + +### `packages/cli` 目录要点 + +``` +packages/cli/ +├── src/ +│ ├── main.ts # 入口、鉴权分支、调用 registry +│ ├── registry.ts # 命令树解析、动态 help(读 catalog) +│ ├── commands/ +│ │ ├── catalog.ts # 命令总表(登记处,构建脚本也读它) +│ │ ├── index.ts # re-export commands +│ │ └── /...ts # 各命令 defineCommand 实现 +│ ├── output/ # CLI 输出、prompt、progress +│ └── urls.ts # 控制台/文档 URL(仅 cli) +└── tests/e2e/ +``` + +Skill / 命令手册不再随 npm 包发布,改由独立的 `npx add skills` 机制安装。`tools/generate-reference.ts` 仍然从 `catalog.ts` 生成命令手册到 `tools/generated/reference/`(gitignore,临时),等新机制接入后再迁走。 + +非代码资产: + +- `tools/release.mjs` — 发版自动化 +- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册(临时输出到 `tools/generated/reference/`) +- `README.md` / `README_CN.md` — npm 和 GitHub 主页 + +约定: + +- core 是纯库,不依赖 cli(详见下方通用约定) +- 文件路径与命令路径一一对应:`commands/text/chat.ts` ↔ `bl text chat` +- 单级命令:`commands/.ts`(如 `update.ts`);两级:`commands//.ts` +- 命令登记在 **`catalog.ts`**;`bl --help` 与 `tools/generate-reference.ts` 生成的命令手册同源,见 [command-add-remove.md](docs/agents/command-add-remove.md) + +## 业务场景索引 + +按当前任务从下表挑一条进入对应文档: + +| 场景 | 何时进入 | 详见 | +| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ | +| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) | +| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) | +| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) | +| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | +| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | +| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | +| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | +| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | +| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) | +| 发版前自检 | beta / rc / 正式发布到 npm | [docs/agents/release.md](docs/agents/release.md) | +| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) | + +如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增一份 `docs/agents/.md`,把清单沉淀下来。 + +## 通用约定 + +下面两条与场景无关,任何改动都适用。每次完成改动后自查。 + +### 1. cli 和 core 版本号同步 + +`packages/cli/package.json` 和 `packages/core/package.json` 的 `version` 字段必须始终相等。一动两动。 + +### 2. core 是纯库,cli 是 core 的 UI 层 + +core 不应该知道 cli 的存在。具体表现: + +- core 不写 stderr,不调 `process.exit`(用 `console.*` 或 `throw`) +- core 抛的 `BailianError`,hint 字符串不出现 `bl xxx` 命令名 +- core 不写死域名 / region / 追踪参数(URL 集中在 `packages/cli/src/urls.ts`) +- core 接收 cli 通过 `Config` 注入的 metadata(`clientName` / `clientVersion`) + +### 3. 错误处理边界:CLI 不翻译服务端错误 + +CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的错误**原样透传**。详见 [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md)。 + +| 错误来源 | 归类 | 处理方式 | +| ---------------------------------------------------- | -------- | ----------------------------------------------------------- | +| 命令解析、缺 flag、参数校验 | **内部** | `BailianError(USAGE)` | +| 文件 I/O(ENOENT/EACCES/...) | **内部** | `BailianError(GENERAL)` + errno-specific hint | +| 本地 credentials 缺失(resolver/ensure-key/AK-SK 等) | **内部** | `BailianError(AUTH)` | +| `fetch` 自身失败(DNS/TCP/TLS/proxy) | **内部** | `BailianError(NETWORK)` + 读 `err.cause.code` 给 errno-hint | +| polling 客户端超时 | **内部** | `BailianError(TIMEOUT)` | +| HTTP 4xx/5xx、HTTP 200 + 业务错码、async task FAILED | **服务** | `BailianError(GENERAL)`,**message 原样透传**,不分类、不替换 | + +不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎(详见 `docs/agents/error-hint-change.md` 中的反面 case)。 + +## 完成改动后的快速验证 + +```sh +vp check # format + lint + type check +vp test # unit + e2e (e2e 需 API key) +``` + +## 这份指南本身怎么演化 + +这套文档不是写完就死,**随真实工作沉淀**。完成每次改动后回看:这次发现的漏点该不该补?是不是一类新场景? + +新增场景 / 改主入口 / 跨文档引用规则 → [docs/agents/maintaining-agent-docs.md](docs/agents/maintaining-agent-docs.md) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ccd7a5d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to `bailian-cli` and `bailian-cli-core` are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The two packages share a single version number — they are always released together. + +[中文版](CHANGELOG_CN.md) + +## [1.1.0] - 2026-05-28 + +Initial public release on GitHub. The CLI was previously developed internally; this is the first version published as open source under Apache-2.0. + +### Added + +Out-of-the-box capabilities your AI agent can compose across complex tasks: + +**Model services** + +| Capability | Default | Description | +| ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------- | +| Text generation | `qwen3.7-max` | Flagship Max model for the agent era — strong at coding, office work, and long-horizon autonomy | +| Speech synthesis | `cosyvoice-v3-flash` | Multi-voice real-time streaming TTS with enhanced naturalness/emotion; clone from 5–20s samples | +| Speech recognition | `fun-asr` | 7 Chinese dialects + 20+ Mandarin accents; covers 30 languages | +| Image generation | `qwen-image-2.0` | Fused generation & editing, pro text rendering, photorealism, strong semantic adherence | +| Image editing | `qwen-image-2.0` | Smart editing with multi-image composition | +| Image-to-video | `happyhorse-1.0-i2v` | Faithful text-semantic interpretation, smooth high-quality output | +| Text-to-video | `happyhorse-1.0-t2v` | Vivid motion reproduction with rich detail | +| Reference-to-video | `happyhorse-1.0-r2v` | Up to 9 reference images; stable subject & scene preservation | +| Video editing | `happyhorse-1.0-video-edit` | Natural-language video editing, up to 5 reference images | +| Vision understanding | `qwen-vl` | Long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR | + +**Application data** + +| Capability | Default | Description | +| -------------- | -------------------------------- | -------------------------------------------------------------------- | +| Knowledge base | Aliyun Model Studio Knowledge | Multimodal RAG CRUD and retrieval; requires AccessKey | +| Memory | Aliyun Model Studio Memory | Cross-session persistence for personalized coherent dialogue | + +**Application building** + +| Capability | Default | Description | +| -------------- | ---------------- | ------------------------------------------ | +| Workflow calls | Workflow service | Invoke published workflow apps | +| Agent calls | Agent service | Invoke published agent apps | + +**Tools** + +| Capability | Default | Description | +| ------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------ | +| Web search | `bailian_web_search` | Real-time internet retrieval for accuracy and freshness | +| Temp file upload | Temp upload service | Free temp storage; upload local files for URLs (48-hour validity) | +| Free-quota query | Quota query | Check available free-tier quota by model id | +| API reference | Aliyun Model Studio API reference docs | Auto-integrate Aliyun Model Studio model and app capability APIs during build | diff --git a/CHANGELOG_CN.md b/CHANGELOG_CN.md new file mode 100644 index 0000000..0052073 --- /dev/null +++ b/CHANGELOG_CN.md @@ -0,0 +1,53 @@ +# 更新日志 + +`bailian-cli` 和 `bailian-cli-core` 的所有重要变更都记录在此。 + +格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。两个包共享一个版本号,总是一起发布。 + +[English](CHANGELOG.md) + +## [1.1.0] - 2026-05-28 + +GitHub 上的首次公开发布。本项目此前在内部开发,这是首个以 Apache-2.0 协议开源的版本。 + +### 新增 + +让您的 AI Agent 开箱就具备以下能力,并可在复杂任务中自动组合调用: + +**模型服务** + +| 能力 | 默认服务 | 简介 | +| ---------- | ----------------------------- | ----------------------------------------------------------------------------------- | +| 文本生成 | `qwen3.7-max` | 面向智能体时代的旗舰 Max 模型,编程、办公与长周期自主执行能力出色 | +| 语音生成 | `cosyvoice-v3-flash` | 多音色实时流式合成,自然度/情感增强,5-20s 样本即可克隆 | +| 语音识别 | `fun-asr` | 汉语七大方言 + 20+ 口音官话,覆盖 30 种语种 | +| 图像生成 | `qwen-image-2.0` | 图片生成与编辑融合,专业文字渲染、真实质感、强语义遵循 | +| 图像编辑 | `qwen-image-2.0` | 智能编辑,支持多图合成 | +| 图生视频 | `happyhorse-1.0-i2v` | 精准理解文本语义,输出流畅自然的高质量视频 | +| 文生视频 | `happyhorse-1.0-t2v` | 高度还原动态画面,细节丰富 | +| 参考生视频 | `happyhorse-1.0-r2v` | 支持最多 9 张图片参考,稳定主体与场景保持 | +| 视频编辑 | `happyhorse-1.0-video-edit` | 自然语言指令编辑视频,支持最多 5 张图片参考 | +| 视觉理解 | `qwen-vl` | 长视频分析、图表/文档解析、视觉推理、多语言 OCR | + +**应用数据** + +| 能力 | 默认服务 | 简介 | +| ------ | ---------------- | --------------------------------------------- | +| 知识库 | 阿里云百炼知识库 | 多模态数据知识库增删改查检索,需 AccessKey 认证 | +| 记忆库 | 阿里云百炼记忆库 | 跨会话持久化存储,提供个性化连贯对话体验 | + +**应用构建** + +| 能力 | 默认服务 | 简介 | +| ---------- | ---------- | -------------------------- | +| 工作流调用 | 工作流服务 | 调用已有的工作流应用服务 | +| 智能体调用 | 智能体服务 | 调用已有的智能体应用服务 | + +**工具能力** + +| 能力 | 默认服务 | 简介 | +| ------------ | --------------------------------- | ------------------------------------------------------------------------ | +| 联网搜索 | `bailian_web_search` | 实时互联网全栈信息检索,提升回答准确性及时效性 | +| 临时文件上传 | 临时文件上传服务 | 免费临时存储空间,上传本地文件获得 URL(有效期 48 小时) | +| 模型额度查询 | 模型额度查询 | 根据模型 id 查询可以使用的免费额度 | +| 接口文档 | 阿里云百炼模型应用 API 调用参考文档 | 在构建应用的过程中,自动为您的应用集成阿里云百炼模型和应用能力 API | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..c317064 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e3a4a76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to bailian-cli + +Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (DashScope). For end-user usage, see [README.md](README.md). + +[中文版](CONTRIBUTING_CN.md) + +## Prerequisites + +- Node.js ≥ 22.12 +- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`) +- A DashScope API key for running e2e tests + +## Repository layout + +``` +bailian-cli/ +├── packages/ +│ ├── cli/ # `bailian-cli` — CLI entry, commands, UI +│ └── core/ # `bailian-cli-core` — auth, HTTP, types +├── docs/agents/ # Scenario-based maintenance guides +├── tools/ # Release & reference generation +├── AGENTS.md # Contract for AI agents +└── README.md +``` + +## Local setup + +```bash +git clone https://github.com/modelstudioai/cli.git bailian-cli +cd bailian-cli +pnpm install +``` + +### Running the CLI from source + +Open two terminals: + +```bash +# Terminal 1 — watch-build core +pnpm dev + +# Terminal 2 — run any bl command +pnpm bl auth login --api-key sk-xxxxx +pnpm bl text chat --message "hello" +pnpm bl video generate --prompt "a cat walking" +``` + +## Common scripts + +| Command | What it does | +| ---------------- | ------------------------------------------- | +| `pnpm bl ` | Run the CLI from source | +| `pnpm dev` | Watch-build `bailian-cli-core` | +| `pnpm check` | Format + lint + type check | +| `pnpm test` | Unit + e2e (e2e needs `DASHSCOPE_API_KEY`) | +| `pnpm ready` | Full pre-PR verification | + +## AI Native engineering + +This project treats AI agents as first-class maintainers. Three pieces of infrastructure make that work, and they're worth knowing about whether you're a human contributor or an agent: + +- **[AGENTS.md](AGENTS.md) + [docs/agents/](docs/agents/)** — A contract for working in this repo. `AGENTS.md` gives the project map and a scenario index; each `docs/agents/.md` breaks a recurring task (add a command, change a flag, add a model, change an error hint, …) into a checklist that can be followed end-to-end without prior exploration. New scenarios get sedimented back as they emerge — the docs grow with the project, not from a one-off design phase. + +- **E2E test suite** — Cases under [packages/cli/tests/e2e/](packages/cli/tests/e2e/) cover the main flows across text / image / video / speech / understanding / knowledge / memory. They exercise the CLI as a black box against the live DashScope service, so behavior changes (yours or an agent's) are caught the way real users would experience them. + +- **Main-flow stress tests** (`pnpm test:stress`) — Concurrent runs against all model capabilities to verify they stay stable under heavy request volume. Catches rate-limit edge cases, race conditions, and silent regressions that single-shot e2e can miss. Run before each release. + +**If you are an AI agent working on this repo, read [AGENTS.md](AGENTS.md) first** — it's the entry point that routes you to the right scenario doc. + +## Branching & PRs + +- Branch from `main`. Name: `feat/`, `fix/`, `docs/`. +- Run `pnpm check` before opening the PR. +- Keep PRs focused — one logical change per PR. +- Commit messages: imperative and scoped. Examples: `feat(image): add --negative-prompt`, `fix(auth): handle expired console token`. +- Do not bump `packages/*/package.json` versions in feature PRs. + +## Reporting issues + +Bug reports and feature requests both go to https://github.com/modelstudioai/cli/issues. For bugs, please include: + +- CLI version (`bl --version`) +- Node version (`node --version`) +- Exact command that failed +- Full output (redact API keys) + +## License + +By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md new file mode 100644 index 0000000..6f454c6 --- /dev/null +++ b/CONTRIBUTING_CN.md @@ -0,0 +1,89 @@ +# 参与贡献 bailian-cli + +`bailian-cli` 是阿里云百炼(DashScope)的官方 CLI。本文是面向**开发者**的指南;终端用户请看 [README_CN.md](README_CN.md)。 + +[English](CONTRIBUTING.md) + +## 环境要求 + +- Node.js ≥ 22.12 +- pnpm 10.33.2(`npm i -g pnpm@10.33.2`) +- 跑 e2e 需要一个百炼 API Key + +## 仓库结构 + +``` +bailian-cli/ +├── packages/ +│ ├── cli/ # `bailian-cli` —— CLI 入口、命令、UI +│ └── core/ # `bailian-cli-core` —— 鉴权、HTTP、类型 +├── docs/agents/ # 场景化维护文档 +├── tools/ # 发版与命令手册生成 +├── AGENTS.md # AI agent 维护契约 +└── README.md +``` + +## 本地搭建 + +```bash +git clone https://github.com/modelstudioai/cli.git bailian-cli +cd bailian-cli +pnpm install +``` + +### 从源码运行 CLI + +开两个终端: + +```bash +# 终端 1 —— core watch 重建 +pnpm dev + +# 终端 2 —— 跑任意 bl 命令 +pnpm bl auth login --api-key sk-xxxxx +pnpm bl text chat --message "你好" +pnpm bl video generate --prompt "一只走路的猫" +``` + +## 常用脚本 + +| 命令 | 作用 | +| ---------------- | ------------------------------------------ | +| `pnpm bl ` | 从源码运行 CLI | +| `pnpm dev` | watch-build `bailian-cli-core` | +| `pnpm check` | format + lint + 类型检查 | +| `pnpm test` | 单测 + e2e(e2e 需要 `DASHSCOPE_API_KEY`) | +| `pnpm ready` | 提 PR 前的完整自检 | + +## AI Native 工程化 + +我们把 AI agent 当作一等公民的维护者。下面三套基建让这件事能跑起来,无论你是人类贡献者还是 AI agent,都值得了解一下: + +- **[AGENTS.md](AGENTS.md) + [docs/agents/](docs/agents/)** —— 本仓库的维护契约。`AGENTS.md` 给出项目地图和场景索引;每份 `docs/agents/<场景>.md` 把高频任务(加命令、改 flag、加模型、改错误提示……)拆成一份可端到端执行的清单,不需要事先摸索全仓库。新场景随真实工作沉淀回来——这份文档随项目生长,不是一次性的设计产物。 + +- **E2E 测试体系** —— [packages/cli/tests/e2e/](packages/cli/tests/e2e/) 下的用例覆盖文本 / 图像 / 视频 / 语音 / 理解 / 知识库 / 记忆库的主链路,黑盒方式打真实的 DashScope 服务。任何人或 agent 改了行为,会以真实用户感知到的方式被兜住。 + +- **主链路压测**(`pnpm test:stress`)—— 对所有模型能力做并发压测,验证它们在大量请求下仍然稳定;同时捕捉单次 e2e 抓不到的限流边界、竞态、静默回归。每次发版前跑。 + +**如果你是在本仓库上工作的 AI agent,先读 [AGENTS.md](AGENTS.md)** —— 它是会把你引到正确场景文档的入口。 + +## 分支与 PR + +- 从 `main` 拉分支。命名:`feat/`、`fix/`、`docs/`。 +- 提 PR 前先跑 `pnpm check`。 +- 一个 PR 只解决一类问题。 +- 提交信息祈使句、带 scope。例:`feat(image): add --negative-prompt`、`fix(auth): handle expired console token`。 +- 功能 PR 不要去动 `packages/*/package.json` 的版本号。 + +## 反馈问题 + +Bug 和需求都走 https://github.com/modelstudioai/cli/issues。如果是 bug,请包含: + +- CLI 版本(`bl --version`) +- Node 版本(`node --version`) +- 触发问题的完整命令 +- 完整输出(请脱敏 API Key) + +## License + +提交贡献即表示你同意以 [Apache License 2.0](LICENSE) 协议授权。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9eb125c --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Aliyun Model Studio (DashScope) AI Platform + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6f045f --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +
+ +Aliyun Model Studio CLI + +# >\_ Aliyun Model Studio CLI + +**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform** + +[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli) +[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org) +[![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) + +[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli) · [中文文档](https://unpkg.com/bailian-cli/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) + +--- + +_Chat with Qwen, generate images & videos, understand images, call agents,_ +_manage memory, search the web — all from your terminal._ + +_Built for AI Agents. Every command works as a structured tool call._ + +
+ +## Features + +Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks: + +- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding +- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video +- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition +- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference) +- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents +- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR +- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue +- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio +- **Web search** — Real-time internet retrieval for up-to-date, accurate answers +- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`) +- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity + +

+ bl --help +

+ +## Installation + +```bash +npm install -g bailian-cli +npx skills add modelstudioai/skills --all -g +``` + +> Requires Node.js >= 22.12. + +## Quick Start + +```bash +# Authenticate +bl auth login --api-key sk-xxxxx + +# Chat with Qwen +bl text chat --message "What is DashScope?" + +# Multimodal chat (text + image + audio + video) +bl omni --message "Describe this image" --image ./photo.jpg + +# Generate an image +bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/ + +# Generate a video from local image +bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4 + +# Browser login (required for console capability commands) +bl auth login --console + +# Browse apps / free-tier quota +bl app list +bl usage free --model qwen3-max +``` + +> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli) + +## Authentication + +### DashScope API Key + +Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key). + +```bash +# Option 1: Environment variable +export DASHSCOPE_API_KEY=sk-xxxxx + +# Option 2: Login command (persisted to ~/.bailian/config.json) +bl auth login --api-key sk-xxxxx + +# Option 3: Per-command flag +bl text chat --api-key sk-xxxxx --message "Hello" +``` + +### Console Login (OAuth) + +Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in. + +```bash +bl auth login --console +``` + +### Alibaba Cloud AK/SK (Knowledge Base only) + +Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak). + +> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK. + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t... +export ALIBABA_CLOUD_ACCESS_KEY_SECRET=... +export BAILIAN_WORKSPACE_ID=ws-... +``` + +## Configuration + +```bash +# View current config +bl config show + +# Set defaults +bl config set --key region --value us +bl config set --key default_text_model --value qwen-turbo +bl config set --key timeout --value 600 + +# Self-update to latest version +bl update +``` + +Config file location: `~/.bailian/config.json` + +## Links + +| Resource | URL | +| :--------------------------- | :-------------------------------------------------------------- | +| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli | +| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ | +| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models | +| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ | +| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key | +| Get AccessKey | https://ram.console.aliyun.com/manage/ak | diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..c4e71ff --- /dev/null +++ b/README_CN.md @@ -0,0 +1,145 @@ +
+ +Aliyun Model Studio CLI + +# >\_ Aliyun Model Studio CLI + +**阿里云百炼 (DashScope) AI 平台命令行工具** + +[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli) +[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org) +[![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://unpkg.com/bailian-cli/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) + +--- + +_千问对话、图像生成与编辑、视频生成与编辑、图像理解、语音合成与识别、_ +_应用调用、记忆管理、知识检索、联网搜索 — 一行命令,触达所有 AI 能力。_ + +_专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ + +
+ +## 功能特性 + +让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用: + +- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强 +- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持 +- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成 +- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑 +- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话 +- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR +- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验 +- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用 +- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性 +- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`) +- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时 + +

+ bl --help +

+ +## 安装 + +```bash +npm install -g bailian-cli +npx skills add modelstudioai/skills --all -g +``` + +> 需要预先安装 Node.js >= 22.12。 + +## 快速开始 + +```bash +# 认证 +bl auth login --api-key sk-xxxxx + +# 和通义千问对话 +bl text chat --message "你好,介绍一下阿里云百炼平台" + +# 多模态对话(文本 + 图片 + 音频 + 视频) +bl omni --message "描述这张图片" --image ./photo.jpg + +# 生成图片 +bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/ + +# 图生视频(本地文件自动上传) +bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4 + +# 浏览器登录(控制台能力相关命令需要) +bl auth login --console + +# 浏览应用 / 免费额度 +bl app list +bl usage free --model qwen3-max +``` + +> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) + +## 认证方式 + +### DashScope API Key + +大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。 + +```bash +# 方式一:环境变量 +export DASHSCOPE_API_KEY=sk-xxxxx + +# 方式二:登录命令(持久化到 ~/.bailian/config.json) +bl auth login --api-key sk-xxxxx + +# 方式三:命令行参数 +bl text chat --api-key sk-xxxxx --message "你好" +``` + +### 控制台登录(OAuth) + +控制台能力命令(`app list`、`usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。 + +```bash +bl auth login --console +``` + +### 阿里云 AK/SK(仅知识库检索) + +`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 + +> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。 + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t... +export ALIBABA_CLOUD_ACCESS_KEY_SECRET=... +export BAILIAN_WORKSPACE_ID=ws-... +``` + +## 配置 + +```bash +# 查看当前配置 +bl config show + +# 设置默认值 +bl config set --key region --value us +bl config set --key default_text_model --value qwen-turbo +bl config set --key timeout --value 600 + +# 自更新到最新版本 +bl update +``` + +配置文件位置:`~/.bailian/config.json` + +## 相关链接 + +| 资源 | 地址 | +| :---------------------- | :-------------------------------------------------------------- | +| 阿里云百炼 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 | diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md new file mode 100644 index 0000000..349047e --- /dev/null +++ b/docs/agents/auth-change.md @@ -0,0 +1,118 @@ +# 鉴权扩展 + +## 触发条件 + +- 增加新的鉴权方式(OAuth、SSO、控制台回调登录) +- 增加新的 token 来源(env / config / flag / 文件) +- 调整凭证解析优先级 +- 改 `bl auth login` 流程 + +## 鉴权链路 + +``` +flag 优先 ─→ config 文件 ─→ env var + │ │ │ + └──── resolveCredential() (core) ───┐ + │ + ▼ + cli/utils/ensure-key.ts (启动时拦) + 命令注入 Authorization 头 +``` + +凭证类型(`AuthMethod`): + +- `api-key` — DashScope SK(`sk-...`),走 Bearer 头 +- `access-token` — 控制台 OAuth 回调拿到的临时 token,走 Bearer + 不同 endpoint +- `ak/sk` — Alibaba Cloud 标准 AK/SK,走 ROA 签名(只用于知识库) + +### 双凭证并存(API Key + Console) + +`~/.bailian/config.json` 可同时保存 `api_key` 与 `access_token`。**登录任一种方式不得删除另一种**(`bl auth login --api-key` / `--console` 只更新对应字段)。 + +解析分工: + +- `resolveCredential()` — DashScope API 命令(`text chat`、`file upload` 等);config 里两者都有时 **优先 `api_key`** +- `resolveConsoleGatewayCredential()` — 控制台网关(`app list`、`usage free`、`console call`);**只用** env/file 的 `access_token`,忽略 `api_key` + +必改调用点: 凡 `callConsoleGateway` 必须用 `resolveConsoleGatewayCredential`,不能误用 `resolveCredential`(否则 config 仅有 api_key 时会拿 sk- 打网关)。 + +`bl auth logout --console` 只清 `access_token`;全量 `bl auth logout` 清两者。 + +## 必查清单 + +### A. core 层(类型 + 解析) + +- [ ] `packages/core/src/auth/types.ts`: + - 新增 `AuthMethod` 字面量 + - 新增 `ResolvedCredential` 字段(如 token 类型 / 过期时间) +- [ ] `packages/core/src/auth/resolver.ts`: + - `resolveCredential()` 增加新分支 + - 控制台网关命令用 `resolveConsoleGatewayCredential()`(与 DashScope 解析分离) + - 优先级注释保持清晰(数字标号) +- [ ] `packages/core/src/auth/credentials.ts`: + - 如果新方式需要持久化,加 `save*` / `load*` / `clear*` +- [ ] `packages/core/src/config/schema.ts`: + - `Config` 接口加新字段(如 `fileAccessToken`、`accessTokenEnv`) + - `ConfigFile` 接口加对应 disk 字段(snake_case) +- [ ] `packages/core/src/config/loader.ts`: + - `loadConfig()` 把 env / 文件读到 Config 上 + +### B. core 客户端 + +- [ ] `packages/core/src/client/http.ts`: + - 不同 `credential.method` 走不同分支(参考已有 `access-token` 分支走 console gateway) + - Authorization 头注入正确 + +### C. cli 层 + +- [ ] `packages/cli/src/utils/ensure-key.ts`: + - 启动时检查新凭证方式是否已配置,缺的话提示 + - 如果是交互式 setup(类似 `bl auth login --console`),增加新分支 +- [ ] `packages/cli/src/commands/auth/login.ts`: + - 新增 `--xxx` flag 触发新登录流程 + - 持久化到 config(调用 core 的 save 函数) +- [ ] `packages/cli/src/commands/auth/status.ts`: + - 分别显示 `api_key` / `access_token` 是否已配置,以及 DashScope vs 控制台网关各自生效的 credential +- [ ] `packages/cli/src/output/status-bar.ts`: + - 顶部状态条显示新凭证 method + +### D. main 启动逻辑 + +- [ ] `packages/cli/src/main.ts:NO_AUTH_SETUP` 列表: + - 如果新增的命令"自己管鉴权或不需要鉴权",加进去绕开 ensureApiKey 拦截 + - 当前清单以 `main.ts:NO_AUTH_SETUP` 为准 + +### E. 错误文案 + +- [ ] core 的 `BailianError` 鉴权失败 hint **保持通用**(不写 cli 命令名,见 [error-hint-change.md](error-hint-change.md)) +- [ ] cli 的 `enhanceHint` (error-handler.ts) 按 `ExitCode.AUTH` 注入新方式的 cli 命令引导 + +### F. 用户面文档 + +- [ ] `README.md` / `README_CN.md` "Authentication" 段落 + +### G. 测试 + +- [ ] `packages/cli/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径 +- [ ] mask token 的输出格式不变(避免泄漏) + +## 完成后自查 + +```sh +# 各种凭证组合 +unset DASHSCOPE_API_KEY DASHSCOPE_ACCESS_TOKEN +HOME=/tmp/empty node packages/cli/src/main.ts auth status + +# flag 注入 +node packages/cli/src/main.ts auth status --api-key sk-xxx + +# env 注入 +DASHSCOPE_ACCESS_TOKEN=xxx node packages/cli/src/main.ts auth status +``` + +## 常见漏点 + +- ✗ 加了新 token 来源但忘了改 `resolveCredential` 优先级,实际不生效 +- ✗ `Config` 加字段但 `loadConfig` 没读 → 字段永远 undefined +- ✗ `bl auth login` 写成功但 `bl auth status` 不识别(两边走的 storage path 不一致) +- ✗ token mask 显示完整 token,日志泄漏 diff --git a/docs/agents/branch-merge-review.md b/docs/agents/branch-merge-review.md new file mode 100644 index 0000000..de1ba35 --- /dev/null +++ b/docs/agents/branch-merge-review.md @@ -0,0 +1,104 @@ +# 分支合并 Review + +## 触发条件 + +- 评估某分支(feature / pipeline / 重构分支)能否合到 `main` +- 评估合并后对原有功能的侵入性影响 +- 用户问"X 分支可以合 Y 吗 / 有什么影响" + +## 目标 + +- **不破坏原功能**:共享文件的运行时行为、公共类型、构建配置不能静默变化 +- **新功能可发现**:用户可见的新命令/新 flag 必须有文档和示例 + +## 步骤(按顺序) + +### ① 看分歧 + +```sh +git fetch origin +git log --oneline .. # head 比 base 多的提交 +git log --oneline .. # base 比 head 多的提交(双向都看,base 已大幅领先时尤其重要) +``` + +### ② 干跑合并,先确认有无冲突 + +```sh +git merge-tree $(git merge-base ) > /tmp/merge.txt +echo "exit=$?" +grep -E "^(<<<<<<<|>>>>>>>|CONFLICT)" /tmp/merge.txt | head -20 +``` + +- exit=0 且无 `<<<<<<<` → 机器可合,继续 ③ +- 有冲突 → 先列冲突文件,把方案讲清楚再动手 + +### ③ 拆 diff:共享文件 vs 新增文件 + +```sh +git diff --stat ... +git diff --name-only ... +``` + +- **新增文件**(对方分支没有)→ 侵入性 = 0,只看是否需要文档透出(跳到清单 B) +- **共享文件**(两边都有)→ 重点看,逐个跑 `git diff ... -- `,过清单 A + +## 清单 A:侵入性(共享文件必看) + +- [ ] **运行时行为没静默变化**:默认值、错误码 / `ExitCode`、retry 次数、并发度、超时 +- [ ] **公共类型 / 导出签名向后兼容**:新增可选字段 OK;改必填、删字段、改返回类型 → 不行(参考 [packages/core/src/types/](packages/core/src/types/)) +- [ ] **`pnpm-workspace.yaml` 没收窄通配**:`packages/*` 改成显式列表会漏掉目标分支新增的子包(本次 pipeline → main 踩过这个坑,漏了 `packages/skills`) +- [ ] **`package.json` 没破坏发布元数据**:`bin` / `exports` / `files` / `inlinedDependencies` 字段任何删除或改名都要单独评估 +- [ ] **公共依赖没被悄悄升级**:catalog / 根 lockfile 改动要列出来 +- [ ] **`package.json` version 没倒退**:目标分支已经更高时(如 main 1.0.3 vs head 1.0.0-beta.1),手动对齐版本号,不要被 head 覆盖 +- [ ] **全局表没冲突**:`registry.ts`、`NO_AUTH_SETUP`(`packages/cli/src/main.ts`)、`ExitCode` 三个全局表新增项不和现有项冲突 + +## 清单 B:用户透出(用户可见的新东西必看) + +- [ ] **新命令 / 新 flag** 已同步到用户面文档: + - [README.md](README.md) + [README_CN.md](README_CN.md)(中英文都要,常漏 `_CN`) + - (SKILL.md 已迁出本仓库,由 `npx add skills` 机制独立维护,不在本仓库 review 范围) +- [ ] **`bl --help`** 文案完整:`description` / `examples` / `apiDocs` 都填了 +- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例(参考 [packages/cli/scene/](packages/cli/scene/) 的组织方式) +- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异 +- [ ] **错误信息 / 提示文案**:面向用户的字符串通顺、双语(项目主体是中文场景) + +## 清单 C:容易漏的(每条一行扫一眼) + +- [ ] **改了文件但没补测试**:`git diff --stat ... -- '*test*' '*spec*'` 与改动文件清单对照 +- [ ] **新功能埋点同步**:遥测事件名 + 参数 allowlist(参考 main 上的 `feat(telemetry): track console gateway api name in params allowlist` commit) +- [ ] **环境变量**:新增 / 重命名的 env var 进 README,旧的有没有兼容 +- [ ] **i18n**:`README.md` 改了,`README_CN.md` 同步了吗 + +## 输出报告(照模板填) + +``` +冲突: 无 / 有 → <文件列表> +必须修(合并前在 head 分支上 commit 掉): + - <清单项> + <文件:行号> + <一句话原因> + ↑ 只放真正"head 分支自己写错了"的项,例如 pnpm-workspace.yaml 收窄、version 倒退、 + 删了不该删的字段等。这些 fix 应该作为 head 分支上的新 commit,而不是合并解冲突时顺手处理。 +解冲突要点(merge 时不要漏): + - <冲突文件> + <字段/段落> + <怎么取舍> + ↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、 + NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。 +建议修(可后置): + - ... +仅信息(无需动作,告知即可): + - ... +合并姿势: + 1. 在 head 分支上修上面"必须修"的项,提 commit + 2. 合并 main,按"解冲突要点"逐项处理冲突 + 3. + 4. 提 MR 合 main +``` + +## 常见漏点(基于历史踩坑) + +| 漏点 | 后果 | +| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 | +| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 | +| `registry.ts` 注册新命令但忘了 [README](README.md) / [README_CN](README_CN.md) | 用户完全感知不到新功能 | +| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 | +| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API | +| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 | diff --git a/docs/agents/changelog-write.md b/docs/agents/changelog-write.md new file mode 100644 index 0000000..3f3b600 --- /dev/null +++ b/docs/agents/changelog-write.md @@ -0,0 +1,186 @@ +# 撰写 Change Log + +## 触发条件 + +- 发布新版本(beta / rc / stable)后,需要为这个版本写一份 release notes +- 补写历史版本遗漏的 changelog + +## 发布位置 + +更新仓库内的两份文件,英文优先,中文同步: + +- [`CHANGELOG.md`](../../CHANGELOG.md) +- [`CHANGELOG_CN.md`](../../CHANGELOG_CN.md) + +新版本条目插在文件顶部"## [X.Y.Z] - YYYY-MM-DD"位置,旧版本依次向下保留。两份文件保持一一对应——任何条目只在一份里出现,另一份漏写,视为错误。 + +## 输出格式:Keep a Changelog + +本项目采用 [Keep a Changelog](https://keepachangelog.com/) 规范,标准 6 段(按出现顺序): + +| 段名 | 含义 | 示例 | +| ------------ | ----------------------------- | --------------------------------- | +| `Added` | 新增功能 / 命令 / 参数 | 新增 `bl app list` | +| `Changed` | 已有功能行为/形式变化(非破坏) | `--page-num` 改名 `--page` | +| `Deprecated` | 即将移除但当前仍可用 | `--legacy-flag` 弃用,下个版本移除 | +| `Removed` | 本版本删除的能力 | 移除 `--instructions` | +| `Fixed` | Bug 修复 | 修复视频生成模型错误 | +| `Security` | 安全相关修复 | 修复凭据明文落盘 | + +项目扩展段:`Internal`(放研发体系 / 工具链 / 测试基建,与用户无关但值得记录的工程改动)。 + +某段没内容就**直接省略**,不要写"无"。 + +## 撰写步骤 + +### 1. 确定版本边界 + +通过 `package.json` 的版本号变更找出两个版本的 commit 边界: + +```sh +# 找出 packages/cli/package.json 历次版本变更 +git log --all --oneline --pretty=format:'%h %ad %s' --date=short \ + -G '"version"' -- packages/cli/package.json | head +``` + +记下: + +- `prevBumpCommit` — 上一个版本号 bump commit(如 `1.0.0` 的 commit) +- `currBumpCommit` — 当前版本号 bump commit(如 `1.0.1` 的 commit) + +本版本内容 = `git log prevBumpCommit..currBumpCommit`。 + +### 2. 列出本版本所有 commit + +```sh +git log .. --no-merges \ + --pretty=format:'%h %ad %s' --date=short +``` + +> 注意:用 `--no-merges` 过滤 merge commit,避免重复条目。 + +### 3. 逐条核对 commit 是否真的进了本版本 + +merge 顺序复杂时,commit 标题在但内容未必合入。用 `git merge-base --is-ancestor` 严格验证: + +```sh +git merge-base --is-ancestor \ + && echo "IN" || echo "NOT IN" +``` + +例:验证 `agent chat`(commit `12f2b1b`)是否在 `1.0.0`(commit `3fc54ae`)里: + +```sh +git merge-base --is-ancestor 12f2b1b 3fc54ae && echo "IN" || echo "NOT IN" +``` + +**不要凭 commit 标题猜**——分支模型常导致一个功能开发完成但未合入当前 release。 + +### 4. 在 release commit 上抽样校验代码真实存在 + +光看 commit 还不够,要确认目标功能的代码 / 文件在 release commit 上真的存在: + +```sh +# 列出 release commit 下某目录的文件 +git ls-tree -r --name-only -- packages/cli/src/commands/ + +# 看 release commit 下某文件的内容 +git show :packages/cli/src/commands/console/call.ts | head +``` + +特别注意被一行带过的"杂项" commit。本仓库历史踩过坑:`feat(cli): enhance output options and add new commands` 这种标题里藏了**新命令** + **新输出格式** + **logout 增强**三件事,粗看会全部漏掉。 + +```sh +# 看整个 commit 改了哪些文件、新增了多少行 +git show --stat +``` + +只要 `--stat` 里出现新文件或大块新增,就值得展开看。 + +### 5. 区分 Added vs Changed + +- **Added**:新文件、新命令、新参数、新输出格式 → 用户能"用上一个新东西" +- **Changed**:已有功能改名、改默认值、改交互文案、性能优化、参数命名统一 → 用户"原来就在用的东西变样了" + +判断方法:在 `prevBumpCommit` 上 `git show :` 看这个文件 / 函数原来在不在。 + +### 6. 排除"不该写进去"的内容 + +| 不要写 | 原因 | +| --------------------------------------------------- | ---------------------------------- | +| 未发布 / 仅在分支上的功能 | release commit 不包含 → 用户拿不到 | +| 仅在文档 / 设计稿层面的能力 | 没代码就没"功能",不能写进 Added | +| 内部包 / 私有工具的开发过程 commit | 用户不可见 | +| 仓库内 file path / 模块路径(如 `core/telemetry/`) | 用户感知不到内部结构 | +| 临时调试 commit(remove console / 补 TODO 等) | 噪音 | + +### 7. 标记 Breaking Change + +- 单条:在条目末尾加 `**(BREAKING)**` 或行内提示 +- 多条:版本头单独开 `### Breaking Changes` 段(major 升级才允许) + +判断标准:用户**已有的命令 / 脚本 / 集成会因为升级而坏掉**。常见来源: + +- 命令重命名 / 删除 +- 参数重命名 / 删除 +- 默认值变化导致输出格式变化 +- 包结构 / import 路径变化(monorepo 拆包) +- 配置文件 schema 不兼容 + +### 8. 写完后给用户过一遍再写入文件 + +**不要直接编辑 `CHANGELOG.md` / `CHANGELOG_CN.md`**。先把中英两份草稿都贴回对话里,让用户: + +- 增删条目 +- 调整措辞(中英、术语) +- 确认 BREAKING 标记 +- 确认版本边界假设是否成立 + +用户确认后,把新版本块插入两个文件顶部(在 H1 标题与上一个版本块之间)。 + +## 模板 + +```markdown +## [X.Y.Z] - YYYY-MM-DD + +> 一句话概括本版本主线(可省略,大版本/含 breaking 时建议加) + +### Added + +- **<能力名>**:一句话描述用户能做什么 + - 子项 1 + - 子项 2 + +### Changed + +- **<点名>(BREAKING)**:变化前 → 变化后,影响范围 + +### Fixed + +- 修复 X 在 Y 场景下的问题 + +### Internal + +- 工程类改动(不影响用户行为) +``` + +## 常见漏点(基于真实踩坑) + +| 漏点 | 后果 | +| ------------------------------------------------- | ------------------------------------------------------- | +| 只看 commit 标题不看 `--stat` | "enhance output options" 这种笼统标题里藏的新命令被漏掉 | +| 凭 commit 标题判断是否进了 release | 分支没合入,标题在但代码不在 | +| 把分支上 WIP 当作已发布功能 | 用户升级后找不到对应能力,被投诉 | +| 把内部文件路径写进 changelog | 用户看不懂,且暴露内部结构 | +| 优化类改动错放到 Added | 用户以为是新功能去找,找不到入口 | +| 版本号 bump commit 自身的 README 改动算进上个版本 | 重复 / 错位 | +| 中英两份不同步 | 文档可信度直接崩,等同于撒谎 | + +## 与 release.md 的边界 + +| 文档 | 管什么 | +| ------------------------ | ---------------------------------------------------- | +| [release.md](release.md) | 发版前自检:版本号 / 包内容 / 安全扫描 / publish 流程 | +| 本文档 | 发版后写说明:面向用户的 release notes | + +两者顺序:`release.md` → npm publish → 本文档(更新 `CHANGELOG.md` + `CHANGELOG_CN.md`)→ 推到 GitHub。 diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md new file mode 100644 index 0000000..68a647b --- /dev/null +++ b/docs/agents/cli-e2e-tests.md @@ -0,0 +1,100 @@ +# CLI E2E 测试规范 + +## 触发条件 + +- 新增/修改 `packages/cli/src` 下的 command(`commands/catalog.ts` 登记、`defineCommand` 实现、options/usage) +- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例 +- 为命令补 help / 缺参 / dry-run / 真实集成测试 + +以上情况必须同步维护 `packages/cli/tests/e2e/.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。 + +## 文件与工具 + +- 路径:`packages/cli/tests/e2e/.e2e.test.ts` +- 框架:`vite-plus/test`;子进程跑 CLI:`runCli` from `./helpers.ts` +- 解析 JSON stdout:`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))` +- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时 + +## 双层 describe(固定结构) + +```ts +// 1) 不 skip:分组 + --help,无密钥、无真实 API +describe("e2e: ", () => { + test(" 分组展示子命令帮助且成功退出", ...); + test(" --help 正常退出", ...); +}); + +// 2) skipIf:缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑 +describe.skipIf()("e2e: (DashScope …)", () => { + test("缺少 -- 时退出为用法错误 (2)", ...); + test(" --dry-run ...", ...); // 若适用 + test("【model】真实流程", ..., LONG_TIMEOUT); +}); +``` + +## skip 条件(helpers.ts) + +| 场景 | 条件 | +| ------------------- | ----------------------------------------------------- | +| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` | +| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` | +| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` | +| 知识库 | `isKnowledgeE2EReady()` | +| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` | + +## 用例类型 + +1. **分组 help**:`runCli(["image"])` → `exitCode === 0`,stdout+stderr 含子命令名 +2. **--help**:`runCli([..., "--help"])` → stderr 含主要 flags +3. **缺参**:`--non-interactive` 且不传 required flag → `exitCode === 2`,stderr 匹配 `--flag|Missing required argument` +4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网 +5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾** + +## 安全与例外 + +- **禁止真实破坏性操作**:`auth logout` 只用 `--dry-run`;`config set` 只用 `--dry-run` +- **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`) +- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag +- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言 + +## 新增 command 检查清单 + +- [ ] `commands/catalog.ts` 登记 + `tests/e2e/.e2e.test.ts`(新建或扩展) +- [ ] 若改了 `usage` / `options` / `examples`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/`(本仓库 gitignore) +- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help) +- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条 +- [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序 +- [ ] `pnpm test packages/cli/tests/e2e/` 通过 + +## 示例片段 + +```ts +test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli(["foo", "bar", "--non-interactive"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--prompt|Missing required argument/i); +}); + +test("foo bar --dry-run 仅输出计划", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "foo", + "bar", + "--dry-run", + "--prompt", + "x", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: unknown }>(stdout); + expect(data.request).toBeDefined(); +}); +``` + +## 与批量压测的关系 + +- **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件) +- **批量压测**:`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- ` + +勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)。 diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md new file mode 100644 index 0000000..092ec42 --- /dev/null +++ b/docs/agents/command-add-remove.md @@ -0,0 +1,94 @@ +# 命令增删改 + +## 触发条件 + +- 增加新的 `bl xxx` 命令 +- 删除已有命令 +- 重命名命令(包括从单级 `bl x` 改成 `bl x y` 或反向) + +## 命令路径与文件路径的对应规则 + +``` +单级命令(无 group): commands/.ts ↔ bl + 例: commands/update.ts ↔ bl update + +两级命令(有 group): commands//.ts ↔ bl + 例: commands/text/chat.ts ↔ bl text chat + +三级命令(子组,慎用): commands///.ts ↔ bl + 例: commands/memory/profile/create.ts ↔ bl memory profile create + 仅当子组下有 ≥2 个 action 时合理(否则拍平到两级) +``` + +文件路径与命令路径必须 1:1 对齐。 + +## CLI 命令注册架构(必读) + +命令元数据以 **`catalog.ts` 为单一登记处**;`registry.ts` 只负责解析与打印 help,不再内嵌命令表或手写 Resources 列表。 + +``` +commands/<...>.ts defineCommand({ name, description, usage, options, examples, apiDocs?, run }) + ↓ +commands/catalog.ts export const commands: Record + ↓ + ┌────┴────┬──────────────────────┬─────────────────────┐ + ↓ ↓ ↓ ↓ +registry.ts main.ts tools/generate-reference.ts export-schema.ts +(解析/help) (入口) → tools/generated/reference/index.md + .md +``` + +- **`packages/cli/src/commands/catalog.ts`**: `import` 命令模块 + `"": handler` 映射;**不** `import registry.ts`(避免构建时循环依赖) +- **`packages/cli/src/commands/index.ts`**: `export { commands } from "./catalog.ts"`(给包内 re-export 用) +- **`packages/cli/src/registry.ts`**: `import { commands } from "./commands/catalog.ts"`,建树、`resolve`、`printHelp`;Commands / Global Flags 从 `Command` 元数据与 `GLOBAL_OPTIONS` **动态生成** +- **`tools/generate-reference.ts`**: build 前读 `catalog.ts`,写 `tools/generated/reference/index.md`(索引) + `tools/generated/reference/<一级命令>.md`(详情,勿手改)。该目录被 gitignore,产物供未来的 `npx add skills` 安装机制消费 + +已删除、勿再引用:`commands/help.ts`、`registry.ts` 内联 `new CommandRegistry({...})`、`printRootHelp` 手写命令行。 + +## 必查清单 + +### A. 代码层 + +- [ ] **新建/删除/移动**对应的 `packages/cli/src/commands/<...>.ts` 文件 +- [ ] **`packages/cli/src/commands/catalog.ts`**: + - 增删 `import xxx from "./.../xxx.ts"` + - 在 `export const commands` 里增删 `" ": xxx`(key 与 `defineCommand({ name })` 一致) +- [ ] **不要**在 `registry.ts` 里重复登记命令(已从 catalog 读取) +- [ ] 命令需在 `bl help` / `reference/` 展示 API 文档链接时,在 `defineCommand` 里设 `apiDocs`(相对路径);help 与 reference 均从此字段生成 +- [ ] 如果命令需要鉴权之外的特殊路径,看 `packages/cli/src/main.ts` 的 `NO_AUTH_SETUP` +- [ ] **`config/export-schema.ts`**: 若新命令不适合作为 agent tool,评估是否加入 `SKIP_PREFIXES`;该文件在 `run()` 内 `import("../catalog.ts")`,勿顶层 import catalog 以免循环依赖 + +### B. 文档层 + +- [ ] 运行 `pnpm --filter bailian-cli run generate:reference`(或 `build`),刷新 `tools/generated/reference/` 下生成文件(本仓库 gitignore,仅供本地校验和未来 skill 安装机制消费) +- [ ] `README.md` / `README_CN.md`: Quick Start、命令一览(用户向,与 help 对齐即可) +- [ ] SKILL.md 已搬出本仓库(由 `npx add skills` 机制分发),本仓库不再维护 + +### C. 测试层 + +- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/.e2e.test.ts` +- [ ] 删除命令时一并删对应 e2e + +### D. 重命名特殊处理 + +- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新: + - `catalog.ts` 的 key + - error hints(cli 层) + - `tools/generated/reference/`(重建后检查;本仓库 gitignore) + - README 示例 + - 测试断言 + +## 完成后自查 + +```sh +pnpm --filter bailian-cli run generate:reference # reference/ 与 catalog 一致 +node packages/cli/src/main.ts --help +node packages/cli/src/main.ts # 根 help 列表含新命令 +vp test packages/cli/tests/e2e/.e2e.test.ts # 相关 e2e +``` + +## 常见漏点 + +- ✗ 只改了命令文件,忘了 **`catalog.ts`** → 命令不存在或 help 里没有 +- ✗ 手改 **`tools/generated/reference/*.md`** → 下次 build 被覆盖;应改 `defineCommand` 后重新 generate +- ✗ 在 `export-schema.ts` 顶层 `import catalog` → 可能与 registry 循环依赖 +- ✗ 单 action 的子组是反模式,新增时优先拍平为两级 diff --git a/docs/agents/command-flag-change.md b/docs/agents/command-flag-change.md new file mode 100644 index 0000000..58fd77c --- /dev/null +++ b/docs/agents/command-flag-change.md @@ -0,0 +1,56 @@ +# 命令选项变更 + +## 触发条件 + +- 给已有命令新增 `--flag ` +- 改 flag 默认值 +- 删除 / 重命名已有 flag +- 把 flag 从可选变成必填(或反向) + +## 必查清单 + +### A. 命令文件本身 + +- [ ] `packages/cli/src/commands//.ts`: + - `defineCommand({ options: [...] })` 数组里增删/改 `{ flag, description, type, required }` + - `usage` 字段(如 `"bl text chat --message [flags]"`)反映新签名 + - `examples` 数组覆盖新 flag 至少一个示例 + - `run()` 里读取 flag 的代码: + - 类型转换正确(`type: "number"` 时 `flags.x as number`,`"array"` 时 `as string[]`) + - 必填校验:`if (!flags.x) failIfMissing("x", ...)` 或交互式 prompt + - 默认值 fallback + +### B. 鉴权 / 全局选项 + +- [ ] 如果是**全局 flag**(所有命令通用),改 `packages/core/src/types/command.ts` 的 `GLOBAL_OPTIONS` +- [ ] 如果新 flag 影响 `Config`,改 `packages/core/src/config/schema.ts` 的 `Config` 接口 +- [ ] 如果对应 env var,改 `packages/core/src/config/loader.ts` 的 `loadConfig` + +### C. 文档层 + +- [ ] `README.md` / `README_CN.md` 如果在示例里展示了相关命令,补充新 flag +- [ ] 跑 `pnpm --filter bailian-cli run generate:reference`,让 `tools/generated/reference/` 与命令一致(本仓库 gitignore,勿手改;SKILL.md 已迁出本仓库) + +### D. 测试层 + +- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 在 `packages/cli/tests/e2e/.e2e.test.ts` 增加新 flag 的断言(含缺参、`--help`、dry-run 若适用) +- [ ] 删除 flag 时,清掉相关测试用例 + +### E. 重命名特殊处理 + +- [ ] 全仓 grep 旧 flag 名(包括 `--old-name`、`oldName`、`old_name` 三种形态,因为 args.ts 会做 kebab→camel 转换) +- [ ] 必要时保留**deprecated alias**(老 flag 仍可用,但 stderr 警告 → 下版本删) + +## 完成后自查 + +```sh +node packages/cli/src/main.ts --help # 看新 flag 出现在 Options +node packages/cli/src/main.ts --new-flag x # 实测一遍 +``` + +## 常见漏点 + +- ✗ 加 `type: "number"` 但 `String(flags.x)` 触发 lint 警告(参考已修过的 memory/list.ts) +- ✗ 加了 array 型 flag 但没考虑用户可能传多次 +- ✗ 改默认值忘记更新 description 里的 "(default: xxx)" 文案 +- ✗ Required flag 缺失时直接抛硬错而不是 prompt(交互友好性问题,参考已实现 prompt 的命令文件作为示例) diff --git a/docs/agents/config-add.md b/docs/agents/config-add.md new file mode 100644 index 0000000..207d6ce --- /dev/null +++ b/docs/agents/config-add.md @@ -0,0 +1,84 @@ +# 配置项扩展 + +## 触发条件 + +- 新增 env var(如 `DASHSCOPE_*` / `BAILIAN_*` / `NO_COLOR`) +- 给 `~/.bailian/config.json` 加字段 +- 给全局 flag 加新选项(`--xxx`) +- 改 config 字段优先级 + +## 配置三层来源 + +``` +flag (--xxx) ─┐ + ├─ loadConfig() 合并 ─→ Config(运行时单一对象) +env (XXX=yyy) ─┤ + │ +config 文件 ─┘ +~/.bailian/config.json +``` + +优先级一般是 **flag > env > config 文件 > 默认值**,具体见 `core/config/loader.ts`。 + +## 必查清单 + +### A. 类型定义 + +- [ ] `packages/core/src/config/schema.ts`: + - `Config`(运行时形状)加新字段 + - `ConfigFile`(disk 形状,snake_case)加新字段(如果允许写文件) + - `parseConfigFile()` 解析新字段 + - 如果是 enum 字段,加校验 + +### B. 加载逻辑 + +- [ ] `packages/core/src/config/loader.ts:loadConfig()`: + - 加新字段的合并逻辑(`flags.x ?? process.env.XXX ?? file.x ?? default`) + - 校验(数值范围、枚举合法性等) + - 校验失败抛 `BailianError(USAGE)` + +### C. 全局 flag(如果加的是 flag) + +- [ ] `packages/core/src/types/command.ts:GLOBAL_OPTIONS` 数组 +- [ ] `registry.ts` 的 `buildGlobalFlagLines` 会**自动**从 `GLOBAL_OPTIONS` 生成 `bl --help` 与 `reference/index.md` 的全局 flag 段,无需手写 +- [ ] flag 的 type 标注(`boolean` / `number` / `array`),让 args.ts 正确解析 +- [ ] 改完全局 flag 后跑 `pnpm --filter bailian-cli run generate:reference` + +### D. 命令使用方 + +- [ ] 用到新字段的命令文件直接读 `config.xxx`,不要重复解析 +- [ ] 配置展示 / 修改命令同步: + - `packages/cli/src/commands/config/show.ts` 显示新字段 + - `packages/cli/src/commands/config/set.ts` 允许 set + - `packages/cli/src/commands/config/export-schema.ts` 在 schema 输出里 + +### E. 文档 + +- [ ] `README.md` / `README_CN.md` 的 env var 表格 + +### F. 测试 + +- [ ] 单测覆盖优先级:flag > env > file +- [ ] 校验失败抛错(非法值) +- [ ] 默认值正确 + +## 完成后自查 + +```sh +# 三个来源都试一遍 +node packages/cli/src/main.ts config show --output json | grep +XXX=value node packages/cli/src/main.ts config show --output json | grep +node packages/cli/src/main.ts config show --xxx value --output json | grep + +# 写到文件 +node packages/cli/src/main.ts config set --key --value +cat ~/.bailian/config.json +``` + +## 常见漏点 + +- ✗ `Config` 接口加字段但 `loadConfig` 没填,运行时永远 undefined +- ✗ `ConfigFile` 用 camelCase 字段名(disk schema 应该是 snake_case) +- ✗ 全局 flag 没标 `type: "boolean"`,被当成需要值的 `--xxx ` +- ✗ 加了 env var 但 README 表格没更新,用户不知道有这条 +- ✗ `config show` 不显示新字段,用户改了无法回查 diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md new file mode 100644 index 0000000..86153a7 --- /dev/null +++ b/docs/agents/error-hint-change.md @@ -0,0 +1,125 @@ +# 错误文案变更 + +## 触发条件 + +- 修改 `BailianError` 的 message 或 hint +- 调整 cli 的 hint 增强逻辑(`enhanceHint`) +- 改 ensure-key 的 setup 流程文案 +- 改任何抛错位置的分类(exitCode) + +> 注意:`mapApiError` **不再做错误分类**(参见下方"边界原则")。如果你想给某种 HTTP 错误码加白名单分类,请先回到本文档读完"边界原则"再说。 + +## 边界原则:CLI 不翻译服务端错误 + +**CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的错误原样透传。** + +| 错误来源 | 归类 | 处理方式 | +| ---------------------------------------------------- | -------- | ----------------------------------------------------------- | +| 命令解析、缺 flag、参数校验 | **内部** | `BailianError(USAGE)` | +| 文件 I/O(ENOENT/EACCES/...) | **内部** | `BailianError(GENERAL)` + errno-specific hint | +| 本地 credentials 缺失(resolver/ensure-key/AK-SK 等) | **内部** | `BailianError(AUTH)` | +| `fetch` 自身失败(DNS/TCP/TLS/proxy) | **内部** | `BailianError(NETWORK)` + 读 `err.cause.code` 给 errno-hint | +| polling 客户端超时 | **内部** | `BailianError(TIMEOUT)` | +| HTTP 4xx/5xx、HTTP 200 + 业务错码、async task FAILED | **服务** | `BailianError(GENERAL)`,**message 原样透传**,不分类、不替换 | + +**判断标准**:错误信息的"权威来源"在哪一侧 —— 来自服务端响应 → 服务错误,透传;来自本地(OS / Node / CLI 自己)→ 内部错误,包装。 + +### 反面 case(为什么这条原则是必要的) + +历史上的几个错误处理 bug 都是因为越过了这条边界: + +1. **`mapApiError` 白名单未覆盖 OpenAI 兼容错误码** —— `qwen3.7` 不存在时服务端返回 `invalid_request_error`,白名单只认 `ModelNotFound`,fall through 到 GENERAL,该是 USAGE 信号被丢 +2. **`bl auth login` 用 try/catch 替换错误** —— key 有效但缺模型权限时,显示"API key validation failed"撒谎,误导用户去换 key +3. **`error-handler.ts` 用 message 关键词归并网络错误** —— ENOTFOUND / ECONNREFUSED / TLS 错误全归并成"Network request failed.",错误根源完全丢失 + +共同根因:**我们试图在没有"权威信息"的位置代理服务端做分类**。修复方案是统一退回到透传 + 让本地错误的真实诊断浮上来。 + +## 错误流的分层架构 + +``` +core 抛出 BailianError(message, exitCode, hint, cause?) + ↓ 沿调用栈冒泡 +cli/main.ts: main().catch(handleError) + ↓ +cli/error-handler.ts: + - 服务端错误(BailianError(GENERAL)) → text 直接打 message + - 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强) + - TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK + - Node fs errno → 翻成 GENERAL + errno hint + - 其它 Error → 默认走 cause 链 + ↓ +process.exit(err.exitCode) +``` + +## 不变量(必须遵守) + +### 1. 不替换服务端错误的 message + +- ❌ `try { call() } catch { throw new BailianError("xxx failed", FIXED_CODE) }` +- ✅ `try { call() } catch (err) { /* 加上下文不替换 */ throw err; }` 或直接不 catch + +### 2. 不在 `mapApiError` 里加 status / apiCode 白名单分支 + +- ❌ 不要回退到"401 → AUTH、429 → QUOTA"那套白名单 +- ✅ message 把 status / apiCode / request_id 拼进去就够,exit 统一 GENERAL +- 例外:CLI **自己**因为本地状态产生的 BailianError(resolver、ensure-key 等)可以用语义化 exitCode + +### 3. core 的 hint 必须不含 cli 关切 + +- ❌ 不写 `bl xxx` 命令名 +- ❌ 不写控制台 URL 或 region +- ❌ 不写渠道追踪参数(`source_channel=xxx`) +- ✅ 只描述抽象做法(如 `"Set DASHSCOPE_API_KEY environment variable, or pass --api-key."`) + +### 4. cli 端可以自由使用 cli 命令名 + URL + +- 命令文件、`error-handler.ts`、`utils/ensure-key.ts` 是 cli 层,内部可以写 `bl xxx` +- URL 必须从 `packages/cli/src/urls.ts` import,不能硬编码 + +## 必查清单 + +### A. core 改动(message / hint) + +- [ ] `packages/core/src/errors/api.ts` 的 `mapApiError`:**保持透传形态**,不要加白名单分支 +- [ ] `packages/core/src/auth/resolver.ts` 改 throw 语句:hint 不含 cli 关切 +- [ ] 任何 core 文件 throw 的 BailianError:同上 + +### B. cli 增强(`enhanceHint`) + +- [ ] `packages/cli/src/error-handler.ts:enhanceHint`:**当前只为 internal AUTH 增强**(因为只有 resolver/ensure-key 等内部位置会发 AUTH) +- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` + +### C. cli 直接抛错(`ensure-key`、命令文件) + +- [ ] cli 层抛 BailianError 时,hint 里可以放 cli 命令名,但 **URL 一律走 `urls.ts` import** +- [ ] 抛错位置如果**已经在调用服务端**,catch 时不要替换 message——重新评估是否需要 catch + +### D. 文案一致性 + +- [ ] 服务端错误的 message:必含 `HTTP ` 字段;有 apiCode/request_id 也拼上 +- [ ] 网络层错误的 message:必含 `err.cause.code`(如 ENOTFOUND) +- [ ] 中英文混用慎重 —— 当前主要是英文文案 + +## 完成后自查 + +```sh +# 触发对应错误,看 text 输出 +HOME=/tmp/empty node packages/cli/src/main.ts text chat --message "x" --non-interactive + +# 看 JSON 输出(应包含 cause 字段当 cause 存在时) +HOME=/tmp/empty node packages/cli/src/main.ts text chat --message "x" --non-interactive --output json + +# 模拟网络层错误,验证 errno 透传 +DASHSCOPE_BASE_URL=https://nonexistent-host.invalid \ + node packages/cli/src/main.ts text chat --message hi +# 预期:"Network request failed: ENOTFOUND ..." + Caused by 链 +``` + +text 模式应看到准确诊断,JSON 模式应能解析出 cause.code(用于 agent 决策)。 + +## 常见漏点 + +- ✗ 在 core 的 hint 里"顺便"写了 `bl auth login` —— 违反第 3 条不变量 +- ✗ 给 `mapApiError` 加了"401 → AUTH"等白名单分支 —— 违反边界原则 +- ✗ 用 `try/catch` 包住服务调用,catch 里 throw 了一个新的 BailianError 替换原错误 —— 违反第 1 条不变量 +- ✗ 加新 ExitCode 但没想清楚谁产生它 —— 服务端永远不应产生新 ExitCode,内部错误才需要新分类 diff --git a/docs/agents/lint-toolchain.md b/docs/agents/lint-toolchain.md new file mode 100644 index 0000000..982f737 --- /dev/null +++ b/docs/agents/lint-toolchain.md @@ -0,0 +1,65 @@ +# 工具链调整 + +## 触发条件 + +- 升级 Vite+ / TypeScript / Node 版本 +- 调整 `vite.config.ts`(根 / 各包) +- 改 lint 规则(Oxlint / Oxfmt / typescript-eslint) +- 升级或替换依赖 +- 修改 `.vite-hooks/` 或 git hooks + +## 必查清单 + +### A. 版本一致性 + +- [ ] `package.json` 的 `engines.node` 与 README 的 Node.js 徽章一致 +- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`) +- [ ] 三处 `tsconfig.json`(根 + cli + core)的 target / module 设置一致 + +### B. lint / format 规则改动 + +- [ ] 全仓跑 `vp check --fix`,看是否产生大量自动 reformat +- [ ] 如果产生 mass diff,**单独提一个 commit**(代码语义改动和 lint reformat 不要混) +- [ ] 已有 warning 的处理: + - 如果新规则消除了某些旧 warning,确认是否合理 + - 如果新规则产生了新 warning,评估是否要修 + +### C. 构建配置 + +- [ ] `packages/cli/vite.config.ts` 和 `packages/core/vite.config.ts` 的 entry / external / dts 设置 +- [ ] cli 的 bundle 必须把 `bailian-cli-core` 当 **external**(不内联),确认 `dist/bailian.mjs` 第一行有 `from "bailian-cli-core"` +- [ ] cli 的 bundle 第一行必须有 `#!/usr/bin/env node` shebang(`tools/release.mjs check` 会断言) + +### D. 依赖升级 + +- [ ] 检查 `bailian-cli-core` 在 cli 的 `dependencies` 里仍是 `"workspace:*"`(不要变成实际版本号 — `tools/release.mjs` 会拦) +- [ ] 升级后跑 `vp check && vp test` +- [ ] 升级 `@types/node` 时注意 Node API 变化(如 fs.existsSync 行为) + +### E. git hooks / pre-commit + +- [ ] `.vite-hooks/pre-commit` 改动后,`pnpm install` 重新软链(走 `prepare: vp config`) +- [ ] 增加 hook 时,确认在干净 clone 后能自动激活 + +### F. CI / 发版工具 + +- [ ] `tools/release.mjs` 中如有版本/规则相关的硬编码,同步更新 +- [ ] 比如 `secretPatterns` 添加新的敏感值识别 + +## 完成后自查 + +```sh +# 完整冒烟 +pnpm install --frozen-lockfile +vp check +vp test +node tools/release.mjs check +``` + +## 常见漏点 + +- ✗ 升级 Node engines 但忘了 README 徽章 +- ✗ 改 lint 规则后没全仓 `--fix`,新人 PR 报红一片 +- ✗ 改 cli 的 vite config 把 core 不小心打成 inline,bundle 体积暴涨 +- ✗ Oxlint 配置改了但 IDE 缓存还是旧的(IDE 可能要重启 ts server) +- ✗ 升级依赖一并升 lockfile,改动量大但没拆 commit diff --git a/docs/agents/maintaining-agent-docs.md b/docs/agents/maintaining-agent-docs.md new file mode 100644 index 0000000..e55bf29 --- /dev/null +++ b/docs/agents/maintaining-agent-docs.md @@ -0,0 +1,130 @@ +# 维护本指南 + +这份"AI 维护指南"(`AGENTS.md` + `docs/agents/*`)本身也是项目资产,需要随项目演化。 + +## 谁来维护 + +两类人/agent 会触发更新: + +1. **AI agent 完成改动后**:回顾"这次改动是不是某个场景的常见漏点?清单是不是漏了什么?" +2. **人(开发者)发现新场景**:某次维护用不上 `docs/agents/` 现有任何场景文档,新增一份 + +## 何时更新 + +任何一种情况发生,这份指南就该改: + +- ✅ 完成改动后发现"清单里没说,但实际要做"的步骤 → 补到对应场景的**必查清单** +- ✅ AI 漏了某个文件,review 时被指出 → 补到对应场景的**常见漏点** +- ✅ 一类改动反复出现且不属于任何已有场景 → 新建场景 +- ✅ 项目结构调整(新增 `packages/xxx`、移动主要目录)→ 改 `AGENTS.md` 的**项目地图** +- ✅ 全局原则调整(如开始支持海外 region、加新的 core/cli 边界规则)→ 改 `AGENTS.md` 的**通用约定** + +## 新增一份场景文档 + +### 何时新增 vs 扩展现有 + +- 已有场景 + 新清单项 → 直接加到对应 `.md`,**不要新建** +- 一类改动有自己**独立的触发条件 + 不重叠的清单** → 新建 + +判断:如果两个场景的清单重叠 ≥ 80%,说明是同一场景;新增清单项即可。 + +### 步骤 + +1. 在 `docs/agents/` 下新建 `.md`,文件名 kebab-case 描述场景 +2. 用下方模板填充 +3. 在 `AGENTS.md` 的"业务场景索引"表格里加一行(按场景频率从高到低排序) +4. 提 PR 时附 1-2 个真实改动 commit 链接,说明这个场景已经发生过 + +### 文件模板 + +```markdown +# <场景中文标题> + +## 触发条件 + +- 何时进入这份文档(2-4 条具体情况) + +## 概念图(可选) + +若场景涉及多文件协作,画一张简单的层次/数据流图 + +## 必查清单 + +### A. <分组名> + +- [ ] 具体到文件路径的 action +- [ ] ... + +### B. <分组名> + +- [ ] ... + +## 完成后自查 + +1-3 条可执行的验证命令 + +## 常见漏点 + +基于真实踩坑(初版可空,随实际场景生长) +``` + +### 命名约定 + +- **文件名**:`-.md`(`command-add-remove.md`、`url-change.md`、`config-add.md`) +- **场景标题**:3-6 字中文短语(命令增删改、URL / 渠道变更) +- **必查清单分组**:用 `### A. xxx` `### B. xxx` 字母编号,方便引用 + +### 跨场景引用 + +两份文档有共同规则时,**一处定义、其他引用**: + +```markdown + + +## 不变量 + +### 1. core 的 hint 必须不含 cli 关切 + + + +- ✗ 在 core 的 hint 里写 URL(违反 [error-hint-change.md](error-hint-change.md) 不变量 1) +``` + +避免同一规则在 N 份文档里复制粘贴,改一处全跟。 + +## 修改 AGENTS.md(主入口) + +`AGENTS.md` 修改频率应该**远低于**场景文件,只在三种情况变动: + +| 触发 | 改的段落 | +| ------------- | -------------- | +| 加/删一个场景 | 业务场景索引表 | +| 项目结构变化 | 项目地图 | +| 全局原则变化 | 通用约定 | + +**不要把场景特定的清单往 AGENTS.md 塞** —— 它的设计目标是 AI 加载到上下文里 ~60 行就够,详细内容按需读 `docs/agents/`。 + +## 文档应该是什么样 + +### Do + +- 写清晰的 **must / must-not / 必查**,不写"建议"性语气 +- 用 file path + 具体 action 的句式(`packages/cli/src/commands/catalog.ts:增加 import 与 commands 条目`) +- 在每份场景末尾留**常见漏点**段,持续累积真实经验 +- 在跨场景的不变量上互相引用,不复制 + +### Don't + +- ❌ 不写前置知识(假设读者会 TS / 会读代码) +- ❌ 不堆砌实现细节(代码改了文档也要改 — 不可持续;清单是"做什么",代码是"怎么做",后者交给代码) +- ❌ 不重复 `README.md` 的内容(README 面向用户,本指南面向维护者) +- ❌ 不写反向索引(文件 → 场景)— 双向维护成本高,AI 不需要 +- ❌ 不要在场景文件里塞通用约定 — 通用的放 AGENTS.md + +## 文档生长的节奏 + +这套文档**不是一次写完**,是随真实工作沉淀的: + +- 初版只有触发条件 + 骨架清单 + 空"常见漏点" +- 每完成一次相关改动,补一两条清单或漏点 +- 长期未触发的场景文件可以归并或删除(避免文档腐化) diff --git a/docs/agents/model-add-remove.md b/docs/agents/model-add-remove.md new file mode 100644 index 0000000..b47d60e --- /dev/null +++ b/docs/agents/model-add-remove.md @@ -0,0 +1,53 @@ +# 模型上下架 + +## 触发条件 + +- 上线新的 Qwen / Wan / CosyVoice / 等模型 +- 切换某命令的默认模型(如 `bl text chat` 默认从 qwen3.7-max 切到 qwen3.7-plus) +- 废弃旧模型 + +模型本身是阿里云后端在管,本仓库要做的是**让 CLI 能正确调用 + 文档/AI 入口准确反映可用模型清单**。 + +## 必查清单 + +### A. 命令实现 + +- [ ] `packages/cli/src/commands//.ts`: + - `--model` flag 的 description 里"default:"反映新默认值 + - 命令内部 `const model = (flags.model as string) || ""` 的 fallback 字符串 + - 如果命令维护一个 supported-models 列表(如 `speech/synthesize.ts:MODEL_VOICES`),增删条目 + - 如果不同模型有不同 endpoint / 请求体形状,确保 `if (model.startsWith("xxx"))` 分支覆盖 +- [ ] 模型如有特殊 endpoint,看 `packages/core/src/client/endpoints.ts` + +### B. 类型层 + +- [ ] `packages/core/src/types/api.ts` 的 request/response 类型如果跟模型相关,同步字段 + +### C. 命令手册 + +- [ ] 若 `--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/.md`(本仓库 gitignore;SKILL.md 由独立的 `npx add skills` 仓库维护,本仓库不再含) + +### D. 用户面文档 + +- [ ] `README.md` / `README_CN.md`: + - Quick Start 示例如使用了具体型号,确认仍可用 + - 顶部 introduction 段落如提到"Qwen-Omni"等品牌名,无需变(模型代号变化不算品牌变) + +### E. 测试层 + +- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 维护 e2e:断言不硬编码废弃模型 ID;新模型至少一条 happy-path 集成 + +## 完成后自查 + +```sh +# 默认模型走通 +node packages/cli/src/main.ts --message "test" +# 显式指定新模型 +node packages/cli/src/main.ts --model --message "test" +``` + +## 常见漏点 + +- ✗ 改了命令默认模型,但 SKILL.md frontmatter 仍写老型号 → AI agent 调用时仍按老型号宣传 +- ✗ 废弃模型时只删了代码,e2e 测试还在跑,CI 红 +- ✗ 新模型 endpoint 不一致,但只改了 default,没加 endpoint 分支判断 diff --git a/docs/agents/release.md b/docs/agents/release.md new file mode 100644 index 0000000..41e340d --- /dev/null +++ b/docs/agents/release.md @@ -0,0 +1,94 @@ +# 发版前自检 + +## 触发条件 + +- 准备发布 beta / rc / 正式版到 npm +- 准备打 git tag + +## 主入口:`tools/release.mjs` + +发版流程**必须**走这两个命令,不要手动跑 `pnpm publish`: + +```sh +node tools/release.mjs check # 全套自检,不发布 +node tools/release.mjs publish # 自检 + 交互确认 + 发布 +``` + +### `check` 已自动覆盖(无需手动重复) + +| 检查项 | 实现 | +| -------------------------------------------------------------------------------------- | ------------------------------------------- | +| cli/core 版本号一致 | `validatePackages()` | +| `publishConfig.registry` 指向公共 npm | `assertPublishConfig()` | +| frozen lockfile install | `pnpm install --frozen-lockfile` | +| format + lint + type check | `pnpm run check` | +| 构建 core + cli | `pnpm --filter ... run build` | +| **黑名单文件**(`.env` / `.npmrc` / `*.pem` / `*.key` / `*.crt` / SSH keys / debug log) | `denyPathPatterns` in `scanPackageContents` | +| **敏感字符串**(DashScope `sk-xxxxx`、Alibaba `LTAI...`、access key secret) | `secretPatterns` in `scanPackageContents` | +| tarball 里 cli 依赖 `bailian-cli-core@`,无 `workspace:*` 泄漏 | `assertCliPackage()` | +| cli bin 含 `#!/usr/bin/env node` shebang | 同上 | +| cli bundle 把 core 当外部依赖 | 同上 | +| core tarball 含 `dist/index.mjs` + `dist/index.d.mts` | `assertCorePackage()` | + +如果新增了"应该自动检查"的项目级规则,优先加到 `tools/release.mjs`,不要单独靠人工或 AI 记。 + +## `release.mjs` 不覆盖的(手动确认) + +### 版本号目标 + +- [ ] `packages/cli/package.json` 和 `packages/core/package.json` 已升到目标版本 +- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`,**不要直接用 `1.0.0` 当 beta**) + +### 用户面文档 + +- [ ] `README.md` / `README_CN.md` 的 Quick Start 命令仍能跑通 +- [ ] README 的 Node.js 徽章版本与 `cli/package.json.engines.node` 一致 +- [ ] README 宣传的 bin 名称在 `cli/package.json.bin` 都真的注册(常见漏点) +- [ ] `LICENSE` 文件存在(根 + cli + core 各一份) + +### AI 入口资产 + +- [ ] `pnpm --filter bailian-cli run build` 已执行(`generate:reference` 会刷新 `tools/generated/reference/`,仅本地校验用,不随 npm 包发布) +- [ ] SKILL.md 与命令手册的分发已迁出本仓库,改由独立的 `npx add skills` 机制安装;本仓库的 `cli/package.json.files` 不再包含 `skill` 与 `scripts/postinstall.js` + +## 发布 + +### beta / rc(不进 `latest` dist-tag) + +`tools/release.mjs publish` 当前不接受 `--tag` 参数(见末尾 TODO)。pre-release 版本 npm 默认行为不会污染 `latest`,但稳妥起见,**直接用 pnpm 命令显式带 tag**: + +```sh +# 先跑一次 check 确保通过 +node tools/release.mjs check + +# 然后显式发布到 beta dist-tag +pnpm --filter bailian-cli-core publish --tag beta --no-git-checks +pnpm --filter bailian-cli publish --tag beta --no-git-checks +``` + +### 正式版(默认进 latest) + +```sh +node tools/release.mjs publish +``` + +## 完成后 + +- [ ] 推 git tag(如 `v1.0.0-beta.0`) +- [ ] 验证 npm 上能装:`npm view bailian-cli@beta version` +- [ ] 试装一次:`npm i -g bailian-cli@beta && bl --version` + +## TODO(给 release.mjs 维护者) + +- [ ] `releasePublish` 接受 `--tag ` 参数,beta/rc 不再绕开脚本 +- [ ] `check` 增加 SKILL.md 与 `catalog.ts` / `reference/index.md` 一致性断言(如命令数、关键子命令名) + +## 常见漏点(基于历史踩坑) + +| 漏点 | 后果 | +| ------------------------------------------------ | ---------------------------------------------------------------------- | +| 改了源码忘 `vp pack`,直接 publish | npm 上是旧代码 — `tools/release.mjs publish` 会自动重建,**不要绕过它** | +| cli 升版号但 core 没升 | release.mjs 会拦下 | +| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 | +| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` | +| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 | diff --git a/docs/agents/stress-batch-tests.md b/docs/agents/stress-batch-tests.md new file mode 100644 index 0000000..69b2707 --- /dev/null +++ b/docs/agents/stress-batch-tests.md @@ -0,0 +1,223 @@ +# 批量压测脚本(多能力统一入口) + +**使用者文档**(方案说明、命令示例、fixtures、默认值表):[../stress-testing.md](../stress-testing.md) + +## 触发条件 + +- 新增、修改或排查 `packages/cli/tests/stress/**/*.mjs` 批量压测代码 +- 用户要求并发压测 `bl` 各能力(文本 / 语音 / 图像 / 视频等)、生成 Markdown/HTML 报告 +- 修改对应命令的 JSON 输出字段后,需同步 `packages/cli/tests/stress/lib/parsers.mjs` 与各 `targets/*.mjs` 的成功判定 +- 在 monorepo 根 `package.json` 调整 `test:stress` 入口 + +**不要**为压测去改 CLI 产品行为(除非用户明确要求修 CLI bug);压测侧通过子进程参数与解析逻辑适配现有命令。 + +## 与 E2E / `vp test` 的关系 + +| 维度 | E2E(Vitest) | 批量压测(`.mjs`) | +| ---- | ---------------------------------------- | ------------------------------------------------------------------ | +| 路径 | `packages/cli/tests/e2e/*.e2e.test.ts` | `packages/cli/tests/stress/run.mjs` + `targets/*.mjs`、`lib/*.mjs` | +| 运行 | `vp test`(真实 API 需 `BAILIAN_E2E=1`) | **仅手动** `pnpm run test:stress -- -- ...` | +| 目的 | 回归:help、缺参、dry-run、单条集成 | 并发、限流、耗时统计、批量报告、前置资源 fixtures | +| CI | 可纳入 `vp test`(skip 块默认跳过) | **禁止**默认 CI / `vp test` 自动执行 | + +`vp test` 只收集 `*.test.ts` / `*.spec.ts` 等,**不会**执行 `tests/stress/*.mjs`。勿将压测逻辑写成 Vitest 用例并放进默认测试流。 + +E2E 规范见 [cli-e2e-tests.md](cli-e2e-tests.md)。 + +## 文件与入口 + +``` +packages/cli/tests/stress/ +├── run.mjs # 路由入口(解析 target、全局 flags、`--`) +├── lib/ +│ ├── argv-parse.mjs # 共用 --count/-n、-c、-m、--voice、--report-dir 等 +│ ├── stress-config.mjs # count/concurrency 配置加载与解析 +│ ├── cli-runner.mjs # spawn main.ts、限流重试、线程池 +│ ├── fixtures.mjs # prerequisites.json + 前置音频/图/视频生成 +│ ├── parsers.mjs # 各命令 stdout / 文件解析 +│ ├── paths.mjs +│ ├── rate-limit.mjs +│ ├── finish-run.mjs # 写报告;套件模式返回摘要而不 exit +│ ├── run-suite.mjs # 全量套件编排 +│ ├── suite-catalog.mjs # 用例顺序与中文名 +│ ├── suite-report.mjs # SUITE_REPORT.md / .html +│ ├── trace-ids.mjs # requestId / taskId 提取 +│ └── report.mjs # REPORT.md / REPORT.html / results.json +└── targets/ + ├── text-chat.mjs + ├── speech-synthesize.mjs + ├── speech-recognize.mjs + ├── image-generate.mjs + ├── image-edit.mjs + ├── video-t2v.mjs + ├── video-i2v.mjs + ├── video-ref.mjs + └── video-edit.mjs +``` + +monorepo 根 `package.json`: + +```json +"test:stress": "node packages/cli/tests/stress/run.mjs" +``` + +`pnpm` 会在子进程 argv 中插入 `--`;入口会**跳过**孤立的 `--`,因此 `pnpm run test:stress -- list` 与 `pnpm run test:stress list` 均可。 + +## 如何运行 + +在 **monorepo 根目录**执行(需已配置 `DASHSCOPE_API_KEY` 或 `~/.bailian/config.json`): + +```sh +# 顺序执行全部 9 个用例,并生成套件总报告 SUITE_REPORT.md(耗时长、会打真实 API) +pnpm run test:stress +pnpm run test:stress -- all -- --count 5 -c 2 + +# 列出全部 target +pnpm run test:stress -- list + +# 文本对话 +pnpm run test:stress -- text -- --count 20 -c 5 + +# 语音合成(音色可用 --voice 或环境变量 STRESS_TTS_VOICE) +pnpm run test:stress -- speech-tts -- --count 10 + +# 语音识别(先在同批次目录下生成 fixtures/setup-audio.mp3 等) +pnpm run test:stress -- speech-asr -- --count 5 + +# 生图 / 修图 / 视频类 +pnpm run test:stress -- image-generate -- --count 50 -c 2 +pnpm run test:stress -- image-edit --reuse-fixtures -- --count 10 +pnpm run test:stress -- video-t2v -- --count 3 -c 1 +pnpm run test:stress -- video-i2v --setup-only # 仅生成前置图 + manifest +pnpm run test:stress -- video-edit --reuse-fixtures -- --count 3 +``` + +**环境变量**:可写在 `pnpm` 前,例如 `COUNT=5 pnpm run test:stress -- video-t2v`。 + +**命令行参数**:`--` 之后传给具体 target(与旧 `stress:* -- --count` 语义一致)。 + +配置优先级: + +- **任务数 / 并发**:命令行 `--count` / `-n`、`--concurrency` / `-c` > `packages/cli/tests/stress/stress.defaults.json`(或 `--stress-config` / `STRESS_CONFIG`)> `lib/stress-config.mjs` 中 `CODE_DEFAULTS` +- **其它参数**(如 `MODEL`):命令行 > 环境变量 > target 内默认值 + +### 全局选项(由 `run.mjs` 解析,可从 argv 任意位置剥离) + +| 选项 | 含义 | +| ----------------------- | ------------------------------------------------------------------------------- | +| `--reuse-fixtures` | 若当前批次的 `fixtures/prerequisites.json` 已存在且校验通过,则不再跑前置 CLI | +| `--setup-only` | 仅生成前置资源并写入 manifest,不进入压测循环(适用于带 fixtures 的 target) | +| `--fixtures-dir ` | 使用**已有**目录下的 `prerequisites.json`(不拷贝),满足本 target 所需字段即可 | + +前置 manifest 写入路径:`{REPORT_DIR}/fixtures/prerequisites.json`(默认 REPORT_DIR 含时间戳)。 + +## 脚本架构(不可随意破坏的约束) + +### 子进程调用方式 + +- **实际执行**:`node packages/cli/src/main.ts `,`cwd` 为 `packages/cli` +- **禁止**用 `pnpm run dev` 跑子任务:`pnpm` 会向 stdout 打生命周期日志,污染 JSON 解析 +- **报告中的「完整命令」**:用 `pnpm run dev ...` 展示(`buildDisplayCommand`) + +### 必须带的 CLI 参数(通用) + +- `--non-interactive` +- 除 `speech recognize` 外,压测子进程宜带 `--output json`(语音识别以 `--out` 文件为准 stdout 可能为纯文本) +- 异步类命令带 `--timeout`、对应 `--poll-interval` + +**禁止**对子进程加 `--quiet`(与 `--output json` 并存时可能丢 `urls` / `video_url`)。 + +**禁止**对视频相关子进程加 `--no-wait`;须阻塞到任务完成(及下载路径正确时落盘)。 + +### 成功 / 失败判定(概要) + +| Target / 能力 | 成功条件 | +| ------------- | ------------------------------------------------------------------------------------------ | +| text | JSON 含有效 `choices[0].message.content` 或流式汇总后的 `content` | +| speech-tts | JSON 含 `audio_url` / `audio_urls` 与 `saved` | +| speech-asr | 进程 exit 0,且 `--out` JSON 可被解析出 `transcripts` 文本(或 stdout 有非空正文作为兜底) | +| image | JSON 含 `urls` 和/或 `saved`(不可仅凭 `task_id` 判失败) | +| video | JSON 含 `video_url` 和/或 `saved`(同上) | + +详见 `lib/parsers.mjs`。 + +### 墙钟耗时 + +报告中的 **墙钟总耗时** = `finishedAt - startedAt`(整批真实经过时间),不是各任务 `durationMs` 之和。 + +## 默认参数(摘要) + +各 target 默认值见对应 `targets/*.mjs` 文件头与常量;常见约定: + +- **count / concurrency**:见仓库内 `packages/cli/tests/stress/stress.defaults.json`,可按 target 修改 +- **video-t2v / i2v / ref / edit**:默认视频类较低 `COUNT`、并发多为 1,`TIMEOUT_MS` 可达 1 小时;前置 `video` fixtures 使用文生视频生成约 5s 样片 +- **speech-asr**:默认 `POLL_INTERVAL=2`;输入音频由 `speech synthesize` 写入 fixtures + +## 报告产物 + +### 单用例 + +每次运行在 `REPORT_DIR`(默认 `test/output/-batch-<时间戳>/`)生成: + +| 文件 | 内容 | +| ----------------------------- | ---------------------------------------------------------------------------------------- | +| `REPORT.md` | 汇总与明细表(含 **Request ID**、**Task ID** 列,便于排查) | +| `REPORT.html` | 同上 | +| `results.json` | 精简后的原始结果(含 `requestId` / `taskId` 字段) | +| `fixtures/prerequisites.json` | 需前置资源时:记录 `audio` / `image` / `video` 的 http URL、`saved` 本地路径与可复制命令 | + +`requestId` / `taskId` 提取逻辑见 `lib/trace-ids.mjs`(**仅压测脚本**): + +- 成功时 CLI stdout 通常不含 `request_id`;压测会默认加 `--verbose`,从 stderr 的 `request_id:` / JSON 块解析 +- 有 `task_id` 仍缺 `request_id` 时,压测会额外调用 DashScope `GET /tasks/{id}` 补齐(`lib/fetch-request-id.mjs`) +- 关闭方式:`STRESS_VERBOSE_REQUEST_ID=0`(不注入 `--verbose`)、`STRESS_FETCH_REQUEST_ID=0`(不查任务 API) + +### 全量套件 + +`pnpm run test:stress`(无 target 或 `all`)在 `test/output/stress-suite-<时间戳>/` 下为每个用例建子目录,并生成: + +| 文件 | 内容 | +| ----------------------- | ------------------------------------------------------------------- | +| `SUITE_REPORT.md` | 各用例中文名、墙钟耗时、任务数、并发、成功/失败、成功率、子报告路径 | +| `SUITE_REPORT.html` | 同上 | +| `suite-results.json` | 结构化汇总 | +| `/REPORT.md` | 该用例明细报告 | + +进程退出码:存在失败任务 → `1`,全部成功 → `0`。 + +根目录 `.gitignore` 已忽略 `test/`,压测产物勿提交。 + +## Agent 修改清单 + +### 只改压测脚本时 + +- [ ] `lib/paths.mjs` 解析的 `CLI_PACKAGE` / `MONOREPO_ROOT` 仍正确 +- [ ] 子进程仍为 `node` + `src/main.ts`,未改回裸 `pnpm run dev` 执行任务 +- [ ] 未对子进程加 `--quiet`,视频未加 `--no-wait` +- [ ] `parsers.mjs` 与文档中的成功判定一致 +- [ ] 根 `package.json` 仅保留 `test:stress` 入口指向 `run.mjs` +- [ ] `node --check` 对相关 `.mjs` 通过,`pnpm run test:stress -- list` 可运行 + +### 改了某命令 JSON 输出时 + +- [ ] 同步 `lib/parsers.mjs` 及受影响 `targets/*.mjs` +- [ ] 有 API Key 时跑小规模:`pnpm run test:stress -- -- --count 2 -c 1` + +### 禁止(除非用户明确要求) + +- [ ] 为压测专门改 CLI `--quiet` + `--output json` 行为 +- [ ] 压测写进默认 `vp test` 路径且未 skip +- [ ] CI / `ready` 自动调用 `test:stress` + +## 常见漏点 + +- 环境变量写在 `pnpm run test:stress` **之后** → 未注入进程 +- **未知 target `"--"`**:已在新入口中跳过孤立 `--`;若仍报错请检查是否多空格或 shell 引用 +- 高并发无节流 → DashScope Rate limit(exit 4);应依赖脚本内限流或降低 `-c` +- `image-generate` 大批量开启 `REPORT_THUMBNAILS=1` → HTML 变慢 +- 改压测却未同步本文档与 `AGENTS.md`/`CLAUDE.md` 索引 + +## 与 E2E 的分工 + +- **改命令选项、help、缺参、单条真实调用** → `tests/e2e/*.e2e.test.ts` +- **并发、配额、fixtures、批量报告** → `tests/stress/` + **手动** `pnpm run test:stress -- ` diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md new file mode 100644 index 0000000..c477e93 --- /dev/null +++ b/docs/agents/url-change.md @@ -0,0 +1,74 @@ +# URL / 渠道变更 + +## 触发条件 + +- 控制台域名变更(如 `bailian.console.aliyun.com` → 新域名) +- API endpoint 迁移 +- 文档站迁移 +- 调整 / 删除 / 新增渠道追踪参数(`source_channel` 等) + +## URL 的分层架构 + +``` +core/config/schema.ts ← API endpoint / 文档站(region-aware) + REGIONS{cn, us, intl} dashscope.aliyuncs.com 等 + DOCS_HOSTS{cn, us, intl} help.aliyun.com/zh/model-studio + BAILIAN_HOST bailian.cn-beijing.aliyuncs.com (POP API) + +cli/src/urls.ts ← 用户面控制台 URL(cn-only) + BAILIAN_CONSOLE_ROOT bailian.console.aliyun.com + BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing + API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key + +core/files/upload.ts ← 文件上传 endpoint(cn-pinned) + UPLOAD_API ${REGIONS.cn}/api/v1/uploads +``` + +## 必查清单 + +### A. TS 源码(必须 import,不准硬编码) + +- [ ] `packages/core/src/config/schema.ts` 是所有 API/docs 基址的源头 +- [ ] `packages/cli/src/urls.ts` 是所有用户面控制台 URL 的源头 +- [ ] 改完后 grep 验证: + +```sh +# 控制台 URL — 应只在 urls.ts 出现 +grep -rnE "https://bailian\.console\.aliyun\.com" packages/ --include="*.ts" \ + | grep -v "node_modules" | grep -v "/dist/" +# 期望:只匹配 packages/cli/src/urls.ts + +# API endpoint — 应只在 schema.ts 和 upload.ts 出现 +grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \ + | grep -v "node_modules" | grep -v "/dist/" +# 期望:只匹配 schema.ts(REGIONS)、upload.ts(派生)、tests +``` + +### B. 非 TS 文件(只能人工同步,无法 import) + +- [ ] `tools/generated/reference/` 各 `.md` 中 API/控制台 URL(`generate:reference` 重建后核对;本仓库 gitignore) +- [ ] `README.md` / `README_CN.md` 中所有 URL + +### C. 渠道追踪参数 + +- [ ] **当前现状**:全仓不带 `source_channel=aliway` 等追踪参数 +- [ ] 如未来要恢复以收集分析数据,**统一评估再加回**(不要单点恢复造成不一致) +- [ ] 全仓 grep `source_channel=`,确认无残留 + +## 完成后自查 + +```sh +# 验证错误 hint 不再泄漏旧 URL +HOME=/tmp/empty node packages/cli/src/main.ts text chat --message x --non-interactive +# 看输出的 Get API Key URL 是否走新值 + +# 验证 banner / help +node packages/cli/src/main.ts # banner +node packages/cli/src/main.ts help # help 命令 +``` + +## 常见漏点 + +- ✗ 改了 `urls.ts` 但忘记同步 README(用户最先看到) +- ✗ 在 cli 命令文件里 inline `https://bailian.console.aliyun.com/...` 而不是 `${API_KEY_PAGE}` +- ✗ 在 core 的 hint 里写 URL(违反 [error-hint-change.md](error-hint-change.md) 不变量 1) diff --git a/package.json b/package.json new file mode 100644 index 0000000..934c9c3 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "bailian-cli-monorepo", + "version": "0.0.0", + "private": true, + "homepage": "https://github.com/modelstudioai/cli", + "bugs": { + "url": "https://github.com/modelstudioai/cli/issues" + }, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/modelstudioai/cli.git" + }, + "type": "module", + "scripts": { + "ready": "vp check && vp run -r test && vp run -r build", + "prepare": "vp config", + "check": "vp check", + "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", + "wiki:crawl": "node tools/wiki-crawler/index.mjs", + "test:stress": "node packages/cli/tests/stress/run.mjs" + }, + "devDependencies": { + "vite-plus": "catalog:" + }, + "engines": { + "node": ">=22.12.0" + }, + "packageManager": "pnpm@10.33.2" +} diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 0000000..3e9713e --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.DS_Store +outputs/ \ No newline at end of file diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..9eb125c --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Aliyun Model Studio (DashScope) AI Platform + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..d6f045f --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,145 @@ +
+ +Aliyun Model Studio CLI + +# >\_ Aliyun Model Studio CLI + +**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform** + +[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli) +[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org) +[![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) + +[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli) · [中文文档](https://unpkg.com/bailian-cli/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) + +--- + +_Chat with Qwen, generate images & videos, understand images, call agents,_ +_manage memory, search the web — all from your terminal._ + +_Built for AI Agents. Every command works as a structured tool call._ + +
+ +## Features + +Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks: + +- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding +- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video +- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition +- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference) +- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents +- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR +- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue +- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio +- **Web search** — Real-time internet retrieval for up-to-date, accurate answers +- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`) +- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity + +

+ bl --help +

+ +## Installation + +```bash +npm install -g bailian-cli +npx skills add modelstudioai/skills --all -g +``` + +> Requires Node.js >= 22.12. + +## Quick Start + +```bash +# Authenticate +bl auth login --api-key sk-xxxxx + +# Chat with Qwen +bl text chat --message "What is DashScope?" + +# Multimodal chat (text + image + audio + video) +bl omni --message "Describe this image" --image ./photo.jpg + +# Generate an image +bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/ + +# Generate a video from local image +bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4 + +# Browser login (required for console capability commands) +bl auth login --console + +# Browse apps / free-tier quota +bl app list +bl usage free --model qwen3-max +``` + +> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli) + +## Authentication + +### DashScope API Key + +Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key). + +```bash +# Option 1: Environment variable +export DASHSCOPE_API_KEY=sk-xxxxx + +# Option 2: Login command (persisted to ~/.bailian/config.json) +bl auth login --api-key sk-xxxxx + +# Option 3: Per-command flag +bl text chat --api-key sk-xxxxx --message "Hello" +``` + +### Console Login (OAuth) + +Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in. + +```bash +bl auth login --console +``` + +### Alibaba Cloud AK/SK (Knowledge Base only) + +Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak). + +> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK. + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t... +export ALIBABA_CLOUD_ACCESS_KEY_SECRET=... +export BAILIAN_WORKSPACE_ID=ws-... +``` + +## Configuration + +```bash +# View current config +bl config show + +# Set defaults +bl config set --key region --value us +bl config set --key default_text_model --value qwen-turbo +bl config set --key timeout --value 600 + +# Self-update to latest version +bl update +``` + +Config file location: `~/.bailian/config.json` + +## Links + +| Resource | URL | +| :--------------------------- | :-------------------------------------------------------------- | +| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli | +| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ | +| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models | +| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ | +| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key | +| Get AccessKey | https://ram.console.aliyun.com/manage/ak | diff --git a/packages/cli/README_CN.md b/packages/cli/README_CN.md new file mode 100644 index 0000000..c4e71ff --- /dev/null +++ b/packages/cli/README_CN.md @@ -0,0 +1,145 @@ +
+ +Aliyun Model Studio CLI + +# >\_ Aliyun Model Studio CLI + +**阿里云百炼 (DashScope) AI 平台命令行工具** + +[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli) +[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org) +[![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://unpkg.com/bailian-cli/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) + +--- + +_千问对话、图像生成与编辑、视频生成与编辑、图像理解、语音合成与识别、_ +_应用调用、记忆管理、知识检索、联网搜索 — 一行命令,触达所有 AI 能力。_ + +_专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ + +
+ +## 功能特性 + +让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用: + +- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强 +- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持 +- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成 +- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑 +- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话 +- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR +- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验 +- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用 +- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性 +- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`) +- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时 + +

+ bl --help +

+ +## 安装 + +```bash +npm install -g bailian-cli +npx skills add modelstudioai/skills --all -g +``` + +> 需要预先安装 Node.js >= 22.12。 + +## 快速开始 + +```bash +# 认证 +bl auth login --api-key sk-xxxxx + +# 和通义千问对话 +bl text chat --message "你好,介绍一下阿里云百炼平台" + +# 多模态对话(文本 + 图片 + 音频 + 视频) +bl omni --message "描述这张图片" --image ./photo.jpg + +# 生成图片 +bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/ + +# 图生视频(本地文件自动上传) +bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4 + +# 浏览器登录(控制台能力相关命令需要) +bl auth login --console + +# 浏览应用 / 免费额度 +bl app list +bl usage free --model qwen3-max +``` + +> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) + +## 认证方式 + +### DashScope API Key + +大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。 + +```bash +# 方式一:环境变量 +export DASHSCOPE_API_KEY=sk-xxxxx + +# 方式二:登录命令(持久化到 ~/.bailian/config.json) +bl auth login --api-key sk-xxxxx + +# 方式三:命令行参数 +bl text chat --api-key sk-xxxxx --message "你好" +``` + +### 控制台登录(OAuth) + +控制台能力命令(`app list`、`usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。 + +```bash +bl auth login --console +``` + +### 阿里云 AK/SK(仅知识库检索) + +`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 + +> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。 + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t... +export ALIBABA_CLOUD_ACCESS_KEY_SECRET=... +export BAILIAN_WORKSPACE_ID=ws-... +``` + +## 配置 + +```bash +# 查看当前配置 +bl config show + +# 设置默认值 +bl config set --key region --value us +bl config set --key default_text_model --value qwen-turbo +bl config set --key timeout --value 600 + +# 自更新到最新版本 +bl update +``` + +配置文件位置:`~/.bailian/config.json` + +## 相关链接 + +| 资源 | 地址 | +| :---------------------- | :-------------------------------------------------------------- | +| 阿里云百炼 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 | diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..3cef27e --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,71 @@ +{ + "name": "bailian-cli", + "version": "1.1.0", + "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", + "keywords": [ + "agent", + "ai", + "cli", + "model-studio" + ], + "homepage": "https://bailian.console.aliyun.com/cli", + "bugs": { + "url": "https://github.com/modelstudioai/cli/issues" + }, + "license": "Apache-2.0", + "author": "Aliyun Model Studio", + "repository": { + "type": "git", + "url": "git+https://github.com/modelstudioai/cli.git", + "directory": "packages/cli" + }, + "bin": { + "bailian": "dist/bailian.mjs", + "bl": "dist/bailian.mjs" + }, + "files": [ + "dist", + "README_CN.md" + ], + "type": "module", + "exports": { + ".": "./dist/bailian.mjs", + "./package.json": "./package.json" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts", + "build": "pnpm run generate:reference && vp pack", + "dev": "node src/main.ts", + "test": "vp test", + "check": "vp check" + }, + "dependencies": { + "bailian-cli-core": "workspace:*" + }, + "devDependencies": { + "@clack/prompts": "^0.7.0", + "@types/node": "catalog:", + "@typescript/native-preview": "7.0.0-dev.20260328.1", + "ajv": "catalog:", + "typescript": "^6.0.2", + "vite-plus": "catalog:", + "yaml": "catalog:" + }, + "engines": { + "node": ">=22.12.0" + }, + "inlinedDependencies": { + "@clack/core": "0.3.5", + "@clack/prompts": "0.7.0", + "ajv": "8.20.0", + "fast-deep-equal": "3.1.3", + "fast-uri": "3.1.2", + "json-schema-traverse": "1.0.0", + "picocolors": "1.1.1", + "sisteransi": "1.0.5", + "yaml": "2.8.3" + } +} diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000..5f8dc35 --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,166 @@ +import type { GlobalFlags } from "bailian-cli-core"; +import type { OptionDef } from "bailian-cli-core"; +import { BailianError, ExitCode } from "bailian-cli-core"; + +function kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +/** Extract camelCase flag name from an OptionDef.flag string, e.g. '--max-tokens ' → 'maxTokens' */ +function flagKey(def: OptionDef): string | null { + const m = def.flag.match(/^--([a-z][a-z0-9-]*)/i); + return m ? kebabToCamel(m[1]!) : null; +} + +/** Boolean when no value placeholder and type is not string/number/array */ +function isBooleanDef(def: OptionDef): boolean { + if (def.type === "boolean") return true; + if (def.type === "string" || def.type === "number" || def.type === "array") return false; + return !def.flag.includes("<") && !def.flag.includes("["); +} + +interface FlagSchema { + booleans: Set; + numbers: Set; + arrays: Set; +} + +function buildSchema(options: OptionDef[]): FlagSchema { + const booleans = new Set(); + const numbers = new Set(); + const arrays = new Set(); + for (const opt of options) { + const key = flagKey(opt); + if (!key) continue; + if (isBooleanDef(opt)) booleans.add(key); + else if (opt.type === "number") numbers.add(key); + else if (opt.type === "array") arrays.add(key); + } + return { booleans, numbers, arrays }; +} + +/** + * Quick scan: collect positional (non-dash) args to determine the command path. + * Skips global flags and their values so that e.g. `--output json text chat` + * correctly produces ['text', 'chat'] instead of ['json', 'text', 'chat']. + */ +export function scanCommandPath(argv: string[], globalOptions: OptionDef[] = []): string[] { + const globalSchema = buildSchema(globalOptions); + const path: string[] = []; + let i = 0; + while (i < argv.length) { + const arg = argv[i]!; + if (arg === "--") break; + + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + const key = eqIdx !== -1 ? arg.slice(2, eqIdx) : arg.slice(2); + const camelKey = kebabToCamel(key); + + if (!globalSchema.booleans.has(camelKey) && eqIdx === -1) { + const next = argv[i + 1]; + // Command-local booleans (e.g. `--console`) are not in GLOBAL_OPTIONS; if the next + // token is another flag, do not consume it as this flag's value. + if (next === undefined || next.startsWith("-")) { + i += 1; + } else { + i += 2; + } + } else { + i += 1; + } + continue; + } + + if (arg.startsWith("-")) { + i++; + continue; + } + + path.push(arg); + i++; + } + return path; +} + +/** + * Full flag parse. Types are derived entirely from the provided OptionDef schema: + * - boolean: no placeholder in flag string (or type: 'boolean') + * - number: type: 'number' + * - array: type: 'array' (repeatable via multiple --flag occurrences) + * - default: string + */ +export function parseFlags(argv: string[], options: OptionDef[]): GlobalFlags { + const schema = buildSchema(options); + const flags: GlobalFlags = { + quiet: false, + verbose: false, + noColor: false, + yes: false, + dryRun: false, + help: false, + nonInteractive: false, + async: false, + }; + + let i = 0; + while (i < argv.length) { + const arg = argv[i]!; + + if (arg === "--help" || arg === "-h") { + flags.help = true; + i++; + continue; + } + if (arg === "--") { + break; + } + + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + let key: string; + let value: string | undefined; + + if (eqIdx !== -1) { + key = arg.slice(2, eqIdx); + value = arg.slice(eqIdx + 1); + } else { + key = arg.slice(2); + } + + const camelKey = kebabToCamel(key); + + if (schema.booleans.has(camelKey)) { + (flags as Record)[camelKey] = true; + i++; + continue; + } + + if (value === undefined) { + i++; + value = argv[i]; + } + + if (value === undefined) + throw new BailianError(`Flag --${key} requires a value.`, ExitCode.USAGE); + + if (schema.arrays.has(camelKey)) { + const arr = (flags as Record)[camelKey] as string[] | undefined; + if (arr) arr.push(value); + else (flags as Record)[camelKey] = [value]; + } else if (schema.numbers.has(camelKey)) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + throw new BailianError(`Flag --${key} requires a finite number.`, ExitCode.USAGE); + } + (flags as Record)[camelKey] = numericValue; + } else { + (flags as Record)[camelKey] = value; + } + } + + i++; + } + + return flags; +} diff --git a/packages/cli/src/commands/app/call.ts b/packages/cli/src/commands/app/call.ts new file mode 100644 index 0000000..365071c --- /dev/null +++ b/packages/cli/src/commands/app/call.ts @@ -0,0 +1,187 @@ +import { + defineCommand, + request, + requestJson, + appCompletionEndpoint, + parseSSE, + detectOutputFormat, + type Config, + type GlobalFlags, + type AppCompletionRequest, + type AppStreamChunk, + type AppCompletionResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "app call", + description: "Call a Bailian application (agent or workflow)", + usage: "bl app call --app-id --prompt [flags]", + options: [ + { flag: "--app-id ", description: "Application ID (required)", required: true }, + { flag: "--prompt ", description: "Input prompt text", required: true }, + { + flag: "--image ", + description: "Image URL(s) to pass to the app (repeatable)", + type: "array", + }, + { flag: "--file-id ", description: "Pre-uploaded file ID(s) (repeatable)", type: "array" }, + { flag: "--session-id ", description: "Session ID for multi-turn conversation" }, + { flag: "--stream", description: "Stream response (default: on in TTY)" }, + { flag: "--pipeline-ids ", description: "Knowledge base pipeline IDs (comma-separated)" }, + { flag: "--memory-id ", description: "Memory ID for long-term memory" }, + { flag: "--biz-params ", description: "Business parameters JSON (workflow variables)" }, + { flag: "--has-thoughts", description: "Show agent thinking process" }, + ], + examples: [ + 'bl app call --app-id abc123 --prompt "你好"', + 'bl app call --app-id abc123 --prompt "描述这张图片" --image https://example.com/photo.jpg', + 'bl app call --app-id abc123 --prompt "分析图片" --image img1.jpg --image img2.jpg', + 'bl app call --app-id abc123 --prompt "继续" --session-id sess_xxx --stream', + 'bl app call --app-id abc123 --prompt "搜索资料" --pipeline-ids pipe1,pipe2', + 'bl app call --app-id abc123 --prompt "开始" --biz-params \'{"key":"value"}\'', + ], + async run(config: Config, flags: GlobalFlags) { + const appId = flags.appId as string; + if (!appId) failIfMissing("app-id", "bl app call --app-id --prompt "); + + const prompt = flags.prompt as string; + if (!prompt) failIfMissing("prompt", "bl app call --app-id --prompt "); + + const shouldStream = + flags.stream === true || (flags.stream === undefined && process.stdout.isTTY); + const format = detectOutputFormat(config.output); + + const body: AppCompletionRequest = { + input: { prompt }, + parameters: { + incremental_output: shouldStream, + }, + }; + + if (flags.sessionId) { + body.input.session_id = flags.sessionId as string; + } + + // Pass image URLs via image_list + const imageUrls = flags.image as string[] | undefined; + if (imageUrls && imageUrls.length > 0) { + body.input.image_list = imageUrls; + } + + // Pass pre-uploaded file IDs + const fileIds = flags.fileId as string[] | undefined; + if (fileIds && fileIds.length > 0) { + body.input.file_ids = fileIds; + } + + if (flags.hasThoughts) { + body.parameters!.has_thoughts = true; + } + + if (flags.pipelineIds) { + const ids = (flags.pipelineIds as string) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + body.parameters!.rag_options = { pipeline_ids: ids }; + } + + if (flags.memoryId) { + body.parameters!.memory_id = flags.memoryId as string; + } + + if (flags.bizParams) { + try { + body.input.biz_params = JSON.parse(flags.bizParams as string); + } catch { + process.stderr.write("Error: --biz-params must be valid JSON\n"); + process.exit(1); + } + } + + if (config.dryRun) { + emitResult({ endpoint: appCompletionEndpoint(config.baseUrl, appId), request: body }, format); + return; + } + + const url = appCompletionEndpoint(config.baseUrl, appId); + + if (shouldStream) { + const headers: Record = { "X-DashScope-SSE": "enable" }; + const res = await request(config, { + url, + method: "POST", + body, + headers, + stream: true, + }); + + let fullText = ""; + let sessionId = ""; + const writesStreamingStdout = format === "text"; + const dim = config.noColor ? "" : "\x1b[2m"; + const reset = config.noColor ? "" : "\x1b[0m"; + + for await (const event of parseSSE(res)) { + if (event.data === "[DONE]") break; + try { + const chunk = JSON.parse(event.data) as AppStreamChunk; + const text = chunk.output?.text; + + if (text) { + // incremental_output: text is delta + if (writesStreamingStdout) process.stdout.write(text); + fullText += text; + } + + // Capture session_id for multi-turn + if (chunk.output?.session_id) { + sessionId = chunk.output.session_id; + } + + // Show thoughts if available + if (chunk.output?.thoughts && flags.hasThoughts) { + for (const t of chunk.output.thoughts) { + if (t.thought) process.stderr.write(`${dim}[Thinking] ${t.thought}${reset}\n`); + if (t.action_name) + process.stderr.write( + `${dim}[Action] ${t.action_name}: ${t.action_input || ""}${reset}\n`, + ); + if (t.observation) + process.stderr.write(`${dim}[Observation] ${t.observation}${reset}\n`); + } + } + } catch { + // skip unparseable + } + } + + // Show session_id for multi-turn conversation + if (sessionId && !config.quiet) { + process.stderr.write(`${dim}Session ID: ${sessionId}${reset}\n`); + } + + if (format === "json") { + emitResult({ text: fullText, session_id: sessionId }, format); + } else { + process.stdout.write("\n"); + } + } else { + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + const text = response.output?.text ?? ""; + + if (config.quiet || format === "text") { + emitBare(text); + } else { + emitResult(response, format); + } + } + }, +}); diff --git a/packages/cli/src/commands/app/list.ts b/packages/cli/src/commands/app/list.ts new file mode 100644 index 0000000..904ad47 --- /dev/null +++ b/packages/cli/src/commands/app/list.ts @@ -0,0 +1,88 @@ +import { + defineCommand, + callConsoleGateway, + resolveConsoleGatewayCredential, + detectOutputFormat, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +import { emitResult } from "../../output/output.ts"; + +const APP_LIST_API = "zeldaEasy.broadscope-bailian.app-control.list"; + +export default defineCommand({ + name: "app list", + description: "List Bailian applications", + usage: "bl app list [flags]", + options: [ + { + flag: "--name ", + description: "Filter by app name (keyword search)", + }, + { + flag: "--page ", + description: "Page number (default: 1)", + type: "number", + }, + { + flag: "--page-size ", + description: "Results per page (default: 30)", + type: "number", + }, + { + flag: "--region ", + description: "API region (default: cn-beijing)", + }, + ], + examples: [ + "bl app list", + "bl app list --name 客服", + "bl app list --page 2 --page-size 10", + "bl app list --output json", + ], + async run(config: Config, flags: GlobalFlags) { + const name = (flags.name as string) || ""; + const pageNo = (flags.page as number) || 1; + const pageSize = (flags.pageSize as number) || 30; + const region = (flags.region as string) || "cn-beijing"; + const format = detectOutputFormat(config.output); + + const credential = await resolveConsoleGatewayCredential(config); + + const data = { + reqDTO: { + name, + notInTypes: [10], + type: 5, + statuses: [1, 4], + page_no: pageNo, + page_size: pageSize, + }, + }; + + if (config.dryRun) { + emitResult( + { api: APP_LIST_API, data, region, token: credential.token.slice(0, 8) + "..." }, + format, + ); + return; + } + + const result = (await callConsoleGateway(config, credential.token, { + api: APP_LIST_API, + data, + region, + })) as any; + + const list: unknown[] = result?.data?.DataV2?.data?.data?.list ?? []; + const total: number = result?.data?.DataV2?.data?.data?.total ?? 0; + + const apps = list.map((item: any) => ({ + code: item.code, + name: item.name, + user_prompt_params: item.config?.user_prompt_params ?? [], + })); + + emitResult({ total, apps }, format); + }, +}); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts new file mode 100644 index 0000000..8110705 --- /dev/null +++ b/packages/cli/src/commands/auth/login.ts @@ -0,0 +1,390 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import http from "node:http"; + +import { + BailianError, + ExitCode, + chatEndpoint, + defineCommand, + getConfigPath, + isInteractive, + maskToken, + readConfigFile, + requestJson, + writeConfigFile, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +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(); + }); + }); +} + +async function validateKeyAndPersist(config: Config, key: string): Promise { + process.stderr.write("Testing key... "); + const testConfig = { ...config, apiKey: key }; + await requestJson(testConfig, { + url: chatEndpoint(testConfig.baseUrl), + method: "POST", + body: { + model: "qwen3.7-max", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + }); + process.stderr.write("Valid\n"); + + const existing = readConfigFile() as Record; + existing.api_key = key; + await writeConfigFile(existing); + 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)", + usage: "bl auth login --api-key | bl auth login --console", + options: [ + { flag: "--api-key ", description: "DashScope API key to store" }, + { + flag: "--console", + description: "Sign in via browser; opens the console login URL in your default browser", + type: "boolean", + }, + ], + examples: ["bl auth login --api-key sk-xxxxx", "bl auth login --console"], + async run(config: Config, flags: GlobalFlags) { + if (flags.console) { + if (config.dryRun) { + emitBare( + "Would bind a free port on 127.0.0.1 and open the console login URL in your browser.", + ); + return; + } + await runConsoleLogin(resolveConsoleOrigin()); + return; + } + + const envKey = process.env.DASHSCOPE_API_KEY; + if (envKey && !flags.apiKey) { + const maskedEnvKey = maskToken(envKey); + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const proceed = await promptConfirm({ + message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`, + initialValue: false, + }); + if (!proceed) { + process.stdout.write("Login skipped. Using environment variables.\n"); + process.exit(0); + } + } else { + process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`); + } + } + + const key = (flags.apiKey as string) || config.apiKey; + if (!key) { + printCurrentCommandHelp(process.stderr); + process.exit(0); + } + + if (!config.dryRun) { + await validateKeyAndPersist(config, key); + printQuickStart(); + } else { + emitBare("Would validate and save API key."); + } + }, +}); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts new file mode 100644 index 0000000..fbd2a07 --- /dev/null +++ b/packages/cli/src/commands/auth/logout.ts @@ -0,0 +1,79 @@ +import { + defineCommand, + clearApiKey, + readConfigFile, + writeConfigFile, + getConfigPath, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +import { emitBare } from "../../output/output.ts"; + +async function clearConsoleToken(): Promise { + const file = readConfigFile() as Record; + if (!file.access_token) return false; + delete file.access_token; + await writeConfigFile(file); + return true; +} + +export default defineCommand({ + name: "auth logout", + description: "Clear stored credentials", + usage: "bl auth logout [--console] [--yes] [--dry-run]", + options: [ + { + flag: "--console", + description: "Only clear the console access_token, keep api_key intact", + type: "boolean", + }, + { flag: "--yes", description: "Skip confirmation prompt" }, + ], + examples: [ + "bl auth logout", + "bl auth logout --console", + "bl auth logout --dry-run", + "bl auth logout --yes", + ], + async run(config: Config, flags: GlobalFlags) { + const file = readConfigFile(); + + if (flags.console) { + const hasToken = !!file.access_token; + if (config.dryRun) { + if (hasToken) emitBare("Would clear access_token from ~/.bailian/config.json"); + else emitBare("No console access_token to clear."); + emitBare("No changes made."); + return; + } + if (hasToken) { + await clearConsoleToken(); + process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`); + if (file.api_key) { + process.stderr.write( + "api_key is still configured and will be used for authentication.\n", + ); + } + } else { + process.stderr.write("No console access_token to clear.\n"); + } + return; + } + + const hasKey = !!(file.api_key || file.access_token); + + if (config.dryRun) { + if (hasKey) emitBare("Would clear api_key / access_token from ~/.bailian/config.json"); + else emitBare("No credentials to clear."); + emitBare("No changes made."); + return; + } + + if (hasKey) { + await clearApiKey(); + process.stderr.write("Cleared api_key / access_token from ~/.bailian/config.json\n"); + } else { + process.stderr.write("No credentials to clear.\n"); + } + }, +}); diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts new file mode 100644 index 0000000..7ea9b85 --- /dev/null +++ b/packages/cli/src/commands/auth/status.ts @@ -0,0 +1,172 @@ +import { + defineCommand, + resolveCredential, + resolveConsoleGatewayCredential, + detectOutputFormat, + maskToken, + type Config, + type GlobalFlags, + type ResolvedCredential, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { API_KEY_PAGE } from "../../urls.ts"; + +interface StoredCredential { + configured: boolean; + source?: string; + masked?: string; +} + +interface AuthStatusPayload { + api_key: StoredCredential; + access_token: StoredCredential; + dashscope_commands?: { method: string; source: string; masked: string }; + console_gateway_commands?: { method: string; source: string; masked: string }; +} + +function storedApiKey(config: Config): StoredCredential { + if (config.apiKey) { + return { configured: true, source: "flag", masked: maskToken(config.apiKey) }; + } + if (config.fileApiKey) { + return { configured: true, source: "config.json", masked: maskToken(config.fileApiKey) }; + } + const env = process.env.DASHSCOPE_API_KEY?.trim(); + if (env) { + return { configured: true, source: "DASHSCOPE_API_KEY", masked: maskToken(env) }; + } + return { configured: false }; +} + +function storedAccessToken(config: Config): StoredCredential { + if (config.accessTokenEnv) { + return { + configured: true, + source: "DASHSCOPE_ACCESS_TOKEN", + masked: maskToken(config.accessTokenEnv), + }; + } + if (config.fileAccessToken) { + return { + configured: true, + source: "config.json", + masked: maskToken(config.fileAccessToken), + }; + } + return { configured: false }; +} + +async function tryResolveDashscope(config: Config): Promise { + try { + return await resolveCredential(config); + } catch { + return undefined; + } +} + +async function tryResolveConsole(config: Config): Promise { + try { + return await resolveConsoleGatewayCredential(config); + } catch { + return undefined; + } +} + +async function buildStatus(config: Config): Promise { + const status: AuthStatusPayload = { + api_key: storedApiKey(config), + access_token: storedAccessToken(config), + }; + + const dashscope = await tryResolveDashscope(config); + if (dashscope) { + status.dashscope_commands = { + method: dashscope.method, + source: dashscope.source, + masked: maskToken(dashscope.token), + }; + } + + const consoleGw = await tryResolveConsole(config); + if (consoleGw) { + status.console_gateway_commands = { + method: consoleGw.method, + source: consoleGw.source, + masked: maskToken(consoleGw.token), + }; + } + + return status; +} + +function hasAnyAuth(status: AuthStatusPayload): boolean { + return ( + status.api_key.configured || + status.access_token.configured || + !!status.dashscope_commands || + !!status.console_gateway_commands + ); +} + +function emitTextStatus(status: AuthStatusPayload): void { + emitBare("Authentication Status:"); + emitBare(" Stored credentials (can coexist):"); + if (status.api_key.configured) { + emitBare(` API key: ${status.api_key.source} ${status.api_key.masked}`); + } else { + emitBare(" API key: not configured"); + } + if (status.access_token.configured) { + emitBare(` Console token: ${status.access_token.source} ${status.access_token.masked}`); + } else { + emitBare(" Console token: not configured"); + } + emitBare(" Effective credential per command family:"); + if (status.dashscope_commands) { + emitBare( + ` DashScope API: ${status.dashscope_commands.method} (${status.dashscope_commands.source}) ${status.dashscope_commands.masked}`, + ); + } else { + emitBare(" DashScope API: unavailable"); + } + if (status.console_gateway_commands) { + emitBare( + ` Console gateway: ${status.console_gateway_commands.method} (${status.console_gateway_commands.source}) ${status.console_gateway_commands.masked}`, + ); + } else { + emitBare(" Console gateway: unavailable (run bl auth login --console)"); + } +} + +export default defineCommand({ + name: "auth status", + description: "Show current authentication state", + usage: "bl auth status", + examples: ["bl auth status", "bl auth status --output json"], + async run(config: Config, _flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const status = await buildStatus(config); + + if (!hasAnyAuth(status)) { + const result = { + authenticated: false, + message: "Not authenticated.", + hint: [ + "DashScope API: bl auth login --api-key or DASHSCOPE_API_KEY", + "Console gateway: bl auth login --console or DASHSCOPE_ACCESS_TOKEN", + `Get API Key: ${API_KEY_PAGE}`, + ].join("\n"), + ...status, + }; + emitResult(result, format); + return; + } + + if (format !== "text") { + emitResult({ authenticated: true, ...status }, format); + return; + } + + emitTextStatus(status); + }, +}); diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts new file mode 100644 index 0000000..debfd23 --- /dev/null +++ b/packages/cli/src/commands/catalog.ts @@ -0,0 +1,76 @@ +import type { Command } from "bailian-cli-core"; + +import authLogin from "./auth/login.ts"; +import authStatus from "./auth/status.ts"; +import authLogout from "./auth/logout.ts"; +import textChat from "./text/chat.ts"; +import textOmni from "./omni/chat.ts"; +import imageGenerate from "./image/generate.ts"; +import imageEdit from "./image/edit.ts"; +import videoGenerate from "./video/generate.ts"; +import videoEdit from "./video/edit.ts"; +import videoRef from "./video/ref.ts"; +import videoTaskGet from "./video/task-get.ts"; +import videoDownload from "./video/download.ts"; +import visionDescribe from "./vision/describe.ts"; +import configShow from "./config/show.ts"; +import configSet from "./config/set.ts"; +import configExportSchema from "./config/export-schema.ts"; +import update from "./update.ts"; +import appCall from "./app/call.ts"; +import appList from "./app/list.ts"; +import memoryAdd from "./memory/add.ts"; +import memorySearch from "./memory/search.ts"; +import memoryList from "./memory/list.ts"; +import memoryUpdate from "./memory/update.ts"; +import memoryDelete from "./memory/delete.ts"; +import memoryProfileCreate from "./memory/profile-create.ts"; +import memoryProfileGet from "./memory/profile-get.ts"; +import knowledgeRetrieve from "./knowledge/retrieve.ts"; +import searchWeb from "./search/web.ts"; +import speechSynthesize from "./speech/synthesize.ts"; +import speechRecognize from "./speech/recognize.ts"; +import fileUpload from "./file/upload.ts"; +import consoleCall from "./console/call.ts"; +import usageFree from "./usage/free.ts"; +import pipelineRun from "./pipeline/run.ts"; +import pipelineValidate from "./pipeline/validate.ts"; + +/** Command registry map (no dependency on registry.ts — safe for build-time import). */ +export const commands: Record = { + "auth login": authLogin, + "auth status": authStatus, + "auth logout": authLogout, + "text chat": textChat, + omni: textOmni, + "image generate": imageGenerate, + "image edit": imageEdit, + "video generate": videoGenerate, + "video edit": videoEdit, + "video ref": videoRef, + "video task get": videoTaskGet, + "video download": videoDownload, + "vision describe": visionDescribe, + "app call": appCall, + "app list": appList, + "memory add": memoryAdd, + "memory search": memorySearch, + "memory list": memoryList, + "memory update": memoryUpdate, + "memory delete": memoryDelete, + "memory profile create": memoryProfileCreate, + "memory profile get": memoryProfileGet, + "knowledge retrieve": knowledgeRetrieve, + "search web": searchWeb, + "speech synthesize": speechSynthesize, + "speech recognize": speechRecognize, + "file upload": fileUpload, + "console call": consoleCall, + "usage free": usageFree, + "pipeline run": pipelineRun, + "pipeline validate": pipelineValidate, + "config show": configShow, + "config set": configSet, + "config export-schema": configExportSchema, + update: update, +}; diff --git a/packages/cli/src/commands/config/export-schema.ts b/packages/cli/src/commands/config/export-schema.ts new file mode 100644 index 0000000..0785021 --- /dev/null +++ b/packages/cli/src/commands/config/export-schema.ts @@ -0,0 +1,46 @@ +import { defineCommand, generateToolSchema } from "bailian-cli-core"; +import type { Config } from "bailian-cli-core"; +import type { GlobalFlags } from "bailian-cli-core"; +import { BailianError } from "bailian-cli-core"; +import { ExitCode } from "bailian-cli-core"; + +/** + * Commands that are infrastructure/auth-related and not suitable as Agent tools. + */ +const SKIP_PREFIXES = ["auth ", "config ", "update"]; + +export default defineCommand({ + name: "config export-schema", + description: + "Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas", + usage: 'bl config export-schema [--command ""]', + options: [ + { + flag: "--command ", + description: 'Export schema for a specific command only (e.g. "image generate")', + }, + ], + examples: ["bl config export-schema", 'bl config export-schema --command "video generate"'], + async run(config: Config, flags: GlobalFlags) { + const { commands } = await import("../catalog.ts"); + const targetCommand = flags.command as string | undefined; + + if (targetCommand) { + const command = commands[targetCommand]; + if (!command) { + throw new BailianError(`Command "${targetCommand}" not found.`, ExitCode.USAGE); + } + const schema = generateToolSchema(command); + process.stdout.write(JSON.stringify(schema, null, 2) + "\n"); + return; + } + + // Export all suitable commands + const allCommands = Object.values(commands); + const schemas = allCommands + .filter((c) => !SKIP_PREFIXES.some((p) => c.name.startsWith(p))) + .map((c) => generateToolSchema(c)); + + process.stdout.write(JSON.stringify(schemas, null, 2) + "\n"); + }, +}); diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts new file mode 100644 index 0000000..004be0d --- /dev/null +++ b/packages/cli/src/commands/config/set.ts @@ -0,0 +1,126 @@ +import { + defineCommand, + detectOutputFormat, + readConfigFile, + writeConfigFile, + BailianError, + type Config, + type GlobalFlags, + ExitCode, +} from "bailian-cli-core"; +import { emitResult } from "../../output/output.ts"; + +const VALID_KEYS = [ + "region", + "base_url", + "output", + "output_dir", + "timeout", + "api_key", + "access_token", + "default_text_model", + "default_video_model", + "default_image_model", + "default_speech_model", + "default_omni_model", + "access_key_id", + "access_key_secret", + "workspace_id", +]; + +// Allow hyphen-style keys (e.g. default-text-model → default_text_model) +const KEY_ALIASES: Record = { + "base-url": "base_url", + "output-dir": "output_dir", + "api-key": "api_key", + "access-token": "access_token", + "default-text-model": "default_text_model", + "default-video-model": "default_video_model", + "default-image-model": "default_image_model", + "default-speech-model": "default_speech_model", + "default-omni-model": "default_omni_model", + "access-key-id": "access_key_id", + "access-key-secret": "access_key_secret", + "workspace-id": "workspace_id", +}; + +export default defineCommand({ + name: "config set", + description: "Set a config value", + usage: "bl config set --key --value ", + options: [ + { + flag: "--key ", + description: + "Config key (region, base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)", + }, + { flag: "--value ", description: "Value to set" }, + ], + examples: [ + "bl config set --key output --value json", + "bl config set --key timeout --value 600", + "bl config set --key base_url --value https://dashscope.aliyuncs.com", + ], + async run(config: Config, flags: GlobalFlags) { + const key = flags.key as string | undefined; + const value = flags.value as string | undefined; + + if (!key || value === undefined) { + throw new BailianError( + "--key and --value are required.", + ExitCode.USAGE, + "bl config set --key --value ", + ); + } + + // Resolve hyphen aliases to underscore keys + const resolvedKey: string = KEY_ALIASES[key] || key; + + if (!VALID_KEYS.includes(resolvedKey)) { + throw new BailianError( + `Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`, + ExitCode.USAGE, + ); + } + + // Validate specific values + if (resolvedKey === "region" && !["cn", "us", "intl"].includes(value)) { + throw new BailianError( + `Invalid region "${value}". Valid values: cn, us, intl`, + ExitCode.USAGE, + ); + } + + if (resolvedKey === "output" && !["text", "json"].includes(value)) { + throw new BailianError( + `Invalid output format "${value}". Valid values: text, json`, + ExitCode.USAGE, + ); + } + + if (resolvedKey === "timeout") { + const num = Number(value); + if (isNaN(num) || num <= 0) { + throw new BailianError( + `Invalid timeout "${value}". Must be a positive number.`, + ExitCode.USAGE, + ); + } + } + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ would_set: { [resolvedKey]: value } }, format); + return; + } + + const existing = readConfigFile() as Record; + existing[resolvedKey] = resolvedKey === "timeout" ? Number(value) : value; + await writeConfigFile(existing); + + if (!config.quiet) { + emitResult({ [resolvedKey]: existing[resolvedKey] }, format); + } + }, +}); diff --git a/packages/cli/src/commands/config/show.ts b/packages/cli/src/commands/config/show.ts new file mode 100644 index 0000000..6f268e2 --- /dev/null +++ b/packages/cli/src/commands/config/show.ts @@ -0,0 +1,44 @@ +import { + defineCommand, + readConfigFile as loadConfigFile, + getConfigPath, + detectOutputFormat, + maskToken, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +import { emitResult } from "../../output/output.ts"; + +export default defineCommand({ + name: "config show", + description: "Display current configuration", + usage: "bl config show", + examples: ["bl config show", "bl config show --output json"], + async run(config: Config, _flags: GlobalFlags) { + const file = loadConfigFile(); + const format = detectOutputFormat(config.output); + + const result: Record = { + region: config.region, + base_url: config.baseUrl, + output: config.output, + timeout: config.timeout, + config_file: getConfigPath(), + }; + + // Mask API key if present + if (file.api_key) { + result.api_key = maskToken(file.api_key); + } + if (file.access_token) { + result.access_token = maskToken(file.access_token); + } + + // Default models + if (file.default_text_model) result.default_text_model = file.default_text_model; + if (file.default_video_model) result.default_video_model = file.default_video_model; + if (file.default_image_model) result.default_image_model = file.default_image_model; + + emitResult(result, format); + }, +}); diff --git a/packages/cli/src/commands/console/call.ts b/packages/cli/src/commands/console/call.ts new file mode 100644 index 0000000..b3d01ba --- /dev/null +++ b/packages/cli/src/commands/console/call.ts @@ -0,0 +1,78 @@ +import { + defineCommand, + callConsoleGateway, + resolveConsoleGatewayCredential, + CONSOLE_GATEWAY_NO_TOKEN_MESSAGE, + BailianError, + detectOutputFormat, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult } from "../../output/output.ts"; + +export default defineCommand({ + name: "console call", + description: "Call a Bailian console API via the CLI gateway", + usage: "bl console call --api --data [flags]", + options: [ + { + flag: "--api ", + description: "API name (e.g. zeldaEasy.broadscope-bailian.memory-library.getLibraries)", + required: true, + }, + { + flag: "--data ", + description: "Request data as JSON string", + required: true, + }, + { + flag: "--region ", + description: "API region (default: cn-beijing)", + }, + ], + examples: [ + `bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`, + `bl console call --api some.api.name --data '{"key":"value"}' --region cn-beijing`, + ], + async run(config: Config, flags: GlobalFlags) { + const api = flags.api as string; + if (!api) failIfMissing("api", "bl console call --api --data "); + + const dataRaw = flags.data as string; + if (!dataRaw) failIfMissing("data", "bl console call --api --data "); + + let data: Record; + try { + data = JSON.parse(dataRaw) as Record; + } catch { + process.stderr.write("Error: --data must be valid JSON\n"); + process.exit(1); + } + + const region = (flags.region as string) || "cn-beijing"; + const format = detectOutputFormat(config.output); + + let token: string | undefined; + try { + token = (await resolveConsoleGatewayCredential(config)).token; + } catch (err) { + if (!(err instanceof BailianError && err.message === CONSOLE_GATEWAY_NO_TOKEN_MESSAGE)) { + throw err; + } + } + + if (config.dryRun) { + emitResult({ api, data, region, token: token ? token.slice(0, 8) + "..." : null }, format); + return; + } + + const result = await callConsoleGateway(config, token, { + api, + data, + region, + }); + + emitResult(result, format); + }, +}); diff --git a/packages/cli/src/commands/file/upload.ts b/packages/cli/src/commands/file/upload.ts new file mode 100644 index 0000000..edc4c12 --- /dev/null +++ b/packages/cli/src/commands/file/upload.ts @@ -0,0 +1,76 @@ +import { + defineCommand, + resolveCredential, + detectOutputFormat, + type Config, + type GlobalFlags, + uploadFile, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "file upload", + description: "Upload a local file to DashScope temporary storage (48h)", + apiDocs: "/developer-reference/get-temporary-file-url", + usage: "bl file upload --file --model ", + options: [ + { + flag: "--file ", + description: "Local file to upload (image, video, audio)", + required: true, + }, + { + flag: "--model ", + description: "Target model name (file is bound to this model)", + required: true, + }, + ], + examples: [ + "bl file upload --file photo.jpg --model qwen-vl-max", + "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", + ], + async run(config: Config, flags: GlobalFlags) { + const filePath = flags.file as string | undefined; + if (!filePath) { + failIfMissing("file", "bl file upload --file --model "); + } + + const model = flags.model as string | undefined; + if (!model) { + failIfMissing("model", "bl file upload --file --model "); + } + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ action: "upload", file: filePath, model }, format); + return; + } + + // Resolve API key for upload + const credential = await resolveCredential(config); + + const ossUrl = await uploadFile({ + apiKey: credential.token, + model: model!, + filePath: filePath!, + }); + + if (config.quiet) { + emitBare(ossUrl); + } else { + emitResult( + { + url: ossUrl, + model, + expires_in: "48 hours", + note: "When using this URL in API calls, add header: X-DashScope-OssResourceResolve: enable", + }, + format, + ); + } + }, +}); diff --git a/packages/cli/src/commands/image/edit.ts b/packages/cli/src/commands/image/edit.ts new file mode 100644 index 0000000..8257fe3 --- /dev/null +++ b/packages/cli/src/commands/image/edit.ts @@ -0,0 +1,196 @@ +import { + defineCommand, + requestJson, + imageSyncEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + resolveCredential, + resolveFileUrl, + resolveOutputDir, + generateFilename, + isInteractive, + stripUndefined, + type DashScopeImageRequest, + type DashScopeImageSyncResponse, + ExitCode, + BailianError, +} from "bailian-cli-core"; +import { downloadFile } from "../../utils/download.ts"; +import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { resolveImageSize } from "../../utils/image-size.ts"; +import { join } from "path"; + +export default defineCommand({ + name: "image edit", + description: "Edit an existing image with text instructions (Qwen-Image)", + apiDocs: "/developer-reference/qwen-image-edit-api", + usage: "bl image edit --image --prompt [flags]", + options: [ + { + flag: "--image ", + description: "Source image URL or local file path (repeatable for multi-image merge)", + required: true, + type: "array", + }, + { flag: "--prompt ", description: "Edit instruction text", required: true }, + { flag: "--model ", description: "Model ID (default: qwen-image-2.0)" }, + { + flag: "--size ", + description: "Output image size: ratio (3:4, 16:9) or pixels (2048*2048)", + }, + { flag: "--n ", description: "Number of images (default: 1, max: 6)", type: "number" }, + { flag: "--seed ", description: "Random seed for reproducible results", type: "number" }, + { + flag: "--negative-prompt ", + description: "Negative prompt to exclude unwanted content", + }, + { flag: "--prompt-extend", description: "Enable prompt smart rewrite (default: true)" }, + { flag: "--no-prompt-extend", description: "Disable prompt extend" }, + { flag: "--watermark", description: "Add watermark to output images" }, + { flag: "--out-dir ", description: "Download images to directory" }, + { flag: "--out-prefix ", description: "Filename prefix (default: edited)" }, + ], + examples: [ + 'bl image edit --image ./photo.png --prompt "把背景换成海滩"', + 'bl image edit --image https://example.com/logo.png --prompt "Change color to blue" --n 3', + 'bl image edit --image ./a.png --image ./b.png --prompt "把两张图合并成一张拼图"', + 'bl image edit --image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro', + ], + async run(config: Config, flags: GlobalFlags) { + // Normalize --image to string array (supports both single and repeated flags) + let rawImages: string[] = []; + if (Array.isArray(flags.image)) { + rawImages = flags.image as string[]; + } else if (typeof flags.image === "string") { + rawImages = [flags.image]; + } + if (rawImages.length === 0) { + failIfMissing("image", "bl image edit --image --prompt "); + } + + let prompt = flags.prompt as string | undefined; + if (!prompt) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ + message: "Enter your edit instruction:", + }); + if (!hint) { + process.stderr.write("Image editing cancelled.\n"); + process.exit(1); + } + prompt = hint; + } else { + failIfMissing("prompt", "bl image edit --image --prompt "); + } + } + + const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0"; + + // Auto-upload local files (resolve all images in parallel) + const credential = await resolveCredential(config); + const resolvedImages = await Promise.all( + rawImages.map((img) => resolveFileUrl(img, credential.token, model)), + ); + const n = (flags.n as number) ?? 1; + + // Determine prompt_extend + let promptExtend: boolean | undefined; + if (flags.noPromptExtend === true) { + promptExtend = false; + } else if (flags.promptExtend === true) { + promptExtend = true; + } else { + promptExtend = true; // default on for qwen-image + } + + // Build content: all images first, then text prompt + const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map( + (u: string) => ({ image: u }), + ); + contentItems.push({ text: prompt! }); + + const body: DashScopeImageRequest = { + model, + input: { + messages: [ + { + role: "user", + content: contentItems, + }, + ], + }, + parameters: { + size: resolveImageSize(flags.size as string | undefined, true), + n, + seed: flags.seed as number | undefined, + prompt_extend: promptExtend, + watermark: flags.watermark === true ? true : undefined, + negative_prompt: (flags.negativePrompt as string) || undefined, + }, + }; + + // Remove undefined parameters + stripUndefined(body.parameters as Record); + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}] [Mode: sync] [Images: ${resolvedImages.length}]\n`); + } + + const url = imageSyncEndpoint(config.baseUrl); + const concurrent = getConcurrency(flags); + + const results = await runConcurrent(concurrent, config, () => + requestJson(config, { + url, + method: "POST", + body, + }), + ); + + // Extract image URLs from all responses + const imageUrls = results + .flatMap((r) => r.output.choices || []) + .flatMap((c) => c.message?.content || []) + .map((item) => item.image) + .filter(Boolean); + + if (imageUrls.length === 0) { + throw new BailianError("Edit completed but no images returned.", ExitCode.GENERAL); + } + + const outDir = resolveOutputDir(config, { + flagDir: flags.outDir as string | undefined, + subDir: flags.outDir ? undefined : "images", + }); + + const prefix = + (flags.outPrefix as string) || generateFilename("edited", flags?.prompt as string); + + // Parallel download all images + const items = + imageUrls.length > 1 + ? imageUrls.map((url, i) => { + const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`; + return { url, destPath: join(outDir, filename) }; + }) + : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; + + const saved = await downloadParallel(items, downloadFile, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(saved.join("\n")); + } else { + emitResult({ urls: imageUrls, saved, total: imageUrls.length }, format); + } + }, +}); diff --git a/packages/cli/src/commands/image/generate.ts b/packages/cli/src/commands/image/generate.ts new file mode 100644 index 0000000..1ab4b9c --- /dev/null +++ b/packages/cli/src/commands/image/generate.ts @@ -0,0 +1,307 @@ +import { + defineCommand, + requestJson, + imageEndpoint, + imageSyncEndpoint, + taskEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + resolveOutputDir, + isInteractive, + type DashScopeImageRequest, + type DashScopeImageSyncResponse, + BailianError, + ExitCode, + type DashScopeAsyncResponse, + type OutputFormat, + type DashScopeTaskResponse, + generateFilename, +} from "bailian-cli-core"; +import { poll } from "../../utils/polling.ts"; +import { downloadFile } from "../../utils/download.ts"; +import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { resolveImageSize } from "../../utils/image-size.ts"; + +import { join } from "path"; + +// qwen-image-2.0 series uses the sync multimodal-generation endpoint +const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; + +function isSyncModel(model: string): boolean { + return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p)); +} + +export default defineCommand({ + name: "image generate", + description: "Generate images (Qwen-Image / wan2.x)", + apiDocs: "/best-practice/wanx/text-to-image", + usage: "bl image generate --prompt [flags]", + options: [ + { flag: "--prompt ", description: "Image description", required: true }, + { flag: "--model ", description: "Model ID (default: qwen-image-2.0)" }, + { + flag: "--size ", + description: "Image size: ratio (3:4, 16:9, 1:1) or pixels (2048*2048)", + }, + { + flag: "--n ", + description: "Number of images per request (default: 1, max: 6)", + type: "number", + }, + { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, + { + flag: "--negative-prompt ", + description: "Negative prompt to exclude unwanted content", + }, + { + flag: "--prompt-extend", + description: "Automatically extend prompt for better results (default: true for qwen-image)", + }, + { flag: "--no-prompt-extend", description: "Disable prompt extend" }, + { flag: "--watermark", description: "Add watermark to generated images" }, + { + flag: "--no-wait", + description: "Return task ID immediately without waiting (async models only)", + }, + { flag: "--out-dir ", description: "Download images to directory" }, + { flag: "--out-prefix ", description: "Filename prefix (default: image)" }, + { + flag: "--poll-interval ", + description: "Polling interval when waiting (default: 3)", + type: "number", + }, + ], + examples: [ + 'bl image generate --prompt "一只穿太空服的猫在火星上"', + 'bl image generate --prompt "Logo design" --n 3 --out-dir ./generated/', + 'bl image generate --prompt "Mountain landscape" --size 2688*1536', + 'bl image generate --prompt "A castle" --seed 42 --no-prompt-extend', + 'bl image generate --prompt "sunset" --model wan2.6-t2i --no-wait --quiet', + 'bl image generate --prompt "Pro quality" --model qwen-image-2.0-pro', + 'bl image generate --prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel', + ], + async run(config: Config, flags: GlobalFlags) { + let prompt = (flags.prompt ?? (flags._positional as string[] | undefined)?.[0]) as + | string + | undefined; + + if (!prompt) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ + message: "Enter your image prompt:", + }); + if (!hint) { + process.stderr.write("Image generation cancelled.\n"); + process.exit(1); + } + prompt = hint; + } else { + failIfMissing("prompt", "bl image generate --prompt "); + } + } + + const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0"; + const useSync = isSyncModel(model); + const defaultSize = useSync ? "1:1" : "1:1"; + const sizeInput = (flags.size as string) || defaultSize; + const size = resolveImageSize(sizeInput, useSync); + const n = (flags.n as number) ?? 1; + const concurrent = getConcurrency(flags); + + // Determine prompt_extend: default true for qwen-image, undefined for others + let promptExtend: boolean | undefined; + if (flags.noPromptExtend === true) { + promptExtend = false; + } else if (flags.promptExtend === true) { + promptExtend = true; + } else if (useSync) { + promptExtend = true; // qwen-image default + } + + const body: DashScopeImageRequest = { + model, + input: { + messages: [{ role: "user", content: [{ text: prompt! }] }], + }, + parameters: { + size, + n, + seed: flags.seed as number | undefined, + prompt_extend: promptExtend, + watermark: flags.watermark === true ? true : undefined, + negative_prompt: (flags.negativePrompt as string) || undefined, + }, + }; + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ request: body, mode: useSync ? "sync" : "async" }, format); + return; + } + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`); + } + + if (useSync) { + await handleSyncMode(config, model, body, flags, format, concurrent); + } else { + await handleAsyncMode(config, model, body, flags, format, concurrent); + } + }, +}); + +// ---- Sync mode: qwen-image-2.0 series ---- + +async function handleSyncMode( + config: Config, + _model: string, + body: DashScopeImageRequest, + flags: GlobalFlags, + format: string, + concurrent: number, +): Promise { + const url = imageSyncEndpoint(config.baseUrl); + + const results = await runConcurrent(concurrent, config, () => + requestJson(config, { url, method: "POST", body }), + ); + + const imageUrls = results + .flatMap((r) => r.output.choices || []) + .flatMap((c) => c.message?.content || []) + .map((item) => item.image) + .filter(Boolean); + + if (imageUrls.length === 0) { + throw new BailianError("Generation completed but no images returned.", ExitCode.GENERAL); + } + + await saveImages(imageUrls, flags, config, format); +} + +// ---- Async mode: wan2.x / qwen-image-plus ---- + +async function handleAsyncMode( + config: Config, + _model: string, + body: DashScopeImageRequest, + flags: GlobalFlags, + format: string, + concurrent: number, +): Promise { + const url = imageEndpoint(config.baseUrl); + + const responses = await runConcurrent( + concurrent, + config, + () => requestJson(config, { url, method: "POST", body, async: true }), + "tasks", + ); + const taskIds = responses.map((r) => r.output.task_id); + + // --no-wait: return all task IDs immediately + if (flags.noWait || config.async) { + emitResult({ task_ids: taskIds }, format as OutputFormat); + return; + } + + // Poll all tasks concurrently + const pollInterval = (flags.pollInterval as number) ?? 3; + + const pollPromises = taskIds.map((taskId) => { + const pollUrl = taskEndpoint(config.baseUrl, taskId); + return poll(config, { + url: pollUrl, + intervalSec: pollInterval, + timeoutSec: config.timeout, + isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, + getErrorMessage: (d) => { + const o = (d as DashScopeTaskResponse).output; + return o.message || o.code || undefined; + }, + }); + }); + + const results = await Promise.all(pollPromises); + + let imageUrls: string[] = []; + for (const result of results) { + if (result.output.choices) { + const urls = result.output.choices + .flatMap((c) => c.message?.content || []) + .map((item) => item.image) + .filter(Boolean); + imageUrls.push(...urls); + } + if (result.output.results) { + const urls = result.output.results.map((r) => r.url).filter(Boolean); + if (urls.length > 0 && imageUrls.length === 0) { + imageUrls.push(...urls); + } + } + } + + if (imageUrls.length === 0) { + throw new BailianError("All tasks completed but no images returned.", ExitCode.GENERAL); + } + + await saveImages( + imageUrls, + flags, + config, + format, + taskIds.length === 1 ? taskIds[0] : undefined, + taskIds, + ); +} + +// ---- Shared: download & save ---- + +async function saveImages( + imageUrls: string[], + flags: GlobalFlags, + config: Config, + format: string, + taskId?: string, + taskIds?: string[], +): Promise { + const outDir = resolveOutputDir(config, { + flagDir: flags.outDir as string | undefined, + subDir: flags.outDir ? undefined : "images", + }); + + const promptText = + (flags.prompt as string) || (flags._positional as string[] | undefined)?.[0] || ""; + const prefix = (flags.outPrefix as string) || generateFilename("image", promptText); + + // Parallel download all images + const items = + imageUrls.length > 1 + ? imageUrls.map((url, i) => { + const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`; + return { url, destPath: join(outDir, filename) }; + }) + : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; + + const results = await downloadParallel(items, downloadFile, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(results.join("\n")); + } else { + const output: Record = { + urls: imageUrls, + saved: results, + total: imageUrls.length, + }; + if (taskId) output.task_id = taskId; + if (taskIds && taskIds.length > 1) output.task_ids = taskIds; + emitResult(output, format as OutputFormat); + } +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts new file mode 100644 index 0000000..ac06d96 --- /dev/null +++ b/packages/cli/src/commands/index.ts @@ -0,0 +1 @@ +export { commands } from "./catalog.ts"; diff --git a/packages/cli/src/commands/knowledge/retrieve.ts b/packages/cli/src/commands/knowledge/retrieve.ts new file mode 100644 index 0000000..349fb64 --- /dev/null +++ b/packages/cli/src/commands/knowledge/retrieve.ts @@ -0,0 +1,154 @@ +import { + defineCommand, + signRequest, + detectOutputFormat, + type Config, + type GlobalFlags, + type KnowledgeRetrieveRequest, + type KnowledgeRetrieveResponse, + BailianError, + ExitCode, + trackingHeaders, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +const BAILIAN_HOST = "bailian.cn-beijing.aliyuncs.com"; + +export default defineCommand({ + name: "knowledge retrieve", + description: "Retrieve from a Bailian knowledge base (requires AK/SK)", + usage: "bl knowledge retrieve --index-id --query [flags]", + options: [ + { flag: "--index-id ", description: "Knowledge base index ID (required)", required: true }, + { flag: "--query ", description: "Search query (required)", required: true }, + { + flag: "--workspace-id ", + description: "Bailian workspace ID (or env BAILIAN_WORKSPACE_ID)", + }, + { flag: "--top-k ", description: "Number of results (default: 10)", type: "number" }, + { flag: "--rerank", description: "Enable rerank" }, + { flag: "--rerank-top-n ", description: "Rerank top N results", type: "number" }, + { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (or env)" }, + { flag: "--access-key-secret ", description: "Alibaba Cloud Access Key Secret (or env)" }, + ], + examples: [ + 'bl knowledge retrieve --index-id idx_xxx --query "如何使用阿里云百炼" --workspace-id ws_xxx', + 'bl knowledge retrieve --index-id idx_xxx --query "API限流" --top-k 5 --rerank', + ], + async run(config: Config, flags: GlobalFlags) { + const indexId = flags.indexId as string; + if (!indexId) failIfMissing("index-id", "bl knowledge retrieve --index-id --query "); + + const query = flags.query as string; + if (!query) failIfMissing("query", "bl knowledge retrieve --index-id --query "); + + const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId; + const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret; + const workspaceId = (flags.workspaceId as string) || config.workspaceId; + + if (!accessKeyId || !accessKeySecret) { + throw new BailianError( + "Knowledge retrieve requires Alibaba Cloud AK/SK.\n" + + "Set via: --access-key-id / --access-key-secret flags,\n" + + " or env: ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET,\n" + + " or config: bl config set access_key_id ", + ExitCode.AUTH, + ); + } + + if (!workspaceId) { + throw new BailianError( + "Knowledge retrieve requires a workspace ID.\n" + + "Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id ", + ExitCode.USAGE, + ); + } + + const body: KnowledgeRetrieveRequest = { + IndexId: indexId, + Query: query, + }; + + if (flags.topK !== undefined) body.TopK = flags.topK as number; + if (flags.rerank) body.Rerank = true; + if (flags.rerankTopN !== undefined) body.RerankTopN = flags.rerankTopN as number; + + const format = detectOutputFormat(config.output); + const pathname = `/${workspaceId}/index/retrieve`; + + if (config.dryRun) { + emitResult( + { + endpoint: `https://${BAILIAN_HOST}${pathname}`, + workspaceId, + request: body, + }, + format, + ); + return; + } + + const bodyStr = JSON.stringify(body); + + const headers = signRequest({ + accessKeyId, + accessKeySecret, + action: "Retrieve", + version: "2023-12-29", + body: bodyStr, + host: BAILIAN_HOST, + pathname, + }); + + const url = `https://${BAILIAN_HOST}${pathname}`; + + if (config.verbose) { + process.stderr.write(`> POST ${url}\n`); + process.stderr.write(`> AK: ${accessKeyId.slice(0, 8)}...\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(url, { + method: "POST", + headers: { + ...headers, + ...trackingHeaders(), + }, + body: bodyStr, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as KnowledgeRetrieveResponse & { + Code?: string; + Message?: string; + }; + + if (!res.ok || (data.Code && data.Code !== "Success")) { + throw new BailianError( + `Knowledge retrieve failed: ${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + if (config.quiet || format === "text") { + const nodes = data.Data?.Nodes || []; + if (nodes.length === 0) { + emitBare("No results found."); + } else { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + emitBare(`[${i + 1}] (score: ${node.Score.toFixed(4)})`); + emitBare(node.Text); + emitBare(""); + } + } + } else { + emitResult(data, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/add.ts b/packages/cli/src/commands/memory/add.ts new file mode 100644 index 0000000..dfa64cd --- /dev/null +++ b/packages/cli/src/commands/memory/add.ts @@ -0,0 +1,81 @@ +import { + defineCommand, + requestJson, + memoryAddEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type MemoryAddRequest, + type MemoryAddResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory add", + description: "Add memory from messages or custom content", + usage: "bl memory add --user-id [--messages ] [--content ] [flags]", + options: [ + { flag: "--user-id ", description: "User ID (required)", required: true }, + { + flag: "--messages ", + description: 'Messages JSON array: [{"role":"user","content":"..."},...]', + }, + { flag: "--content ", description: "Custom content text to memorize" }, + { flag: "--profile-schema ", description: "Profile schema ID for user profiling" }, + { flag: "--memory-library-id ", description: "Memory library ID (isolate memory space)" }, + ], + examples: [ + 'bl memory add --user-id user1 --content "用户喜欢Python编程"', + 'bl memory add --user-id user1 --messages \'[{"role":"user","content":"我喜欢旅行"}]\'', + 'bl memory add --user-id user1 --content "住在北京" --profile-schema schema_xxx', + ], + async run(config: Config, flags: GlobalFlags) { + const userId = flags.userId as string; + if (!userId) failIfMissing("user-id", "bl memory add --user-id "); + + const body: MemoryAddRequest = { user_id: userId }; + + if (flags.messages) { + try { + body.messages = JSON.parse(flags.messages as string); + } catch { + process.stderr.write("Error: --messages must be valid JSON array\n"); + process.exit(1); + } + } + + if (flags.content) { + body.custom_content = flags.content as string; + } + + if (!body.messages && !body.custom_content) { + process.stderr.write("Error: at least one of --messages or --content is required\n"); + process.exit(1); + } + + if (flags.profileSchema) body.profile_schema = flags.profileSchema as string; + if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string; + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ endpoint: memoryAddEndpoint(config.baseUrl), request: body }, format); + return; + } + + const url = memoryAddEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + if (config.quiet || format === "text") { + const ids = response.memory_ids?.join(", ") || "none"; + emitBare(`Memory added. IDs: ${ids}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/delete.ts b/packages/cli/src/commands/memory/delete.ts new file mode 100644 index 0000000..eb04163 --- /dev/null +++ b/packages/cli/src/commands/memory/delete.ts @@ -0,0 +1,50 @@ +import { + defineCommand, + requestJson, + memoryNodeEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory delete", + description: "Delete a memory node", + usage: "bl memory delete --node-id --user-id ", + options: [ + { flag: "--node-id ", description: "Memory node ID (required)", required: true }, + { flag: "--user-id ", description: "User ID (required)", required: true }, + { flag: "--memory-library-id ", description: "Memory library ID (non-default library)" }, + ], + examples: ["bl memory delete --node-id node_xxx --user-id user1"], + async run(config: Config, flags: GlobalFlags) { + const nodeId = flags.nodeId as string; + if (!nodeId) failIfMissing("node-id", "bl memory delete --node-id --user-id "); + + const userId = flags.userId as string; + if (!userId) failIfMissing("user-id", "bl memory delete --node-id --user-id "); + + const format = detectOutputFormat(config.output); + const params = new URLSearchParams({ user_id: userId }); + if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId as string); + const url = `${memoryNodeEndpoint(config.baseUrl, nodeId)}?${params.toString()}`; + + if (config.dryRun) { + emitResult({ endpoint: url, method: "DELETE" }, format); + return; + } + + const response = await requestJson<{ request_id: string }>(config, { + url, + method: "DELETE", + }); + + if (config.quiet || format === "text") { + emitBare(`Memory node ${nodeId} deleted.`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/list.ts b/packages/cli/src/commands/memory/list.ts new file mode 100644 index 0000000..d01f013 --- /dev/null +++ b/packages/cli/src/commands/memory/list.ts @@ -0,0 +1,65 @@ +import { + defineCommand, + requestJson, + memoryListEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type MemoryNodeListResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory list", + description: "List memory nodes for a user", + usage: "bl memory list --user-id [flags]", + options: [ + { flag: "--user-id ", description: "User ID (required)", required: true }, + { flag: "--page-size ", description: "Results per page (default: 10)", type: "number" }, + { flag: "--page ", description: "Page number (default: 1)", type: "number" }, + { flag: "--memory-library-id ", description: "Memory library ID" }, + ], + examples: [ + "bl memory list --user-id user1", + "bl memory list --user-id user1 --page-size 20 --page 2", + ], + async run(config: Config, flags: GlobalFlags) { + const userId = flags.userId as string; + if (!userId) failIfMissing("user-id", "bl memory list --user-id "); + + const format = detectOutputFormat(config.output); + const params = new URLSearchParams(); + params.set("user_id", userId); + if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize as number)); + if (flags.page !== undefined) params.set("page_num", String(flags.page as number)); + if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId as string); + + const url = `${memoryListEndpoint(config.baseUrl)}?${params.toString()}`; + + if (config.dryRun) { + emitResult({ endpoint: url, method: "GET" }, format); + return; + } + + const response = await requestJson(config, { + url, + method: "GET", + }); + + if (config.quiet || format === "text") { + if (!response.memory_nodes || response.memory_nodes.length === 0) { + emitBare("No memory nodes found."); + } else { + for (const node of response.memory_nodes) { + emitBare(`[${node.memory_node_id}] ${node.content}`); + } + if (response.total !== undefined) { + emitBare(`\nTotal: ${response.total}`); + } + } + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/profile-create.ts b/packages/cli/src/commands/memory/profile-create.ts new file mode 100644 index 0000000..b524a59 --- /dev/null +++ b/packages/cli/src/commands/memory/profile-create.ts @@ -0,0 +1,69 @@ +import { + defineCommand, + requestJson, + profileSchemaEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type ProfileSchemaCreateRequest, + type ProfileSchemaCreateResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory profile create", + description: "Create a user profile schema for memory profiling", + usage: "bl memory profile create --name --attributes [flags]", + options: [ + { flag: "--name ", description: "Schema name (required)", required: true }, + { flag: "--description ", description: "Schema description" }, + { + flag: "--attributes ", + description: 'Attributes JSON array: [{"name":"age","description":"年龄"}]', + required: true, + }, + ], + examples: [ + 'bl memory profile create --name "user_basic" --attributes \'[{"name":"age","description":"年龄"},{"name":"hobby","description":"爱好"}]\'', + ], + async run(config: Config, flags: GlobalFlags) { + const name = flags.name as string; + if (!name) failIfMissing("name", "bl memory profile create --name --attributes "); + + const attrStr = flags.attributes as string; + if (!attrStr) + failIfMissing("attributes", "bl memory profile create --name --attributes "); + + let attributes; + try { + attributes = JSON.parse(attrStr); + } catch { + process.stderr.write("Error: --attributes must be valid JSON array\n"); + process.exit(1); + } + + const body: ProfileSchemaCreateRequest = { name, attributes }; + if (flags.description) body.description = flags.description as string; + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ endpoint: profileSchemaEndpoint(config.baseUrl), request: body }, format); + return; + } + + const url = profileSchemaEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + if (config.quiet || format === "text") { + emitBare(`Profile schema created: ${response.profile_schema_id}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/profile-get.ts b/packages/cli/src/commands/memory/profile-get.ts new file mode 100644 index 0000000..33b51e1 --- /dev/null +++ b/packages/cli/src/commands/memory/profile-get.ts @@ -0,0 +1,56 @@ +import { + defineCommand, + requestJson, + userProfileEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type UserProfileResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory profile get", + description: "Get user profile by schema ID and user ID", + usage: "bl memory profile get --schema-id --user-id ", + options: [ + { flag: "--schema-id ", description: "Profile schema ID (required)", required: true }, + { flag: "--user-id ", description: "User ID (required)", required: true }, + ], + examples: ["bl memory profile get --schema-id schema_xxx --user-id user1"], + async run(config: Config, flags: GlobalFlags) { + const schemaId = flags.schemaId as string; + if (!schemaId) + failIfMissing("schema-id", "bl memory profile get --schema-id --user-id "); + + const userId = flags.userId as string; + if (!userId) failIfMissing("user-id", "bl memory profile get --schema-id --user-id "); + + const format = detectOutputFormat(config.output); + const params = new URLSearchParams({ user_id: userId }); + const url = `${userProfileEndpoint(config.baseUrl, schemaId)}?${params.toString()}`; + + if (config.dryRun) { + emitResult({ endpoint: url, method: "GET" }, format); + return; + } + + const response = await requestJson(config, { + url, + method: "GET", + }); + + if (config.quiet || format === "text") { + if (response.profile?.attributes) { + for (const attr of response.profile.attributes) { + emitBare(`${attr.name}: ${attr.value ?? "(empty)"}`); + } + } else { + emitBare("No profile data found."); + } + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/search.ts b/packages/cli/src/commands/memory/search.ts new file mode 100644 index 0000000..3116a06 --- /dev/null +++ b/packages/cli/src/commands/memory/search.ts @@ -0,0 +1,89 @@ +import { + defineCommand, + requestJson, + memorySearchEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type MemorySearchRequest, + type MemorySearchResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory search", + description: "Search memory nodes by query or messages", + usage: "bl memory search --user-id [--query ] [flags]", + options: [ + { flag: "--user-id ", description: "User ID (required)", required: true }, + { flag: "--query ", description: "Search query text" }, + { flag: "--messages ", description: "Messages JSON array for context-based search" }, + { + flag: "--top-k ", + description: "Number of results to return (default: 10)", + type: "number", + }, + { flag: "--memory-library-id ", description: "Memory library ID" }, + ], + examples: [ + 'bl memory search --user-id user1 --query "编程偏好"', + 'bl memory search --user-id user1 --messages \'[{"role":"user","content":"推荐一本书"}]\' --top-k 5', + ], + async run(config: Config, flags: GlobalFlags) { + const userId = flags.userId as string; + if (!userId) failIfMissing("user-id", "bl memory search --user-id "); + + const body: MemorySearchRequest = { user_id: userId }; + + if (flags.query) body.query = flags.query as string; + + if (flags.messages) { + try { + body.messages = JSON.parse(flags.messages as string); + } catch { + process.stderr.write("Error: --messages must be valid JSON array\n"); + process.exit(1); + } + } + + // API requires messages; if only query is given, wrap it as a user message + if (!body.messages && body.query) { + body.messages = [{ role: "user", content: body.query }]; + } + + if (!body.query && !body.messages) { + process.stderr.write("Error: at least one of --query or --messages is required\n"); + process.exit(1); + } + + if (flags.topK !== undefined) body.top_k = flags.topK as number; + if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string; + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ endpoint: memorySearchEndpoint(config.baseUrl), request: body }, format); + return; + } + + const url = memorySearchEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + if (config.quiet || format === "text") { + if (!response.memory_nodes || response.memory_nodes.length === 0) { + emitBare("No memory nodes found."); + } else { + for (const node of response.memory_nodes) { + emitBare(`[${node.memory_node_id}] ${node.content}`); + } + } + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/memory/update.ts b/packages/cli/src/commands/memory/update.ts new file mode 100644 index 0000000..645a8c7 --- /dev/null +++ b/packages/cli/src/commands/memory/update.ts @@ -0,0 +1,70 @@ +import { + defineCommand, + requestJson, + memoryNodeEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type MemoryNodeUpdateRequest, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "memory update", + description: "Update a memory node content", + usage: "bl memory update --node-id --user-id --content ", + options: [ + { flag: "--node-id ", description: "Memory node ID (required)", required: true }, + { flag: "--user-id ", description: "User ID (required)", required: true }, + { + flag: "--content ", + description: "New content for the memory node (required)", + required: true, + }, + { flag: "--memory-library-id ", description: "Memory library ID (non-default library)" }, + ], + examples: ['bl memory update --node-id node_xxx --user-id user1 --content "更新后的记忆内容"'], + async run(config: Config, flags: GlobalFlags) { + const nodeId = flags.nodeId as string; + if (!nodeId) + failIfMissing("node-id", "bl memory update --node-id --user-id --content "); + + const userId = flags.userId as string; + if (!userId) + failIfMissing("user-id", "bl memory update --node-id --user-id --content "); + + const content = flags.content as string; + if (!content) + failIfMissing("content", "bl memory update --node-id --user-id --content "); + + const body: MemoryNodeUpdateRequest = { + user_id: userId, + custom_content: content, + }; + if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string; + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult( + { endpoint: memoryNodeEndpoint(config.baseUrl, nodeId), method: "PATCH", request: body }, + format, + ); + return; + } + + const url = memoryNodeEndpoint(config.baseUrl, nodeId); + const response = await requestJson<{ request_id: string }>(config, { + url, + method: "PATCH", + body, + }); + + if (config.quiet || format === "text") { + emitBare(`Memory node ${nodeId} updated.`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/cli/src/commands/omni/chat.ts b/packages/cli/src/commands/omni/chat.ts new file mode 100644 index 0000000..169b831 --- /dev/null +++ b/packages/cli/src/commands/omni/chat.ts @@ -0,0 +1,322 @@ +import { writeFileSync } from "fs"; +import { + defineCommand, + request, + chatEndpoint, + parseSSE, + detectOutputFormat, + type Config, + type GlobalFlags, + type ChatMessage, + type ChatMessageContent, + type ChatRequest, + type StreamChunk, + isInteractive, + resolveFileUrl, +} from "bailian-cli-core"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult } from "../../output/output.ts"; +import { resolveOutputDir, resolveCredential } from "bailian-cli-core"; + +const OMNI_VOICES = ["Chelsie", "Cherry", "Ethan", "Serena", "Tina"]; + +/** + * Build a standard WAV file header for PCM 16-bit mono 24kHz audio. + */ +function buildWavHeader(dataLength: number): Buffer { + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + dataLength, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); // PCM chunk size + header.writeUInt16LE(1, 20); // format = PCM + header.writeUInt16LE(1, 22); // channels = 1 (mono) + header.writeUInt32LE(24000, 24); // sample rate + header.writeUInt32LE(48000, 28); // byte rate (24000 * 1 * 2) + header.writeUInt16LE(2, 32); // block align (channels * bitsPerSample / 8) + header.writeUInt16LE(16, 34); // bits per sample + header.write("data", 36); + header.writeUInt32LE(dataLength, 40); + return header; +} + +export default defineCommand({ + name: "omni", + description: "Multimodal chat with text + audio output (Qwen-Omni)", + apiDocs: "/model-studio/qwen-omni", + usage: "bl omni --message [flags]", + options: [ + { + flag: "--message ", + description: "Message text (repeatable, prefix role: to set role)", + required: true, + type: "array", + }, + { flag: "--model ", description: "Model ID (default: qwen3.5-omni-plus)" }, + { flag: "--system ", description: "System prompt" }, + { flag: "--image ", description: "Image URL or local file (repeatable)", type: "array" }, + { flag: "--audio ", description: "Audio URL or local file (repeatable)", type: "array" }, + { + flag: "--video ", + description: "Video file URL / local path, or comma-separated frame URLs", + type: "array", + }, + { + flag: "--voice ", + description: `Output voice (default: Cherry). Options: ${OMNI_VOICES.join(", ")}`, + }, + { flag: "--audio-format ", description: "Audio output format (default: wav)" }, + { flag: "--audio-out ", description: "Save audio to file (default: auto-generate)" }, + { flag: "--text-only", description: "Output text only, no audio generation" }, + { flag: "--max-tokens ", description: "Maximum tokens to generate", type: "number" }, + { flag: "--temperature ", description: "Sampling temperature (0.0, 2.0]", type: "number" }, + ], + examples: [ + 'bl omni --message "你好,你是谁?"', + 'bl omni --message "描述这张图片" --image ./photo.jpg', + 'bl omni --message "这段音频在说什么?" --audio https://example.com/audio.wav', + 'bl omni --message "总结这个视频" --video https://example.com/video.mp4', + 'bl omni --message "这个视频讲了什么" --video ./local-video.mp4 --text-only', + 'bl omni --message "用四川话回答:今天天气怎么样" --voice Serena', + 'bl omni --message "Hello" --text-only --output json', + 'bl omni --message "朗读这段话" --audio-out greeting.wav', + ], + async run(config: Config, flags: GlobalFlags) { + // --- Parse messages --- + let userMessages: string[] = []; + if (flags.message) { + userMessages = flags.message as string[]; + } + + if (userMessages.length === 0) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ message: "Enter your message:" }); + if (!hint) { + process.stderr.write("Omni chat cancelled.\n"); + process.exit(1); + } + userMessages = [hint]; + } else { + failIfMissing("message", "bl text omni --message "); + } + } + + const model = (flags.model as string) || config.defaultOmniModel || "qwen3.5-omni-plus"; + const voice = (flags.voice as string) || "Cherry"; + const audioFormat = (flags.audioFormat as string) || "wav"; + const textOnly = flags.textOnly === true; + const format = detectOutputFormat(config.output); + + // --- Build messages array --- + const allMessages: ChatMessage[] = []; + if (flags.system) { + allMessages.push({ role: "system", content: flags.system as string }); + } + + // Build multimodal content for user messages + const validRoles = new Set(["system", "user", "assistant"]); + for (const m of userMessages) { + const colonIdx = m.indexOf(":"); + const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : ""; + + if (validRoles.has(maybeRole)) { + const content = m.slice(colonIdx + 1); + if (maybeRole === "system") { + allMessages.push({ role: "system", content }); + } else { + allMessages.push({ role: maybeRole as "user" | "assistant", content }); + } + } else { + allMessages.push({ role: "user", content: m }); + } + } + + // Attach multimodal inputs to the last user message + const rawImageUrls = (flags.image as string[] | undefined) || []; + const rawAudioUrls = (flags.audio as string[] | undefined) || []; + const rawVideoUrls = (flags.video as string[] | undefined) || []; + + // Auto-upload local files + const imageUrls: string[] = []; + const audioUrls: string[] = []; + const videoUrls: string[] = []; + + const needsResolve = + rawImageUrls.length > 0 || rawAudioUrls.length > 0 || rawVideoUrls.length > 0; + if (needsResolve) { + const credential = await resolveCredential(config); + for (const u of rawImageUrls) { + const resolved = await resolveFileUrl(u, credential.token, model); + imageUrls.push(resolved); + } + for (const u of rawAudioUrls) { + const resolved = await resolveFileUrl(u, credential.token, model); + audioUrls.push(resolved); + } + for (const u of rawVideoUrls) { + // Detect: comma-separated = frame list, otherwise single video URL/file + if (u.includes(",")) { + // Legacy frame list mode + const frames = u + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + // Resolve each frame URL + for (const f of frames) { + const resolved = await resolveFileUrl(f, credential.token, model); + videoUrls.push(`frame:${resolved}`); + } + } else { + const resolved = await resolveFileUrl(u, credential.token, model); + videoUrls.push(resolved); + } + } + } + + if (imageUrls.length > 0 || audioUrls.length > 0 || videoUrls.length > 0) { + // Find last user message and convert to multimodal content array + for (let i = allMessages.length - 1; i >= 0; i--) { + if (allMessages[i].role === "user") { + const existingContent = allMessages[i].content; + const contentArray: ChatMessageContent[] = []; + + // Keep existing text + if (typeof existingContent === "string") { + contentArray.push({ type: "text", text: existingContent }); + } else if (Array.isArray(existingContent)) { + contentArray.push(...existingContent); + } + + // Add image URLs + for (const url of imageUrls) { + contentArray.push({ type: "image_url", image_url: { url } }); + } + + // Add audio URLs + for (const url of audioUrls) { + contentArray.push({ type: "audio_url", audio_url: { url } }); + } + + // Add video URLs: frame:xxx are frame list items, others are direct video URLs + const frameItems = videoUrls.filter((v) => v.startsWith("frame:")).map((v) => v.slice(6)); + const directVideoUrls = videoUrls.filter((v) => !v.startsWith("frame:")); + + if (frameItems.length > 0) { + contentArray.push({ type: "video", video: frameItems }); + } + for (const url of directVideoUrls) { + contentArray.push({ type: "video_url", video_url: { url } }); + } + + allMessages[i] = { role: "user", content: contentArray }; + break; + } + } + } + + // --- Build request body --- + const body: ChatRequest = { + model, + messages: allMessages, + stream: true, + stream_options: { include_usage: true }, + }; + + if (!textOnly) { + body.modalities = ["text", "audio"]; + body.audio = { voice, format: audioFormat }; + } + + if (flags.maxTokens !== undefined) body.max_tokens = flags.maxTokens as number; + if (flags.temperature !== undefined) body.temperature = flags.temperature as number; + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + if (!config.quiet) { + const modeLabel = textOnly ? "text-only" : `text+audio, voice: ${voice}`; + process.stderr.write(`[Model: ${model}] [${modeLabel}]\n`); + } + + // --- Stream request --- + const url = chatEndpoint(config.baseUrl); + const res = await request(config, { + url, + method: "POST", + body, + stream: true, + }); + + let textContent = ""; + let audioBase64 = ""; + const isTTY = process.stdout.isTTY; + const resultOut = process.stdout; + + for await (const event of parseSSE(res)) { + if (event.data === "[DONE]") break; + try { + const parsed = JSON.parse(event.data) as StreamChunk; + + for (const choice of parsed.choices) { + const delta = choice.delta; + + // Collect text content + if (delta.content) { + textContent += delta.content; + if (isTTY) { + resultOut.write(delta.content); + } + } + + // Collect audio data + if (delta.audio?.data) { + audioBase64 += delta.audio.data; + } + } + } catch { + // Skip unparseable chunks + } + } + + if (isTTY && textContent) { + resultOut.write("\n"); + } + + // --- Save audio --- + let audioSaved: string | undefined; + if (audioBase64 && !textOnly) { + const pcmBuffer = Buffer.from(audioBase64, "base64"); + const wavHeader = buildWavHeader(pcmBuffer.length); + const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]); + + let destPath = flags.audioOut as string | undefined; + if (!destPath) { + // eslint-disable-next-line @typescript-eslint/unbound-method + const { join } = await import("path"); + const destDir = resolveOutputDir(config, { subDir: "omni" }); + const timestamp = Date.now(); + destPath = join(destDir, `omni_${timestamp}.wav`); + } + + writeFileSync(destPath, wavBuffer); + audioSaved = destPath; + + if (!config.quiet) { + process.stderr.write(`Audio saved: ${destPath}\n`); + } + } + + // --- Emit structured result --- + if (!isTTY || format === "json") { + const result: Record = { content: textContent }; + if (audioSaved) { + result.audio_saved = audioSaved; + result.voice = voice; + } + emitResult(result, format); + } + }, +}); diff --git a/packages/cli/src/commands/pipeline/load-file.ts b/packages/cli/src/commands/pipeline/load-file.ts new file mode 100644 index 0000000..a6a61e2 --- /dev/null +++ b/packages/cli/src/commands/pipeline/load-file.ts @@ -0,0 +1,24 @@ +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import type { PipelineDefinition } from "../../pipeline/types.ts"; + +export async function loadPipelineFile(filePath: string): Promise { + const raw = await readFile(filePath, "utf-8").catch((err: Error) => { + process.stderr.write(`Error: cannot read pipeline file: ${err.message}\n`); + process.exit(2); + }); + const ext = extname(filePath).toLowerCase(); + let parsed: unknown; + if (ext === ".yaml" || ext === ".yml") { + const { parse: parseYaml } = await import("yaml"); + parsed = parseYaml(raw); + } else { + try { + parsed = JSON.parse(raw); + } catch { + const { parse: parseYaml } = await import("yaml"); + parsed = parseYaml(raw); + } + } + return parsed as PipelineDefinition; +} diff --git a/packages/cli/src/commands/pipeline/run.ts b/packages/cli/src/commands/pipeline/run.ts new file mode 100644 index 0000000..ad6131d --- /dev/null +++ b/packages/cli/src/commands/pipeline/run.ts @@ -0,0 +1,149 @@ +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core"; +import { emitResult } from "../../output/output.ts"; +import { initPipelineSteps } from "../../pipeline/init.ts"; +import { executePipeline, streamPipelineEvents } from "../../pipeline/executor.ts"; +import type { PipelineLifecycleEvent } from "../../pipeline/types.ts"; +import { loadPipelineFile } from "./load-file.ts"; + +export default defineCommand({ + name: "pipeline run", + description: "Run a pipeline workflow definition", + usage: "bl pipeline run [flags]", + options: [ + { flag: "--input ", description: "Runtime input as inline JSON" }, + { flag: "--input-file ", description: "Runtime input from a JSON file" }, + { + flag: "--concurrency ", + description: "Max parallel steps (default: 1)", + type: "number", + }, + { flag: "--events ", description: "Emit lifecycle events: jsonl" }, + { + flag: "--timeout ", + description: "Default step timeout in seconds", + type: "number", + }, + ], + examples: [ + 'bl pipeline run workflow.yaml --input \'{"brief":"hello"}\'', + "bl pipeline run workflow.json --input-file inputs.json --concurrency 3", + "bl pipeline run workflow.yaml --dry-run", + "bl pipeline run workflow.json --events jsonl", + "bl pipeline run workflow.yaml --output json", + ], + async run(config: Config, flags: GlobalFlags) { + const file = ((flags._positional as string[] | undefined) ?? [])[0] as string | undefined; + if (!file) { + process.stderr.write("Error: pipeline file is required\nUsage: bl pipeline run \n"); + process.exit(2); + } + + initPipelineSteps(); + + const eventsFormat = flags.events as string | undefined; + if (eventsFormat !== undefined && eventsFormat !== "jsonl") { + process.stderr.write( + `Error: unsupported --events format: ${eventsFormat}. Supported: jsonl\n`, + ); + process.exit(2); + } + + const filePath = resolve(file); + const pipeline = await loadPipelineFile(filePath); + const runtimeInput = await resolveRuntimeInput(flags); + const basePath = dirname(filePath); + + if (eventsFormat === "jsonl") { + for await (const event of streamPipelineEvents(pipeline, runtimeInput, { + concurrency: flags.concurrency as number | undefined, + basePath, + dryRun: flags.dryRun, + timeoutSeconds: flags.timeout as number | undefined, + })) { + process.stdout.write(JSON.stringify(event) + "\n"); + } + return; + } + + const report = await executePipeline(pipeline, runtimeInput, { + concurrency: flags.concurrency as number | undefined, + basePath, + dryRun: flags.dryRun, + timeoutSeconds: flags.timeout as number | undefined, + onEvent: flags.verbose ? logEvent : undefined, + }); + + if (config.output === "json") { + emitResult(report, "json"); + } else { + printTextReport(report); + } + + if (report.status === "failed") process.exitCode = 1; + }, +}); + +async function resolveRuntimeInput(flags: GlobalFlags): Promise> { + const inputJson = flags.input as string | undefined; + const inputFile = flags.inputFile as string | undefined; + if (inputJson && inputFile) { + process.stderr.write("Error: use --input or --input-file, not both\n"); + process.exit(2); + } + if (inputJson) return JSON.parse(inputJson) as Record; + if (inputFile) { + const raw = await readFile(resolve(inputFile), "utf-8"); + return JSON.parse(raw) as Record; + } + return {}; +} + +function logEvent(event: PipelineLifecycleEvent): void { + const prefix = `[${event.type}]`; + if ("stepCount" in event) { + process.stderr.write(`${prefix} ${event.stepCount} step${event.stepCount === 1 ? "" : "s"}\n`); + return; + } + if ("step" in event && event.step) { + process.stderr.write(`${prefix} ${formatStep(event.step)}\n`); + } else { + process.stderr.write(`${prefix}\n`); + } +} + +function formatStep(step: { id: string; type: string; index?: number; total?: number }): string { + const progress = + step.index !== undefined && step.total !== undefined ? `${step.index}/${step.total} ` : ""; + return `${progress}${step.id} (${step.type})`; +} + +function printTextReport(report: { + status: string; + steps: Array<{ + id: string; + type: string; + status: string; + skipReason?: string; + error?: { message: string }; + }>; +}): void { + const statusIcon = report.status === "succeeded" ? "+" : report.status === "failed" ? "x" : "~"; + process.stdout.write(`\nPipeline ${report.status} [${statusIcon}]\n\n`); + for (const step of report.steps) { + const icon = + step.status === "succeeded" + ? "+" + : step.status === "failed" + ? "x" + : step.status === "skipped" + ? "-" + : "~"; + let line = ` [${icon}] ${step.id} (${step.type}) — ${step.status}`; + if (step.skipReason) line += ` (${step.skipReason})`; + if (step.error) line += `: ${step.error.message}`; + process.stdout.write(line + "\n"); + } + process.stdout.write("\n"); +} diff --git a/packages/cli/src/commands/pipeline/validate.ts b/packages/cli/src/commands/pipeline/validate.ts new file mode 100644 index 0000000..4c7656f --- /dev/null +++ b/packages/cli/src/commands/pipeline/validate.ts @@ -0,0 +1,52 @@ +import { resolve } from "node:path"; +import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core"; +import { emitResult } from "../../output/output.ts"; +import { initPipelineSteps } from "../../pipeline/init.ts"; +import { collectPipelineIssues, collectPipelineHints } from "../../pipeline/validation.ts"; +import { loadPipelineFile } from "./load-file.ts"; + +export default defineCommand({ + name: "pipeline validate", + description: "Validate a pipeline definition without executing", + usage: "bl pipeline validate ", + options: [], + examples: [ + "bl pipeline validate workflow.yaml", + "bl pipeline validate workflow.json --output json", + ], + async run(config: Config, flags: GlobalFlags) { + const file = ((flags._positional as string[] | undefined) ?? [])[0] as string | undefined; + if (!file) { + process.stderr.write( + "Error: pipeline file is required\nUsage: bl pipeline validate \n", + ); + process.exit(2); + } + + initPipelineSteps(); + + const filePath = resolve(file); + const pipeline = await loadPipelineFile(filePath); + const issues = collectPipelineIssues(pipeline); + const hints = issues.length === 0 ? collectPipelineHints(pipeline) : []; + + if (config.output === "json") { + emitResult( + { valid: issues.length === 0, issues, ...(hints.length > 0 ? { hints } : {}) }, + "json", + ); + if (issues.length > 0) process.exitCode = 1; + } else if (issues.length === 0) { + process.stdout.write("Pipeline definition is valid.\n"); + for (const hint of hints) { + process.stderr.write(` hint: ${hint}\n`); + } + } else { + process.stderr.write("Pipeline validation failed:\n"); + for (const issue of issues) { + process.stderr.write(` - ${issue}\n`); + } + process.exitCode = 1; + } + }, +}); diff --git a/packages/cli/src/commands/search/web.ts b/packages/cli/src/commands/search/web.ts new file mode 100644 index 0000000..308bb5f --- /dev/null +++ b/packages/cli/src/commands/search/web.ts @@ -0,0 +1,138 @@ +import { + defineCommand, + mcpWebSearchEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + isInteractive, + McpClient, +} from "bailian-cli-core"; +import { createSpinner } from "../../output/progress.ts"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult } from "../../output/output.ts"; + +export default defineCommand({ + name: "search web", + description: "Search the web using DashScope MCP WebSearch service", + usage: "bl search web --query [flags]", + options: [ + { flag: "--query ", description: "Search query text", required: true }, + { flag: "--count ", description: "Number of search results (default: 10)", type: "number" }, + { flag: "--list-tools", description: "List available MCP tools and exit" }, + ], + examples: [ + 'bl search web --query "阿里云百炼最新功能"', + 'bl search web --query "TypeScript 5.9 new features" --count 5', + 'bl search web --query "今日新闻"', + "bl search web --list-tools", + ], + async run(config: Config, flags: GlobalFlags) { + const mcpUrl = mcpWebSearchEndpoint(config.baseUrl); + const format = detectOutputFormat(config.output); + + // --- List tools mode --- + if (flags.listTools) { + if (config.dryRun) { + emitResult({ endpoint: mcpUrl, action: "tools/list" }, format); + return; + } + + const client = new McpClient(config, mcpUrl); + await client.initialize(); + const tools = await client.listTools(); + + emitResult({ tools }, format); + return; + } + + // --- Search mode --- + let query = flags.query as string | undefined; + if (!query) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ message: "Enter your search query:" }); + if (!hint) { + process.stderr.write("Search cancelled.\n"); + process.exit(1); + } + query = hint; + } else { + failIfMissing("query", "bl search web --query "); + } + } + + if (config.dryRun) { + emitResult( + { + endpoint: mcpUrl, + action: "tools/call", + tool: "bailian_web_search", + arguments: { + query: query!, + count: (flags.count as number) || undefined, + }, + }, + format, + ); + return; + } + + // Initialize MCP client + const client = new McpClient(config, mcpUrl); + const spinner = createSpinner("Initializing search..."); + + if (!config.quiet) spinner.start(); + + try { + await client.initialize(); + + if (!config.quiet) spinner.update("Searching..."); + + // Build tool arguments + const toolArgs: Record = { query: query! }; + if (flags.count) toolArgs.count = flags.count as number; + + // Call the search tool + const result = await client.callTool("bailian_web_search", toolArgs); + + if (!config.quiet) spinner.stop("Done."); + + // Handle error response + if (result.isError) { + const errText = result.content.map((c) => c.text || "").join("\n"); + process.stderr.write(`Search error: ${errText}\n`); + process.exit(1); + } + + // Output results — always structured to stdout + if (format === "json") { + emitResult(result, format); + } else { + // Text mode: try to extract pages for human-friendly display + for (const item of result.content) { + if (item.type === "text" && item.text) { + try { + const data = JSON.parse(item.text) as { + pages?: Array<{ + title?: string; + url?: string; + snippet?: string; + hostname?: string; + }>; + }; + if (data.pages && Array.isArray(data.pages)) { + emitResult({ pages: data.pages, total: data.pages.length }, format); + } else { + emitResult(data, format); + } + } catch { + emitResult({ text: item.text }, format); + } + } + } + } + } catch (error) { + spinner.stop("Failed."); + throw error; + } + }, +}); diff --git a/packages/cli/src/commands/speech/recognize.ts b/packages/cli/src/commands/speech/recognize.ts new file mode 100644 index 0000000..3fdf21c --- /dev/null +++ b/packages/cli/src/commands/speech/recognize.ts @@ -0,0 +1,249 @@ +import { writeFileSync } from "fs"; +import { + BailianError, + defineCommand, + ExitCode, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeASRRequest, + type DashScopeASRTaskResult, + type DashScopeAsyncResponse, + resolveFileUrl, + resolveCredential, + trackingHeaders, + stripUndefined, + taskEndpoint, + requestJson, + type OutputFormat, + speechRecognizeEndpoint, +} from "bailian-cli-core"; +import { poll } from "../../utils/polling.ts"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "speech recognize", + description: "Recognize speech from audio files (FunAudio-ASR)", + apiDocs: "/developer-reference/recording-file-recognition", + usage: "bl speech recognize --url [flags]", + options: [ + { + flag: "--url ", + description: "Audio file URL or local file path (repeatable, max 100)", + required: true, + type: "array", + }, + { flag: "--model ", description: "Model ID (default: fun-asr)" }, + { flag: "--language ", description: "Language hint (e.g. zh, en, ja)" }, + { flag: "--diarization", description: "Enable automatic speaker diarization" }, + { + flag: "--speaker-count ", + description: "Expected number of speakers (requires --diarization)", + type: "number", + }, + { flag: "--vocabulary-id ", description: "Hot-word vocabulary ID for improved accuracy" }, + { flag: "--channel-id ", description: "Audio channel ID (default: 0)", type: "number" }, + { flag: "--out ", description: "Save full transcription result to JSON file" }, + { flag: "--no-wait", description: "Return task ID immediately without polling" }, + { + flag: "--poll-interval ", + description: "Polling interval in seconds (default: 2)", + type: "number", + }, + ], + examples: [ + "bl speech recognize --url https://example.com/audio.mp3", + "bl speech recognize --url https://example.com/a.mp3 --url https://example.com/b.mp3", + "bl speech recognize --url https://example.com/meeting.wav --diarization --speaker-count 3", + "bl speech recognize --url https://example.com/audio.mp3 --language zh", + "bl speech recognize --url https://example.com/audio.mp3 --vocabulary-id vocab-abc123", + "bl speech recognize --url https://example.com/audio.mp3 --out result.json", + "bl speech recognize --url https://example.com/audio.mp3 --no-wait --quiet", + ], + async run(config: Config, flags: GlobalFlags) { + // Normalize --url to string[] (supports both single and repeated flags) + let rawUrls: string[] = []; + if (Array.isArray(flags.url)) { + rawUrls = flags.url as string[]; + } else if (typeof flags.url === "string") { + rawUrls = [flags.url]; + } + if (rawUrls.length === 0) { + failIfMissing("url", "bl speech recognize --url "); + } + + // Strict validation: --speaker-count requires --diarization + const speakerCount = flags.speakerCount as number | undefined; + const diarization = flags.diarization === true; + if (speakerCount !== undefined && !diarization) { + throw new BailianError( + "--speaker-count requires --diarization to be enabled.\nHint: Add --diarization flag to enable speaker separation.", + ExitCode.USAGE, + ); + } + + const model = (flags.model as string) || "fun-asr"; + const format = detectOutputFormat(config.output); + + // Auto-upload local files in parallel + const credential = await resolveCredential(config); + const resolvedUrls = await Promise.all( + rawUrls.map((u) => resolveFileUrl(u, credential.token, model)), + ); + const channelId = flags.channelId as number | undefined; + const language = flags.language as string | undefined; + const vocabularyId = flags.vocabularyId as string | undefined; + + const body: DashScopeASRRequest = { + model, + input: { + file_urls: resolvedUrls, + }, + parameters: { + channel_id: channelId !== undefined ? [channelId] : [0], + language_hints: language ? [language] : undefined, + diarization_enabled: diarization ? true : undefined, + speaker_count: speakerCount, + vocabulary_id: vocabularyId, + }, + }; + + // Remove undefined parameter fields + stripUndefined(body.parameters as Record); + + if (config.dryRun) { + emitResult({ request: body, mode: "async" }, format); + return; + } + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}] [Mode: async] [Files: ${resolvedUrls.length}]\n`); + } + + const url = speechRecognizeEndpoint(config.baseUrl); + await handleAsyncMode(config, url, body, flags, format, resolvedUrls.length); + }, +}); + +async function handleAsyncMode( + config: Config, + url: string, + body: DashScopeASRRequest, + flags: GlobalFlags, + format: OutputFormat, + fileCount: number, +): Promise { + // Submit async task (always required for fun-asr) + const response = await requestJson(config, { + url, + method: "POST", + body, + async: true, + }); + + const taskId = response.output.task_id; + + // --no-wait: return task ID immediately + if (flags.noWait || config.async) { + emitResult({ task_id: taskId }, format); + return; + } + + // Poll until completion + const pollInterval = (flags.pollInterval as number) ?? 2; + const pollUrl = taskEndpoint(config.baseUrl, taskId); + + const result = await poll(config, { + url: pollUrl, + intervalSec: pollInterval, + timeoutSec: config.timeout, + isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED", + isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED", + getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status, + getErrorMessage: (d) => { + const o = (d as DashScopeASRTaskResult).output; + return (o as unknown as Record).message as string | undefined; + }, + }); + + const results = result.output.results ?? []; + + if (results.length === 0) { + emitResult({ task_id: taskId, status: result.output.task_status }, format); + return; + } + + // Collect all transcription data for --out + const allTransData: Record[] = []; + + for (let i = 0; i < results.length; i++) { + const subResult = results[i]!; + const isMulti = fileCount > 1; + + if (isMulti) { + process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`); + } + + if (subResult.subtask_status === "FAILED") { + const errMsg = subResult.message ?? subResult.code ?? "unknown error"; + process.stdout.write(`[FAILED] ${subResult.file_url ?? ""} — ${errMsg}\n`); + if (isMulti) process.stdout.write("\n"); + continue; + } + + if (!subResult.transcription_url) { + if (isMulti) process.stdout.write("\n"); + continue; + } + + // Fetch transcription JSON + const transRes = await fetch(subResult.transcription_url, { + headers: trackingHeaders(), + }); + if (!transRes.ok) { + throw new BailianError( + `Failed to download transcription: HTTP ${transRes.status}`, + ExitCode.GENERAL, + ); + } + const transData = (await transRes.json()) as Record; + allTransData.push(transData); + + // Extract and output text from transcripts[] + const transcripts = transData.transcripts as + | Array<{ + text?: string; + sentences?: Array<{ text: string; speaker_id?: number }>; + }> + | undefined; + + if (transcripts && transcripts.length > 0) { + for (const transcript of transcripts) { + if (transcript.sentences && transcript.sentences.length > 0) { + for (const sentence of transcript.sentences) { + const speakerTag = + sentence.speaker_id !== undefined ? ` [Speaker ${sentence.speaker_id}]` : ""; + process.stdout.write(`${sentence.text}${speakerTag}\n`); + } + } else if (transcript.text) { + process.stdout.write(transcript.text + "\n"); + } + } + } else { + emitBare(JSON.stringify(transData)); + } + + if (isMulti) process.stdout.write("\n"); + } + + // Save to --out file + if (flags.out) { + const outPath = flags.out as string; + const outData = allTransData.length === 1 ? allTransData[0] : allTransData; + writeFileSync(outPath, JSON.stringify(outData, null, 2) + "\n"); + if (!config.quiet) { + process.stderr.write(`Full result saved to: ${outPath}\n`); + } + } +} diff --git a/packages/cli/src/commands/speech/synthesize.ts b/packages/cli/src/commands/speech/synthesize.ts new file mode 100644 index 0000000..e20eda6 --- /dev/null +++ b/packages/cli/src/commands/speech/synthesize.ts @@ -0,0 +1,474 @@ +import { readFileSync, createWriteStream } from "fs"; +import { + BailianError, + defineCommand, + ExitCode, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeTTSRequest, + type DashScopeTTSResponse, + type DashScopeTTSStreamChunk, + stripUndefined, + requestJson, + type OutputFormat, + speechSynthesizeEndpoint, + parseSSE, + isInteractive, + resolveOutputDir, + request, + DOCS_HOSTS, +} from "bailian-cli-core"; + +const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`; +import { downloadFile } from "../../utils/download.ts"; +import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts"; +import { promptText, promptSelect, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +interface VoiceEntry { + voice: string; + name: string; + desc: string; + lang: string; +} + +// cosyvoice-v3-flash system voices +const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [ + // 社交陪伴 + { voice: "longanyang", name: "龙安洋", desc: "阳光大男孩", lang: "中文/英文" }, + { voice: "longanhuan", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" }, + { voice: "longantai_v3", name: "龙安台", desc: "嗲甜台湾女", lang: "中文/英文" }, + { voice: "longhua_v3", name: "龙华", desc: "元气甜美女", lang: "中文/英文" }, + { voice: "longcheng_v3", name: "龙橙", desc: "智慧青年男", lang: "中文/英文" }, + { voice: "longze_v3", name: "龙泽", desc: "温暖元气男", lang: "中文/英文" }, + { voice: "longzhe_v3", name: "龙哲", desc: "呆板大暖男", lang: "中文/英文" }, + { voice: "longyan_v3", name: "龙颜", desc: "温暖春风女", lang: "中文/英文" }, + { voice: "longxing_v3", name: "龙星", desc: "温婉邻家女", lang: "中文/英文" }, + { voice: "longtian_v3", name: "龙天", desc: "磁性理智男", lang: "中文/英文" }, + { voice: "longwan_v3", name: "龙婉", desc: "细腻柔声女", lang: "中文/英文" }, + { voice: "longqiang_v3", name: "龙嫱", desc: "浪漫风情女", lang: "中文/英文" }, + { voice: "longfeifei_v3", name: "龙菲菲", desc: "甜美娇气女", lang: "中文/英文" }, + { voice: "longhao_v3", name: "龙浩", desc: "多情忧郁男", lang: "中文/英文" }, + { voice: "longanrou_v3", name: "龙安柔", desc: "温柔娴静女", lang: "中文/英文" }, + // 语音助手 + { voice: "longxiaochun_v3", name: "龙小淳", desc: "知性积极女", lang: "中文/英文" }, + { voice: "longxiaoxia_v3", name: "龙小夏", desc: "沉稳权威女", lang: "中文/英文" }, + { voice: "longyumi_v3", name: "YUMI", desc: "正经青年女", lang: "中文/英文" }, + { voice: "longanyun_v3", name: "龙安昀", desc: "居家暖男", lang: "中文/英文" }, + { voice: "longanwen_v3", name: "龙安温", desc: "优雅知性女", lang: "中文/英文" }, + { voice: "longanli_v3", name: "龙安莉", desc: "利落从容女", lang: "中文/英文" }, + { voice: "longanlang_v3", name: "龙安朗", desc: "清爽利落男", lang: "中文/英文" }, + { voice: "longyingmu_v3", name: "龙应沐", desc: "优雅知性女", lang: "中文/英文" }, + // 客服 + { voice: "longyingxun_v3", name: "龙应询", desc: "年轻青涩男", lang: "中文/英文" }, + { voice: "longyingjing_v3", name: "龙应静", desc: "低调冷静女", lang: "中文/英文" }, + { voice: "longyingling_v3", name: "龙应聆", desc: "温和共情女", lang: "中文/英文" }, + { voice: "longyingtao_v3", name: "龙应桃", desc: "温柔淡定女", lang: "中文/英文" }, + // 电话销售 + { voice: "longyingxiao_v3", name: "龙应笑", desc: "清甜推销女", lang: "中文/英文" }, + // 诗词朗诵 + { voice: "longfei_v3", name: "龙飞", desc: "热血磁性男", lang: "中文/英文" }, + // 童声 + { voice: "longhuhu_v3", name: "龙呼呼", desc: "天真烂漫女童", lang: "中文/英文" }, + { voice: "longpaopao_v3", name: "龙泡泡", desc: "飞天泡泡音", lang: "中文/英文" }, + { voice: "longjielidou_v3", name: "龙杰力豆", desc: "阳光顽皮男", lang: "中文/英文" }, + { voice: "longxian_v3", name: "龙仙", desc: "豪放可爱女", lang: "中文/英文" }, + { voice: "longling_v3", name: "龙铃", desc: "稚气呆板女", lang: "中文/英文" }, + { voice: "longshanshan_v3", name: "龙闪闪", desc: "戏剧化童声", lang: "中文/英文" }, + { voice: "longniuniu_v3", name: "龙牛牛", desc: "阳光男童声", lang: "中文/英文" }, + // 方言 + { voice: "longjiaxin_v3", name: "龙嘉欣", desc: "优雅粤语女", lang: "粤语/英文" }, + { voice: "longjiayi_v3", name: "龙嘉怡", desc: "知性粤语女", lang: "粤语/英文" }, + { voice: "longanyue_v3", name: "龙安粤", desc: "欢脱粤语男", lang: "粤语/英文" }, + { voice: "longlaotie_v3", name: "龙老铁", desc: "东北直率男", lang: "东北话/英文" }, + { voice: "longshange_v3", name: "龙陕哥", desc: "原味陕北男", lang: "陕西话/英文" }, + { voice: "longanmin_v3", name: "龙安闽", desc: "清纯萝莉女", lang: "闽南话/英文" }, + // 出海营销(仅北京地域) + { voice: "loongabby_v3", name: "loongabby", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongandy_v3", name: "loongandy", desc: "美式英文男", lang: "美式英语" }, + { voice: "loongannie_v3", name: "loongannie", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongava_v3", name: "loongava", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongbeth_v3", name: "loongbeth", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongbetty_v3", name: "loongbetty", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongcally_v3", name: "loongcally", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongcindy_v3", name: "loongcindy", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongdavid_v3", name: "loongdavid", desc: "美式英文男", lang: "美式英语" }, + { voice: "loongdonna_v3", name: "loongdonna", desc: "美式英文女", lang: "美式英语" }, + { voice: "loongemily_v3", name: "loongemily", desc: "英式英文女", lang: "英式英语" }, + { voice: "loongeric_v3", name: "loongeric", desc: "英式英文男", lang: "英式英语" }, + { voice: "loongluna_v3", name: "loongluna", desc: "英式英文女", lang: "英式英语" }, + { voice: "loongluca_v3", name: "loongluca", desc: "英式英文男", lang: "英式英语" }, + { voice: "loongriko_v3", name: "Riko", desc: "二次元霓虹女", lang: "日语" }, + { voice: "loongtomoka_v3", name: "loongtomoka", desc: "日语女", lang: "日语" }, + { voice: "loongtomoya_v3", name: "loongtomoya", desc: "日语男", lang: "日语" }, + { voice: "loongyuuna_v3", name: "Yuuna", desc: "日语女", lang: "日语" }, + { voice: "loongyuuma_v3", name: "Yuuma", desc: "日语男", lang: "日语" }, + { voice: "loongkyong_v3", name: "loongkyong", desc: "韩语女", lang: "韩语" }, + { voice: "loongjihun_v3", name: "Jihun", desc: "韩语男", lang: "韩语" }, + { voice: "loongindah_v3", name: "loongindah", desc: "印尼女", lang: "印尼语" }, +]; + +const MODEL_VOICES: Record = { + "cosyvoice-v3-flash": COSYVOICE_V3_FLASH_VOICES, + "cosyvoice-v3-plus": COSYVOICE_V3_FLASH_VOICES, + "cosyvoice-v3.5-flash": [], + "cosyvoice-v3.5-plus": [], + "cosyvoice-v2": [], +}; + +function printVoiceList(model: string): void { + const voices = MODEL_VOICES[model]; + if (!voices) { + process.stdout.write(`No built-in voice list available for model: ${model}\n`); + return; + } + if (voices.length === 0) { + process.stdout.write(`Model ${model} has no system voices.\n`); + process.stdout.write("Use clone or design voices created via the CosyVoice API.\n"); + process.stdout.write(`See: ${COSYVOICE_CLONE_DESIGN_DOC}\n`); + return; + } + const col = (s: string, w: number) => s.padEnd(w); + process.stdout.write(`\nSystem voices for ${model}:\n`); + process.stdout.write( + `${col("VOICE ID", 26)} ${col("NAME", 10)} ${col("DESCRIPTION", 16)} LANGUAGE\n`, + ); + process.stdout.write(`${"-".repeat(26)} ${"-".repeat(10)} ${"-".repeat(16)} ${"-".repeat(12)}\n`); + for (const v of voices) { + process.stdout.write(`${col(v.voice, 26)} ${col(v.name, 10)} ${col(v.desc, 16)} ${v.lang}\n`); + } + process.stdout.write(`\nTotal: ${voices.length} voices\n`); +} + +export default defineCommand({ + name: "speech synthesize", + description: "Synthesize speech from text (CosyVoice TTS)", + apiDocs: "/developer-reference/cosyvoice", + usage: "bl speech synthesize --text [flags]", + options: [ + { flag: "--text ", description: "Text to synthesize into speech", required: true }, + { flag: "--text-file ", description: "Read text from a file instead of --text" }, + { + flag: "--model ", + description: + "Model ID (default: cosyvoice-v3-flash). System voices available for cosyvoice-v3-flash", + }, + { + flag: "--voice ", + description: + "Voice ID. Use --list-voices to see system voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID", + }, + { + flag: "--list-voices", + description: "List available system voices for the selected model and exit", + }, + { flag: "--format ", description: "Audio format: mp3, pcm, wav, opus (default: mp3)" }, + { flag: "--sample-rate ", description: "Audio sample rate in Hz (e.g. 24000)" }, + { flag: "--volume ", description: "Volume 0-100 (default: 50)" }, + { flag: "--rate ", description: "Speech rate 0.5-2.0 (default: 1.0)" }, + { flag: "--pitch ", description: "Pitch multiplier 0.5-2.0 (default: 1.0)" }, + { flag: "--seed ", description: "Random seed 0-65535 for reproducible synthesis" }, + { flag: "--language ", description: "Language hint (e.g. zh, en, ja, ko, fr, de)" }, + { + flag: "--instruction ", + description: 'Natural language instruction to control speech style (e.g. "请用温柔的语调")', + }, + { flag: "--enable-ssml", description: "Enable SSML markup parsing in input text" }, + { + flag: "--out ", + description: "Save audio to file (default: auto-generate in temp dir)", + }, + { flag: "--stream", description: "Stream raw PCM audio to stdout (pipe to player)" }, + ], + examples: [ + "bl speech synthesize --list-voices --model cosyvoice-v3-flash", + 'bl speech synthesize --text "你好,我是千问" --voice ', + 'bl speech synthesize --text "Hello world" --voice --language en', + "bl speech synthesize --text-file script.txt --out speech.wav --voice ", + 'bl speech synthesize --text "今天天气真好" --voice --instruction "请用温柔的语调说话"', + 'bl speech synthesize --text "Hello" --voice --format wav --sample-rate 24000', + "# Stream to audio player (macOS)", + 'bl speech synthesize --text "你好" --voice --stream | afplay -', + "# Pipe to ffplay", + 'bl speech synthesize --text "Hello" --voice --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -', + ], + async run(config: Config, flags: GlobalFlags) { + const model = (flags.model as string) || config.defaultSpeechModel || "cosyvoice-v3-flash"; + + // --list-voices: print voice list for the model and exit + if (flags.listVoices) { + printVoiceList(model); + return; + } + + let text = flags.text as string | undefined; + + // --text-file takes precedence if provided and --text is empty + if (!text && flags.textFile) { + const filePath = flags.textFile as string; + try { + text = readFileSync(filePath, "utf-8").trim(); + } catch { + throw new BailianError(`Cannot read text file: ${filePath}`, ExitCode.USAGE); + } + } + + if (!text) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ message: "Enter text to synthesize:" }); + if (!hint) { + process.stderr.write("Speech synthesis cancelled.\n"); + process.exit(1); + } + text = hint; + } else { + failIfMissing("text", "bl speech synthesize --text "); + } + } + + let voice = (flags.voice as string) || undefined; + + // In interactive mode, prompt the user to select / enter a voice + if (!voice) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const modelVoices = MODEL_VOICES[model]; + if (modelVoices && modelVoices.length > 0) { + const DEFAULT_VOICE = modelVoices[0]!.voice; + const choices = modelVoices.map((v) => ({ + value: v.voice, + label: `${v.name} (${v.voice})`, + hint: `${v.desc} · ${v.lang}`, + })); + const selected = await promptSelect({ + message: `Select a voice (default: ${DEFAULT_VOICE}):`, + choices, + defaultValue: DEFAULT_VOICE, + }); + if (!selected) { + process.stderr.write("Speech synthesis cancelled.\n"); + process.exit(1); + } + voice = selected; + } else { + // No built-in list (v3.5 / v2): prompt for clone/design voice ID + const entered = await promptText({ message: "Enter voice ID (clone/design voice):" }); + if (!entered) { + process.stderr.write("Speech synthesis cancelled.\n"); + process.exit(1); + } + voice = entered; + } + } else { + // Non-interactive mode: keep original error + const modelVoices = MODEL_VOICES[model]; + if (modelVoices && modelVoices.length > 0) { + throw new BailianError( + `--voice is required.\nRun the following to see available voices:\n bl speech synthesize --list-voices --model ${model}`, + ExitCode.USAGE, + ); + } else { + throw new BailianError( + `--voice is required. Model ${model} has no built-in system voices.\nCreate a clone or design voice first, then pass its ID via --voice .\nSee: ${COSYVOICE_CLONE_DESIGN_DOC}`, + ExitCode.USAGE, + ); + } + } + } + + const language = (flags.language as string) || undefined; + const instruction = (flags.instruction as string) || undefined; + const audioFormat = (flags.format as "mp3" | "pcm" | "wav" | "opus") || undefined; + const sampleRate = flags.sampleRate !== undefined ? Number(flags.sampleRate) : undefined; + const volume = flags.volume !== undefined ? Number(flags.volume) : undefined; + const rate = flags.rate !== undefined ? Number(flags.rate) : undefined; + const pitch = flags.pitch !== undefined ? Number(flags.pitch) : undefined; + const seed = flags.seed !== undefined ? Number(flags.seed) : undefined; + const enableSsml = flags.enableSsml === true ? true : undefined; + const useStream = flags.stream === true; + + const format = detectOutputFormat(config.output); + + const body: DashScopeTTSRequest = { + model, + input: { + text: text!, + voice, + format: audioFormat, + sample_rate: sampleRate, + volume, + rate, + pitch, + seed, + language_hints: language ? [language] : undefined, + instruction, + enable_ssml: enableSsml, + }, + }; + + // Remove undefined fields from input + stripUndefined(body.input as Record); + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`); + } + + const url = speechSynthesizeEndpoint(config.baseUrl); + + if (useStream) { + await handleStreamMode(config, url, body, flags, format); + } else { + await handleNonStreamMode(config, url, body, flags, format); + } + }, +}); + +async function handleNonStreamMode( + config: Config, + url: string, + body: DashScopeTTSRequest, + flags: GlobalFlags, + format: OutputFormat, +): Promise { + const concurrent = getConcurrency(flags); + + const results = await runConcurrent(concurrent, config, () => + requestJson(config, { url, method: "POST", body }), + ); + + const audioUrls = results.map((r) => r.output?.audio?.url).filter(Boolean) as string[]; + + if (audioUrls.length === 0) { + throw new BailianError("API returned no audio URL.", ExitCode.GENERAL); + } + + // Determine output paths + const path = await import("path"); + const destDir = resolveOutputDir(config, { subDir: "speech" }); + + const items = audioUrls.map((audioUrl, i) => { + let destPath = flags.out as string | undefined; + if (destPath && audioUrls.length === 1) { + // Single explicit output path + } else { + const timestamp = Date.now(); + const suffix = audioUrls.length > 1 ? `_${String(i + 1).padStart(3, "0")}` : ""; + const ext = body.input.format ?? "mp3"; + destPath = path.join(destDir, `tts_${timestamp}${suffix}.${ext}`); + } + return { url: audioUrl, destPath: destPath! }; + }); + + const saved = await downloadParallel(items, downloadFile, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(saved.join("\n")); + } else if (saved.length === 1) { + const expiresAt = results[0]!.output?.audio?.expires_at; + emitResult( + { + saved: saved[0], + audio_url: audioUrls[0], + model: body.model, + voice: body.input.voice, + ...(expiresAt ? { url_expires_at: expiresAt } : {}), + }, + format, + ); + } else { + emitResult( + { + saved, + audio_urls: audioUrls, + total: saved.length, + model: body.model, + voice: body.input.voice, + }, + format, + ); + } +} + +async function handleStreamMode( + config: Config, + url: string, + body: DashScopeTTSRequest, + flags: GlobalFlags, + format: OutputFormat, +): Promise { + const res = await request(config, { + url, + method: "POST", + body, + stream: true, + headers: { + Accept: "text/event-stream", + "X-DashScope-SSE": "enable", + }, + }); + + const outPath = flags.out as string | undefined; + const writer = outPath ? createWriteStream(outPath) : null; + let lastAudioUrl: string | undefined; + + try { + for await (const event of parseSSE(res)) { + if (!event.data || event.data === "[DONE]") continue; + + let chunk: DashScopeTTSStreamChunk; + try { + chunk = JSON.parse(event.data) as DashScopeTTSStreamChunk; + } catch { + continue; + } + + const audioData = chunk.output?.audio?.data; + if (audioData) { + const buffer = Buffer.from(audioData, "base64"); + if (writer) { + const ok = writer.write(buffer); + if (!ok) await new Promise((resolve) => writer.once("drain", () => resolve())); + } else { + process.stdout.write(buffer); + } + } + + if (chunk.output?.finish_reason === "stop") { + lastAudioUrl = chunk.output?.audio?.url; + if (lastAudioUrl && !config.quiet) { + process.stderr.write(`\nFull audio URL: ${lastAudioUrl}\n`); + } + break; + } + } + } finally { + if (writer) { + await new Promise((resolve, reject) => { + writer.on("finish", resolve); + writer.on("error", reject); + writer.end(); + }); + if (!config.quiet && outPath) { + process.stderr.write(`Saved: ${outPath}\n`); + } + } + } + + // Emit structured result for agent consumption + if (outPath) { + emitResult( + { + saved: outPath, + ...(lastAudioUrl ? { audio_url: lastAudioUrl } : {}), + model: body.model, + voice: body.input.voice, + }, + format, + ); + } +} diff --git a/packages/cli/src/commands/text/chat.ts b/packages/cli/src/commands/text/chat.ts new file mode 100644 index 0000000..18d74f0 --- /dev/null +++ b/packages/cli/src/commands/text/chat.ts @@ -0,0 +1,258 @@ +import { + defineCommand, + request, + requestJson, + chatEndpoint, + parseSSE, + detectOutputFormat, + type Config, + type GlobalFlags, + type ChatMessage, + type ChatRequest, + type ChatResponse, + type StreamChunk, + isInteractive, +} from "bailian-cli-core"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { readFileSync } from "fs"; + +interface ParsedMessages { + system?: string; + messages: ChatMessage[]; +} + +function parseMessages(flags: GlobalFlags): ParsedMessages { + const messages: ChatMessage[] = []; + let system: string | undefined; + + if (flags.system) { + system = flags.system as string; + } + + if (flags.messagesFile) { + const filePath = flags.messagesFile as string; + const raw = + filePath === "-" ? readFileSync("/dev/stdin", "utf-8") : readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>; + for (const m of parsed) { + if (m.role === "system") { + system = typeof m.content === "string" ? m.content : ""; + } else { + messages.push(m as ChatMessage); + } + } + } + + if (flags.message) { + const validRoles = new Set(["system", "user", "assistant"]); + const msgs = flags.message as string[]; + for (const m of msgs) { + const colonIdx = m.indexOf(":"); + const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : ""; + + if (validRoles.has(maybeRole)) { + const content = m.slice(colonIdx + 1); + if (maybeRole === "system") { + system = content; + } else { + messages.push({ role: maybeRole as "user" | "assistant", content }); + } + } else { + messages.push({ role: "user", content: m }); + } + } + } + + return { system, messages }; +} + +export default defineCommand({ + name: "text chat", + description: "Send a chat completion (OpenAI compatible, DashScope)", + apiDocs: "/compatibility-of-openai-with-dashscope", + usage: "bl text chat --message [flags]", + options: [ + { flag: "--model ", description: "Model ID (default: qwen3.7-max)" }, + { + flag: "--message ", + description: "Message text (repeatable, prefix role: to set role)", + required: true, + type: "array", + }, + { + flag: "--messages-file ", + description: "JSON file with messages array (use - for stdin)", + }, + { flag: "--system ", description: "System prompt" }, + { + flag: "--max-tokens ", + description: "Maximum tokens to generate (default: 4096)", + type: "number", + }, + { flag: "--temperature ", description: "Sampling temperature (0.0, 2.0]", type: "number" }, + { flag: "--top-p ", description: "Nucleus sampling threshold", type: "number" }, + { flag: "--stream", description: "Stream response tokens (default: on in TTY)" }, + { + flag: "--tool ", + description: "Tool definition as JSON or file path (repeatable)", + type: "array", + }, + { + flag: "--enable-thinking", + description: "Enable thinking/reasoning mode (for qwen3/qwq models)", + }, + { + flag: "--thinking-budget ", + description: "Max tokens for thinking (default: 4096)", + type: "number", + }, + ], + examples: [ + 'bl text chat --message "What is Qwen?"', + 'bl text chat --model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"', + 'bl text chat --message "Hello" --message "assistant:Hi!" --message "How are you?"', + "cat conversation.json | bl text chat --messages-file - --stream", + 'bl text chat --message "Hello" --output json', + 'bl text chat --model qwq-plus --message "Solve 1+1" --enable-thinking', + ], + async run(config: Config, flags: GlobalFlags) { + const { system, messages: parsedMessages } = parseMessages(flags); + let messages = parsedMessages; + + if (messages.length === 0) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ + message: "Enter your message:", + }); + if (!hint) { + process.stderr.write("Chat cancelled.\n"); + process.exit(1); + } + messages = [{ role: "user", content: hint }]; + } else { + failIfMissing("message", "bl text chat --message "); + } + } + + const model = (flags.model as string) || config.defaultTextModel || "qwen3.7-max"; + const shouldStream = + flags.stream === true || (flags.stream === undefined && process.stdout.isTTY); + const format = detectOutputFormat(config.output); + + // Build messages array with system prompt + const allMessages: ChatMessage[] = []; + if (system) { + allMessages.push({ role: "system", content: system }); + } + allMessages.push(...messages); + + const body: ChatRequest = { + model, + messages: allMessages, + max_tokens: (flags.maxTokens as number) ?? 4096, + stream: shouldStream, + }; + + if (flags.temperature !== undefined) body.temperature = flags.temperature as number; + if (flags.topP !== undefined) body.top_p = flags.topP as number; + + if (flags.enableThinking) { + body.enable_thinking = true; + if (flags.thinkingBudget !== undefined) { + body.thinking_budget = flags.thinkingBudget as number; + } + } + + if (flags.tool) { + const tools = (flags.tool as string[]).map((t) => { + try { + return JSON.parse(t); + } catch { + const raw = readFileSync(t, "utf-8"); + return JSON.parse(raw); + } + }); + body.tools = tools; + } + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + const url = chatEndpoint(config.baseUrl); + + if (shouldStream) { + const res = await request(config, { + url, + method: "POST", + body, + stream: true, + }); + + let textContent = ""; + let inThinking = false; + const writesStreamingStdout = format === "text"; + const dim = config.noColor ? "" : "\x1b[2m"; + const reset = config.noColor ? "" : "\x1b[0m"; + const isTTY = process.stdout.isTTY; + const statusOut = + format === "json" ? process.stderr : isTTY ? process.stdout : process.stderr; + const resultOut = process.stdout; + + for await (const event of parseSSE(res)) { + if (event.data === "[DONE]") break; + try { + const parsed = JSON.parse(event.data) as StreamChunk; + + for (const choice of parsed.choices) { + const delta = choice.delta; + + // Handle thinking/reasoning content + if (delta.reasoning_content) { + if (writesStreamingStdout && !inThinking) { + inThinking = true; + statusOut.write(`${dim}Thinking:\n`); + } + if (writesStreamingStdout) statusOut.write(delta.reasoning_content); + } + + // Handle regular content + if (delta.content) { + if (writesStreamingStdout && inThinking) { + statusOut.write(`${reset}\n\nResponse:\n`); + inThinking = false; + } + textContent += delta.content; + if (writesStreamingStdout) resultOut.write(delta.content); + } + } + } catch { + // Skip unparseable chunks + } + } + if (inThinking) statusOut.write(reset); + + if (format === "json") { + emitResult({ content: textContent }, format); + } else { + resultOut.write("\n"); + } + } else { + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + const text = response.choices?.[0]?.message?.content ?? ""; + + if (config.quiet || format === "text") { + emitBare(text); + } else { + emitResult(response, format); + } + } + }, +}); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000..5997285 --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -0,0 +1,70 @@ +import { execSync } from "child_process"; +import { writeFileSync } from "fs"; +import { join } from "path"; +import { defineCommand, getConfigDir } from "bailian-cli-core"; +import { CLI_VERSION } from "../version.ts"; +import { NPM_PACKAGE, fetchLatestVersion } from "../utils/update-checker.ts"; + +/** Build the install command */ +function detectInstallCommand(): { cmd: string; label: string } { + return { cmd: `npm install -g ${NPM_PACKAGE}@latest`, label: "npm" }; +} + +export default defineCommand({ + name: "update", + description: "Update bl to the latest version", + usage: "bl update", + examples: ["bl update"], + async run() { + const isTTY = process.stderr.isTTY; + const green = isTTY ? "\x1b[32m" : ""; + const yellow = isTTY ? "\x1b[33m" : ""; + const reset = isTTY ? "\x1b[0m" : ""; + + process.stderr.write(`Current version: ${yellow}${CLI_VERSION}${reset}\n`); + + // Check latest version first + process.stderr.write("Checking for updates...\n"); + const latest = await fetchLatestVersion(5000); + + if (latest && latest === CLI_VERSION) { + process.stderr.write(`${green}\u2713 Already up to date (${CLI_VERSION}).${reset}\n`); + return; + } + + if (latest) { + process.stderr.write(`Latest version: ${green}${latest}${reset}\n\n`); + } + + const { cmd, label } = detectInstallCommand(); + process.stderr.write(`Updating ${NPM_PACKAGE} via ${label}...\n\n`); + + try { + execSync(cmd, { stdio: "inherit" }); + // Verify the installed version after update + try { + const rawVer = execSync("bl --version 2>/dev/null", { encoding: "utf-8" }).trim(); + // bl --version outputs "bl X.Y.Z" — extract just the version number + const newVer = rawVer.replace(/^bl\s+/, ""); + process.stderr.write( + `\n${green}\u2713 Update complete: ${CLI_VERSION} \u2192 ${newVer}${reset}\n`, + ); + // Update the cached state so the post-run notification doesn't fire + try { + const stateFile = join(getConfigDir(), "update-state.json"); + writeFileSync( + stateFile, + JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer }), + ); + } catch { + /* ignore */ + } + } catch { + process.stderr.write(`\n${green}\u2713 Update complete.${reset}\n`); + } + } catch { + process.stderr.write("\nAutomatic update failed. Please run manually:\n"); + process.stderr.write(` ${cmd}\n\n`); + } + }, +}); diff --git a/packages/cli/src/commands/usage/free.ts b/packages/cli/src/commands/usage/free.ts new file mode 100644 index 0000000..215fdf1 --- /dev/null +++ b/packages/cli/src/commands/usage/free.ts @@ -0,0 +1,65 @@ +import { + defineCommand, + callConsoleGateway, + resolveConsoleGatewayCredential, + detectOutputFormat, + type Config, + type GlobalFlags, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult } from "../../output/output.ts"; + +const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota"; + +export default defineCommand({ + name: "usage free", + description: "Query free-tier quota for a model", + usage: "bl usage free --model [flags]", + options: [ + { + flag: "--model ", + description: "Model name to query (e.g. qwen3-max, qwen-turbo)", + required: true, + }, + { + flag: "--region ", + description: "API region (default: cn-beijing)", + }, + ], + examples: [ + "bl usage free --model qwen3-max", + "bl usage free --model qwen-turbo --output json", + "bl usage free --model qwen3-max --region cn-beijing", + ], + async run(config: Config, flags: GlobalFlags) { + const model = flags.model as string; + if (!model) failIfMissing("model", "bl usage free --model "); + + const region = (flags.region as string) || "cn-beijing"; + const format = detectOutputFormat(config.output); + + const credential = await resolveConsoleGatewayCredential(config); + + const data = { + queryFreeTierQuotaRequest: { + models: [model], + }, + }; + + if (config.dryRun) { + emitResult( + { api: FREE_TIER_API, data, region, token: credential.token.slice(0, 8) + "..." }, + format, + ); + return; + } + + const result = await callConsoleGateway(config, credential.token, { + api: FREE_TIER_API, + data, + region, + }); + + emitResult(result, format); + }, +}); diff --git a/packages/cli/src/commands/video/download.ts b/packages/cli/src/commands/video/download.ts new file mode 100644 index 0000000..9d3aa67 --- /dev/null +++ b/packages/cli/src/commands/video/download.ts @@ -0,0 +1,70 @@ +import { + defineCommand, + requestJson, + taskEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeTaskResponse, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { downloadFile, formatBytes } from "../../utils/download.ts"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "video download", + description: "Download a completed video by task ID", + usage: "bl video download --task-id --out ", + options: [ + { flag: "--task-id ", description: "Task ID to download from" }, + { flag: "--out ", description: "Output file path" }, + ], + examples: [ + "bl video download --task-id 3b256896-xxxx --out video.mp4", + "bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet", + ], + async run(config: Config, flags: GlobalFlags) { + const taskId = flags.taskId as string | undefined; + if (!taskId) failIfMissing("task-id", "bl video download --task-id --out "); + + const outPath = flags.out as string | undefined; + if (!outPath) failIfMissing("out", "bl video download --task-id --out video.mp4"); + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ task_id: taskId, action: "download", out: outPath }, format); + return; + } + + // Get task info to find video URL + const url = taskEndpoint(config.baseUrl, taskId); + const taskInfo = await requestJson(config, { url }); + + if (taskInfo.output.task_status !== "SUCCEEDED") { + throw new BailianError( + `Task is not complete (status: ${taskInfo.output.task_status}).`, + ExitCode.GENERAL, + "Wait for the task to complete before downloading.", + ); + } + + const downloadUrl = + taskInfo.output.video_url || (taskInfo.output.results && taskInfo.output.results[0]?.url); + + if (!downloadUrl) { + throw new BailianError("No download URL available for this task.", ExitCode.GENERAL); + } + + const { size } = await downloadFile(downloadUrl, outPath, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(outPath); + return; + } + + emitResult({ saved: outPath, size: formatBytes(size) }, format); + }, +}); diff --git a/packages/cli/src/commands/video/edit.ts b/packages/cli/src/commands/video/edit.ts new file mode 100644 index 0000000..926411b --- /dev/null +++ b/packages/cli/src/commands/video/edit.ts @@ -0,0 +1,235 @@ +import { + defineCommand, + requestJson, + videoGenerateEndpoint, + taskEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeVideoEditRequest, + type DashScopeAsyncResponse, + type DashScopeTaskResponse, + isInteractive, + resolveOutputDir, + resolveFileUrl, + resolveCredential, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { poll } from "../../utils/polling.ts"; +import { downloadFile, formatBytes } from "../../utils/download.ts"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "video edit", + description: + "Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.)", + apiDocs: "/best-practice/wanx/video-edit", + usage: "bl video edit --video --prompt [flags]", + options: [ + { flag: "--model ", description: "Model ID (default: happyhorse-1.0-video-edit)" }, + { + flag: "--video ", + description: "Input video URL or local file (mp4/mov, 2-10s)", + required: true, + }, + { flag: "--prompt ", description: 'Edit instruction (e.g. "将画面转换为黏土风格")' }, + { flag: "--ref-image ", description: "Reference image URL (up to 4, comma-separated)" }, + { + flag: "--negative-prompt ", + description: "Negative prompt to exclude unwanted content", + }, + { flag: "--resolution ", description: "Resolution: 720P or 1080P (default: 1080P)" }, + { flag: "--ratio ", description: "Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4)" }, + { + flag: "--duration ", + description: "Output video duration in seconds (2-10)", + type: "number", + }, + { + flag: "--audio-setting ", + description: "Audio: auto (default) or origin (keep original)", + }, + { flag: "--prompt-extend", description: "Enable prompt intelligent rewriting (default: true)" }, + { flag: "--no-prompt-extend", description: "Disable prompt intelligent rewriting" }, + { flag: "--watermark", description: 'Add "AI生成" watermark' }, + { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, + { flag: "--download ", description: "Save video to file on completion" }, + { flag: "--no-wait", description: "Return task ID immediately without waiting" }, + { + flag: "--async", + description: "Return task ID immediately (agent/CI mode, same as --no-wait)", + }, + { + flag: "--poll-interval ", + description: "Polling interval when waiting (default: 15)", + type: "number", + }, + ], + examples: [ + 'bl video edit --video https://example.com/input.mp4 --prompt "将整个画面转换为黏土风格"', + 'bl video edit --video https://example.com/input.mp4 --prompt "替换衣服为图片中的款式" --ref-image https://example.com/clothes.png', + 'bl video edit --video https://example.com/input.mp4 --prompt "Convert to anime style" --resolution 720P --download output.mp4', + ], + async run(config: Config, flags: GlobalFlags) { + // --- Validate video URL --- + let videoUrl = flags.video as string | undefined; + if (!videoUrl) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ message: "Enter the video URL to edit:" }); + if (!hint) { + process.stderr.write("Video editing cancelled.\n"); + process.exit(1); + } + videoUrl = hint; + } else { + failIfMissing("video", "bl video edit --video --prompt "); + } + } + + // --- Prompt --- + let prompt = flags.prompt as string | undefined; + if (!prompt) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ message: "Enter your edit instruction:" }); + if (!hint) { + process.stderr.write("Video editing cancelled.\n"); + process.exit(1); + } + prompt = hint; + } + // prompt is optional for video edit per API spec + } + + const model = (flags.model as string) || "happyhorse-1.0-video-edit"; + const format = detectOutputFormat(config.output); + + // Auto-upload local files + const credential = await resolveCredential(config); + const resolvedVideoUrl = await resolveFileUrl(videoUrl!, credential.token, model); + // --- Build media array --- + const media: DashScopeVideoEditRequest["input"]["media"] = [ + { type: "video", url: resolvedVideoUrl }, + ]; + + // Support comma-separated reference images + const refImageArg = flags.refImage as string | undefined; + if (refImageArg) { + const images = refImageArg + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + for (const imgUrl of images) { + const resolved = await resolveFileUrl(imgUrl, credential.token, model); + media.push({ type: "reference_image", url: resolved }); + } + } + + // --- Build request body --- + const promptExtend = + flags.noPromptExtend === true ? false : flags.promptExtend === true ? true : undefined; + + const body: DashScopeVideoEditRequest = { + model, + input: { + prompt: prompt || undefined, + negative_prompt: (flags.negativePrompt as string) || undefined, + media, + }, + parameters: { + resolution: (flags.resolution as string) || undefined, + ratio: (flags.ratio as string) || undefined, + duration: (flags.duration as number) || undefined, + audio_setting: (flags.audioSetting as "auto" | "origin") || undefined, + prompt_extend: promptExtend, + watermark: flags.watermark === true ? true : undefined, + seed: flags.seed as number | undefined, + }, + }; + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + // --- Submit async task --- + const url = videoGenerateEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + async: true, + }); + + const taskId = response.output.task_id; + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}]\n`); + process.stderr.write("Note: Video editing typically takes 5-8 minutes. Please be patient.\n"); + } + + // --no-wait or --async: return task ID immediately + if (flags.noWait || config.async) { + emitResult({ task_id: taskId }, format); + return; + } + + // --- Poll until completion --- + // Video editing is compute-intensive; default timeout = 600s (10 min) + const pollInterval = (flags.pollInterval as number) ?? 15; + const pollUrl = taskEndpoint(config.baseUrl, taskId); + const editTimeout = Math.max(config.timeout, 600); + + const result = await poll(config, { + url: pollUrl, + intervalSec: pollInterval, + timeoutSec: editTimeout, + isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, + getErrorMessage: (d) => { + const o = (d as DashScopeTaskResponse).output; + return o.message || o.code || undefined; + }, + }); + + const resultVideoUrl = + result.output.video_url || (result.output.results && result.output.results[0]?.url); + + if (!resultVideoUrl) { + throw new BailianError("Task completed but no video URL returned.", ExitCode.GENERAL); + } + + // --download: save to file + if (flags.download) { + const destPath = flags.download as string; + const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(destPath); + } else { + emitResult( + { + task_id: taskId, + video_url: resultVideoUrl, + status: "SUCCEEDED", + saved: destPath, + size: formatBytes(size), + }, + format, + ); + } + return; + } + + // Default: auto-download to output directory + const path = await import("path"); + const destDir = resolveOutputDir(config, { subDir: "videos" }); + const destPath = path.join(destDir, `${taskId}.mp4`); + + await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + + emitResult({ task_id: taskId, video_url: resultVideoUrl, saved: destPath }, format); + }, +}); diff --git a/packages/cli/src/commands/video/generate.ts b/packages/cli/src/commands/video/generate.ts new file mode 100644 index 0000000..1ab3ec9 --- /dev/null +++ b/packages/cli/src/commands/video/generate.ts @@ -0,0 +1,245 @@ +import { + defineCommand, + requestJson, + videoGenerateEndpoint, + taskEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeVideoRequest, + type DashScopeAsyncResponse, + type DashScopeTaskResponse, + isInteractive, + resolveOutputDir, + resolveFileUrl, + resolveCredential, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { poll } from "../../utils/polling.ts"; +import { downloadFile, formatBytes } from "../../utils/download.ts"; +import { runConcurrent, getConcurrency } from "../../utils/concurrent.ts"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +// Normalize shorthand resolution (720P, 1080P) to pixel format for video generation models +const RESOLUTION_SHORTCUTS: Record = { + "720p": "1280*720", + "1080p": "1920*1080", + "480p": "832*480", +}; + +function normalizeResolution(res: string | undefined): string | undefined { + if (!res) return undefined; + return RESOLUTION_SHORTCUTS[res.toLowerCase()] || res; +} + +export default defineCommand({ + name: "video generate", + description: + "Generate a video from text or image (happyhorse-1.0-t2v / happyhorse-1.0-i2v / wan2.6-t2v)", + apiDocs: "/best-practice/wanx/text-to-video", + usage: "bl video generate --prompt [--image ] [flags]", + options: [ + { + flag: "--model ", + description: "Model ID (default: happyhorse-1.0-t2v, or happyhorse-1.0-i2v with --image)", + }, + { flag: "--prompt ", description: "Video description", required: true }, + { flag: "--image ", description: "Input image URL for image-to-video generation" }, + { + flag: "--negative-prompt ", + description: "Negative prompt to exclude unwanted content", + }, + { flag: "--resolution ", description: "Resolution (e.g. 1280*720, 960*960)" }, + { flag: "--ratio ", description: "Aspect ratio (e.g. 16:9, 1:1)" }, + { + flag: "--duration ", + description: "Video duration in seconds (default: 5)", + type: "number", + }, + { flag: "--prompt-extend", description: "Automatically extend prompt for better results" }, + { flag: "--watermark", description: "Add watermark to generated video" }, + { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, + { flag: "--download ", description: "Save video to file on completion" }, + { flag: "--no-wait", description: "Return task ID immediately without waiting" }, + { + flag: "--async", + description: "Return task ID immediately (agent/CI mode, same as --no-wait)", + }, + { + flag: "--poll-interval ", + description: "Polling interval when waiting (default: 5)", + type: "number", + }, + ], + examples: [ + 'bl video generate --prompt "一个人在读书,静态镜头"', + 'bl video generate --prompt "Ocean waves at sunset." --download sunset.mp4', + 'bl video generate --image https://example.com/cat.png --prompt "让画面中的猫动起来"', + 'bl video generate --prompt "Mountain landscape" --resolution 1280*720 --duration 5', + ], + async run(config: Config, flags: GlobalFlags) { + let prompt = flags.prompt as string | undefined; + + if (!prompt) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ message: "Enter your video prompt:" }); + if (!hint) { + process.stderr.write("Video generation cancelled.\n"); + process.exit(1); + } + prompt = hint; + } else { + failIfMissing("prompt", "bl video generate --prompt "); + } + } + + const model = + (flags.model as string) || + config.defaultVideoModel || + ((flags.image as string) ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v"); + const format = detectOutputFormat(config.output); + + const imageUrl = flags.image as string | undefined; + + // Auto-upload local image file for i2v + let resolvedImageUrl: string | undefined; + if (imageUrl) { + const credential = await resolveCredential(config); + resolvedImageUrl = await resolveFileUrl(imageUrl, credential.token, model); + } + + const body: DashScopeVideoRequest = { + model, + input: { + prompt: prompt!, + negative_prompt: (flags.negativePrompt as string) || undefined, + // i2v models (happyhorse-1.0-i2v) require input.media with type 'first_frame' + ...(resolvedImageUrl + ? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] } + : {}), + }, + parameters: { + resolution: normalizeResolution(flags.resolution as string) || undefined, + ratio: (flags.ratio as string) || undefined, + duration: (flags.duration as number) || undefined, + prompt_extend: flags.promptExtend === true ? true : undefined, + watermark: flags.watermark === true ? true : undefined, + seed: flags.seed as number | undefined, + }, + }; + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + // Submit async task(s) — supports --concurrent for parallel generation + const concurrent = getConcurrency(flags); + const url = videoGenerateEndpoint(config.baseUrl); + + const responses = await runConcurrent( + concurrent, + config, + () => + requestJson(config, { + url, + method: "POST", + body, + async: true, + }), + "tasks", + ); + + const taskIds = responses.map((r) => r.output.task_id); + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}]\n`); + } + + // --no-wait or --async: return task ID(s) immediately + if (flags.noWait || config.async) { + emitResult(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }, format); + return; + } + + // Poll all tasks concurrently + const pollInterval = (flags.pollInterval as number) ?? 5; + + const pollPromises = taskIds.map((taskId) => { + const pollUrl = taskEndpoint(config.baseUrl, taskId); + return poll(config, { + url: pollUrl, + intervalSec: pollInterval, + timeoutSec: config.timeout, + isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, + getErrorMessage: (d) => { + const o = (d as DashScopeTaskResponse).output; + return o.message || o.code || undefined; + }, + }); + }); + + const results = await Promise.all(pollPromises); + + // Collect video URLs from all results + const videos: Array<{ taskId: string; videoUrl: string }> = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]!; + const videoUrl = + result.output.video_url || (result.output.results && result.output.results[0]?.url); + if (videoUrl) { + videos.push({ taskId: taskIds[i]!, videoUrl }); + } + } + + if (videos.length === 0) { + throw new BailianError("All tasks completed but no video URLs returned.", ExitCode.GENERAL); + } + + // --download: save to file (first video only for explicit path) + if (flags.download) { + const destPath = flags.download as string; + const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(destPath); + } else { + emitResult( + { + task_id: videos[0]!.taskId, + video_url: videos[0]!.videoUrl, + status: "SUCCEEDED", + saved: destPath, + size: formatBytes(size), + }, + format, + ); + } + return; + } + + // Default: auto-download all to output directory + const destDir = resolveOutputDir(config, { subDir: "videos" }); + // eslint-disable-next-line @typescript-eslint/unbound-method + const { join } = await import("path"); + + const saved: Array<{ task_id: string; video_url: string; saved: string }> = []; + await Promise.all( + videos.map(async ({ taskId, videoUrl }) => { + const destPath = join(destDir, `${taskId}.mp4`); + await downloadFile(videoUrl, destPath, { quiet: config.quiet }); + saved.push({ task_id: taskId, video_url: videoUrl, saved: destPath }); + }), + ); + + if (saved.length === 1) { + emitResult(saved[0]!, format); + } else { + emitResult({ videos: saved, total: saved.length }, format); + } + }, +}); diff --git a/packages/cli/src/commands/video/ref.ts b/packages/cli/src/commands/video/ref.ts new file mode 100644 index 0000000..a5681a9 --- /dev/null +++ b/packages/cli/src/commands/video/ref.ts @@ -0,0 +1,265 @@ +import { + defineCommand, + requestJson, + videoGenerateEndpoint, + taskEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeVideoRefRequest, + type DashScopeAsyncResponse, + type DashScopeTaskResponse, + isInteractive, + resolveOutputDir, + resolveFileUrl, + resolveCredential, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { poll } from "../../utils/polling.ts"; +import { downloadFile, formatBytes } from "../../utils/download.ts"; +import { promptText, failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "video ref", + description: + "Reference-to-video generation (happyhorse-1.0-r2v / wan2.6-r2v): multi-subject, multi-shot with voice", + apiDocs: "/best-practice/wanx/video-reference", + usage: "bl video ref --prompt --image ... [--ref-video ...] [flags]", + options: [ + { flag: "--model ", description: "Model ID (default: happyhorse-1.0-r2v)" }, + { + flag: "--prompt ", + description: "Video description with reference markers (图1, 视频1, etc.)", + required: true, + }, + { + flag: "--image ", + description: "Reference image URL or local file (repeatable for multiple subjects)", + type: "array", + }, + { + flag: "--ref-video ", + description: "Reference video URL or local file (repeatable)", + type: "array", + }, + { + flag: "--image-voice ", + description: "Voice URL for corresponding image (pairs by position)", + type: "array", + }, + { + flag: "--video-voice ", + description: "Voice URL for corresponding ref-video (pairs by position)", + type: "array", + }, + { flag: "--resolution ", description: "Resolution: 720P or 1080P (default: 720P)" }, + { flag: "--ratio ", description: "Aspect ratio (16:9, 9:16, 1:1)" }, + { + flag: "--duration ", + description: "Video duration in seconds (2-10, default: 5)", + type: "number", + }, + { flag: "--prompt-extend", description: "Enable prompt intelligent rewriting" }, + { flag: "--no-prompt-extend", description: "Disable prompt intelligent rewriting" }, + { flag: "--watermark", description: "Add watermark to generated video" }, + { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, + { flag: "--download ", description: "Save video to file on completion" }, + { flag: "--no-wait", description: "Return task ID immediately without waiting" }, + { + flag: "--async", + description: "Return task ID immediately (agent/CI mode, same as --no-wait)", + }, + { + flag: "--poll-interval ", + description: "Polling interval when waiting (default: 15)", + type: "number", + }, + ], + examples: [ + 'bl video ref --prompt "图1在草地上奔跑" --image person.jpg', + 'bl video ref --prompt "视频1在弹吉他,图1走过来" --ref-video scene.mp4 --image person.jpg', + 'bl video ref --prompt "图1说话" --image person.jpg --image-voice voice.mp3 --resolution 1080P', + 'bl video ref --prompt "图1和图2在对话" --image a.jpg --image b.jpg --image-voice va.mp3 --image-voice vb.mp3', + ], + async run(config: Config, flags: GlobalFlags) { + // --- Validate prompt --- + let prompt = flags.prompt as string | undefined; + if (!prompt) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ + message: "Enter your video prompt (use 图1, 视频1 to reference inputs):", + }); + if (!hint) { + process.stderr.write("Video generation cancelled.\n"); + process.exit(1); + } + prompt = hint; + } else { + failIfMissing("prompt", "bl video ref --prompt --image "); + } + } + + const images = (flags.image as string[] | undefined) || []; + const refVideos = (flags.refVideo as string[] | undefined) || []; + + if (images.length === 0 && refVideos.length === 0) { + throw new BailianError( + "At least one --image or --ref-video is required.", + ExitCode.USAGE, + 'bl video ref --prompt "描述" --image person.jpg', + ); + } + + const imageVoices = (flags.imageVoice as string[] | undefined) || []; + const videoVoices = (flags.videoVoice as string[] | undefined) || []; + + const model = (flags.model as string) || "happyhorse-1.0-r2v"; + const format = detectOutputFormat(config.output); + + // --- Resolve file URLs (auto-upload local files) --- + const credential = await resolveCredential(config); + const media: DashScopeVideoRefRequest["input"]["media"] = []; + + // Add reference images + for (let i = 0; i < images.length; i++) { + const resolved = await resolveFileUrl(images[i]!, credential.token, model); + const entry: DashScopeVideoRefRequest["input"]["media"][number] = { + type: "reference_image", + url: resolved, + }; + + // Pair voice by position + if (imageVoices[i]) { + const resolvedVoice = await resolveFileUrl(imageVoices[i]!, credential.token, model); + entry.reference_voice = resolvedVoice; + } + + media.push(entry); + } + + // Add reference videos + for (let i = 0; i < refVideos.length; i++) { + const resolved = await resolveFileUrl(refVideos[i]!, credential.token, model); + const entry: DashScopeVideoRefRequest["input"]["media"][number] = { + type: "reference_video", + url: resolved, + }; + + // Pair voice by position + if (videoVoices[i]) { + const resolvedVoice = await resolveFileUrl(videoVoices[i]!, credential.token, model); + entry.reference_voice = resolvedVoice; + } + + media.push(entry); + } + + // --- Build request body --- + const promptExtend = + flags.noPromptExtend === true ? false : flags.promptExtend === true ? true : undefined; + + const body: DashScopeVideoRefRequest = { + model, + input: { + prompt: prompt!, + media, + }, + parameters: { + resolution: (flags.resolution as string) || undefined, + ratio: (flags.ratio as string) || undefined, + duration: (flags.duration as number) || undefined, + prompt_extend: promptExtend, + watermark: flags.watermark === true ? true : undefined, + seed: flags.seed as number | undefined, + }, + }; + + if (config.dryRun) { + emitResult({ request: body }, format); + return; + } + + // --- Submit async task --- + const url = videoGenerateEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + async: true, + }); + + const taskId = response.output.task_id; + + if (!config.quiet) { + process.stderr.write(`[Model: ${model}]\n`); + process.stderr.write( + `Note: Reference-to-video typically takes 5-10 minutes. Please be patient.\n`, + ); + } + + // --no-wait or --async: return task ID immediately + if (flags.noWait || config.async) { + emitResult({ task_id: taskId }, format); + return; + } + + // --- Poll until completion --- + const pollInterval = (flags.pollInterval as number) ?? 15; + const pollUrl = taskEndpoint(config.baseUrl, taskId); + const refTimeout = Math.max(config.timeout, 600); + + const result = await poll(config, { + url: pollUrl, + intervalSec: pollInterval, + timeoutSec: refTimeout, + isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, + getErrorMessage: (d) => { + const o = (d as DashScopeTaskResponse).output; + return o.message || o.code || undefined; + }, + }); + + const resultVideoUrl = + result.output.video_url || (result.output.results && result.output.results[0]?.url); + + if (!resultVideoUrl) { + throw new BailianError("Task completed but no video URL returned.", ExitCode.GENERAL); + } + + // --download: save to file + if (flags.download) { + const destPath = flags.download as string; + const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + + if (config.quiet) { + emitBare(destPath); + } else { + emitResult( + { + task_id: taskId, + video_url: resultVideoUrl, + status: "SUCCEEDED", + saved: destPath, + size: formatBytes(size), + }, + format, + ); + } + return; + } + + // Default: auto-download to output directory + // eslint-disable-next-line @typescript-eslint/unbound-method + const { join } = await import("path"); + const destDir = resolveOutputDir(config, { subDir: "videos" }); + const destPath = join(destDir, `${taskId}.mp4`); + + await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + + emitResult({ task_id: taskId, video_url: resultVideoUrl, saved: destPath }, format); + }, +}); diff --git a/packages/cli/src/commands/video/task-get.ts b/packages/cli/src/commands/video/task-get.ts new file mode 100644 index 0000000..65652a4 --- /dev/null +++ b/packages/cli/src/commands/video/task-get.ts @@ -0,0 +1,53 @@ +import { + defineCommand, + requestJson, + taskEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type DashScopeTaskResponse, +} from "bailian-cli-core"; +import { failIfMissing } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; + +export default defineCommand({ + name: "video task get", + description: "Query async task status", + usage: "bl video task get --task-id ", + options: [{ flag: "--task-id ", description: "Async task ID" }], + examples: [ + "bl video task get --task-id 3b256896-3e70-xxxx-xxxx-xxxxxxxxxxxx", + "bl video task get --task-id 3b256896-3e70-xxxx --output json", + ], + async run(config: Config, flags: GlobalFlags) { + const taskId = flags.taskId as string | undefined; + if (!taskId) failIfMissing("task-id", "bl video task get --task-id "); + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult({ task_id: taskId }, format); + return; + } + + const url = taskEndpoint(config.baseUrl, taskId); + const response = await requestJson(config, { url }); + + if (config.quiet) { + emitBare(response.output.task_status); + return; + } + + emitResult( + { + task_id: response.output.task_id, + task_status: response.output.task_status, + video_url: response.output.video_url, + results: response.output.results, + submit_time: response.output.submit_time, + end_time: response.output.end_time, + }, + format, + ); + }, +}); diff --git a/packages/cli/src/commands/vision/describe.ts b/packages/cli/src/commands/vision/describe.ts new file mode 100644 index 0000000..f0121f2 --- /dev/null +++ b/packages/cli/src/commands/vision/describe.ts @@ -0,0 +1,196 @@ +import { + defineCommand, + requestJson, + chatEndpoint, + detectOutputFormat, + type Config, + type GlobalFlags, + type ChatRequest, + type ChatResponse, + type ChatMessageContent, + isInteractive, + resolveFileUrl, + resolveCredential, + BailianError, + ExitCode, + isLocalFile, +} from "bailian-cli-core"; +import { promptText } from "../../output/prompt.ts"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { readFileSync, existsSync } from "fs"; +import { extname } from "path"; + +const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +}; + +const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]); + +function isVideoInput(input: string): boolean { + // Check by extension + const ext = extname(input).toLowerCase().split("?")[0]!; + if (VIDEO_EXTENSIONS.has(ext)) return true; + // URL heuristic: contains common video extensions + if (/\.(mp4|mov|avi|mkv|webm|flv|wmv)(\?|$)/i.test(input)) return true; + return false; +} + +async function toImageUrl(image: string): Promise { + if (image.startsWith("data:")) return image; + if (image.startsWith("http://") || image.startsWith("https://")) return image; + if (image.startsWith("oss://")) return image; + + // Local file → data URI (for small files < 10MB, fallback) + if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE); + const ext = extname(image).toLowerCase(); + const mime = IMAGE_MIME_TYPES[ext]; + if (!mime) + throw new BailianError( + `Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, + ExitCode.USAGE, + ); + const buf = readFileSync(image); + return `data:${mime};base64,${buf.toString("base64")}`; +} + +export default defineCommand({ + name: "vision describe", + description: "Describe an image or video using Qwen-VL", + usage: "bl vision describe --image [--video ] [--prompt ]", + options: [ + { flag: "--image ", description: "Local image path or URL" }, + { + flag: "--video ", + description: "Video file URL or local path (mp4/mov/avi/mkv/webm)", + type: "array", + }, + { flag: "--prompt ", description: "Question about the content (default: auto-detected)" }, + { flag: "--model ", description: "Vision model (default: qwen-vl-max)" }, + ], + examples: [ + "bl vision describe --image photo.jpg", + 'bl vision describe --image https://example.com/photo.jpg --prompt "这只狗是什么品种?"', + 'bl vision describe --video https://example.com/video.mp4 --prompt "总结视频内容"', + "bl vision describe --video ./local-video.mp4", + 'bl vision describe --image photo.png --prompt "Extract the text" --model qwen-vl-plus', + ], + async run(config: Config, flags: GlobalFlags) { + let image = (flags.image ?? (flags._positional as string[] | undefined)?.[0]) as + | string + | undefined; + const videoInputs = (flags.video as string[] | undefined) ?? []; + const model = (flags.model as string) || "qwen-vl-max"; + + // Auto-detect: if --image was given a video file, treat it as --video + if (image && isVideoInput(image)) { + videoInputs.push(image); + image = undefined; + } + + const hasVideo = videoInputs.length > 0; + const defaultPrompt = hasVideo ? "Describe the video." : "Describe the image."; + const prompt = (flags.prompt as string) || defaultPrompt; + + if (!image && !hasVideo) { + if (isInteractive({ nonInteractive: config.nonInteractive })) { + const hint = await promptText({ + message: "Enter image/video path or URL:", + }); + if (!hint) { + process.stderr.write("Vision describe cancelled.\n"); + process.exit(1); + } + // Detect if user entered a video + if (isVideoInput(hint)) { + videoInputs.push(hint); + } else { + image = hint; + } + } else { + throw new BailianError( + "Missing required argument --image or --video.", + ExitCode.USAGE, + "bl vision describe --image \nbl vision describe --video ", + ); + } + } + + const format = detectOutputFormat(config.output); + + if (config.dryRun) { + emitResult( + { request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } }, + format, + ); + return; + } + + const contentArray: ChatMessageContent[] = []; + // ---- Handle video inputs ---- + if (videoInputs.length > 0) { + for (const videoInput of videoInputs) { + let videoUrl = videoInput; + + // Local video file → upload to OSS + if (isLocalFile(videoInput)) { + if (!existsSync(videoInput)) { + throw new BailianError(`Video file not found: ${videoInput}`, ExitCode.USAGE); + } + const credential = await resolveCredential(config); + videoUrl = await resolveFileUrl(videoInput, credential.token, model); + } + + contentArray.push({ type: "video_url", video_url: { url: videoUrl } }); + } + } + + // ---- Handle image input ---- + if (image) { + const imageUrl = await toImageUrl(image); + + let finalImageUrl = imageUrl; + if (isLocalFile(image) && imageUrl.startsWith("data:")) { + const { statSync } = await import("fs"); + const fileSize = statSync(image).size; + if (fileSize > 5 * 1024 * 1024) { + const credential = await resolveCredential(config); + finalImageUrl = await resolveFileUrl(image, credential.token, model); + } + } + + contentArray.push({ type: "image_url", image_url: { url: finalImageUrl } }); + } + + // ---- Text prompt ---- + contentArray.push({ type: "text", text: prompt }); + + const body: ChatRequest = { + model, + messages: [ + { + role: "user", + content: contentArray, + }, + ], + }; + + const url = chatEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + const content = response.choices?.[0]?.message?.content; + + if (format !== "text") { + emitResult(response, format); + return; + } + + emitBare((content || "") as string); + }, +}); diff --git a/packages/cli/src/error-handler.ts b/packages/cli/src/error-handler.ts new file mode 100644 index 0000000..c13d646 --- /dev/null +++ b/packages/cli/src/error-handler.ts @@ -0,0 +1,192 @@ +import { + BailianError, + ExitCode, + detectOutputFormat, + type OutputFormat, + CONSOLE_GATEWAY_NO_TOKEN_MESSAGE, +} from "bailian-cli-core"; +import { API_KEY_PAGE } from "./urls.ts"; + +const LABEL_WIDTH = 13; + +function pad(label: string): string { + return label.padEnd(LABEL_WIDTH); +} + +function alignContinuation(text: string): string { + return text + .split("\n") + .map((line, i) => (i === 0 ? line : " ".repeat(LABEL_WIDTH) + line)) + .join("\n"); +} + +function enhanceHint(err: BailianError): string | undefined { + if (err.exitCode === ExitCode.AUTH) { + if (err.message === CONSOLE_GATEWAY_NO_TOKEN_MESSAGE) { + return err.hint; + } + return [ + err.hint, + "", + "bl auth login --api-key ", + "bl auth status", + `Get API Key: ${API_KEY_PAGE}`, + ] + .filter((s): s is string => s !== undefined) + .join("\n"); + } + return err.hint; +} + +export function detectErrorOutputFormat( + argv: string[] = process.argv.slice(2), + envOutput: string | undefined = process.env.DASHSCOPE_OUTPUT, +): OutputFormat { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--output") { + return detectOutputFormat(argv[i + 1] || envOutput); + } + if (arg?.startsWith("--output=")) { + return detectOutputFormat(arg.slice("--output=".length)); + } + } + return detectOutputFormat(envOutput); +} + +function fromFetchFailed(err: TypeError): BailianError { + const cause = err.cause as NodeJS.ErrnoException | undefined; + const code = cause?.code; + const causeMsg = cause?.message; + + const detailParts: string[] = []; + if (code) detailParts.push(code); + if (causeMsg && causeMsg !== code) detailParts.push(causeMsg); + const detail = detailParts.length > 0 ? detailParts.join(": ") : "unknown cause"; + + return new BailianError( + `Network request failed: ${detail}`, + ExitCode.NETWORK, + pickNetworkHint(code), + { cause: err }, + ); +} + +function pickNetworkHint(code: string | undefined): string { + switch (code) { + case "ENOTFOUND": + case "EAI_AGAIN": + return "DNS resolution failed. Check DASHSCOPE_BASE_URL or your DNS / network."; + case "ECONNREFUSED": + return "Connection refused. Check the target host/port and proxy settings."; + case "ECONNRESET": + return "Connection reset by peer. Retry, or check proxy / firewall."; + case "ETIMEDOUT": + return "Connection timed out. Check your network or try a different region."; + case "CERT_HAS_EXPIRED": + case "UNABLE_TO_VERIFY_LEAF_SIGNATURE": + case "DEPTH_ZERO_SELF_SIGNED_CERT": + return "TLS certificate error. Check system clock and CA bundle."; + default: + return "Check network connection, proxy settings (HTTP_PROXY / HTTPS_PROXY), and DASHSCOPE_BASE_URL."; + } +} + +function fromFsError(err: NodeJS.ErrnoException): BailianError { + const ecode = err.code; + let hint = "Check the file path and permissions."; + if (ecode === "ENOENT") hint = "File or directory not found."; + else if (ecode === "EACCES" || ecode === "EPERM") + hint = "Permission denied — check file or directory permissions."; + else if (ecode === "ENOSPC") hint = "Disk full — free up space and try again."; + return new BailianError(`File system error: ${err.message}`, ExitCode.GENERAL, hint, { + cause: err, + }); +} + +function writeCauseChain(err: Error): void { + let cur: unknown = (err as { cause?: unknown }).cause; + let depth = 0; + while (cur instanceof Error && depth < 5) { + process.stderr.write(`${pad("Caused by:")}${cur.message}\n`); + cur = (cur as { cause?: unknown }).cause; + depth++; + } +} + +function writeBailianErrorText(err: BailianError): void { + process.stderr.write(`\n${pad("Error:")}${err.message}\n`); + + const hint = enhanceHint(err); + if (hint) { + process.stderr.write(`${pad("Hint:")}${alignContinuation(hint)}\n`); + } + + const api = err.api; + if (api) { + if (api.httpStatus !== undefined) { + const codeSuffix = api.apiCode ? ` (${api.apiCode})` : ""; + process.stderr.write(`${pad("Status:")}HTTP ${api.httpStatus}${codeSuffix}\n`); + } else if (api.apiCode) { + process.stderr.write(`${pad("Code:")}${api.apiCode}\n`); + } + if (api.requestId) { + process.stderr.write(`${pad("Request ID:")}${api.requestId}\n`); + } + } + + writeCauseChain(err); + + process.stderr.write(`${pad("Exit code:")}${err.exitCode}\n`); +} + +export function handleError(err: unknown): never { + if (err instanceof BailianError) { + const format = detectErrorOutputFormat(); + + if (format === "json") { + process.stderr.write(JSON.stringify(err.toJSON(), null, 2) + "\n"); + } else { + writeBailianErrorText(err); + } + process.exit(err.exitCode); + } + + if (err instanceof Error) { + if ( + err.name === "AbortError" || + err.name === "TimeoutError" || + err.message.includes("timed out") + ) { + const timeout = new BailianError( + "Request timed out.", + ExitCode.TIMEOUT, + "Try increasing --timeout (e.g. --timeout 60).\n" + + "If this happens on every request with a valid API key, you may be hitting the wrong region.\n" + + "Run: bl auth status — to check your credentials and region.\n" + + "Run: bl config set --key region --value cn — to override the region.", + { cause: err }, + ); + return handleError(timeout); + } + + if (err instanceof TypeError && err.message === "fetch failed") { + return handleError(fromFetchFailed(err)); + } + + const ecode = (err as NodeJS.ErrnoException).code; + if (typeof ecode === "string" && ecode.startsWith("E")) { + return handleError(fromFsError(err as NodeJS.ErrnoException)); + } + + process.stderr.write(`\n${pad("Error:")}${err.message}\n`); + writeCauseChain(err); + if (process.env.DASHSCOPE_VERBOSE === "1") { + process.stderr.write(`${err.stack}\n`); + } + } else { + process.stderr.write(`\n${pad("Error:")}${String(err)}\n`); + } + + process.exit(ExitCode.GENERAL); +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 0000000..b3b94b2 --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,177 @@ +import { scanCommandPath, parseFlags } from "./args.ts"; +import { registry } from "./registry.ts"; +import { + GLOBAL_OPTIONS, + loadConfig, + readConfigFile, + resolveCredential, + trackCommandExecution, + flushTelemetry, + type Region, +} from "bailian-cli-core"; +import { ensureApiKey } from "./utils/ensure-key.ts"; +import { handleError } from "./error-handler.ts"; +import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts"; +import { maybeShowStatusBar } from "./output/status-bar.ts"; +import { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; +import { CLI_VERSION } from "./version.ts"; +import { + printCurrentCommandHelp, + registerCommandHelpPrinter, + setExecutingCommandPath, +} from "./utils/command-help.ts"; + +registerCommandHelpPrinter((commandPath, out) => { + const a = process.argv.slice(2); + const ri = a.indexOf("--region"); + const region = ((ri >= 0 && a[ri + 1]) || + process.env.DASHSCOPE_REGION || + readConfigFile().region || + "cn") as Region; + registry.printHelp(commandPath, out, region); +}); + +// 优雅处理 Ctrl+C +// 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会 +// 落网络;flush 与较短超时 race,保证 SIGINT 仍然响应及时。 +process.on("SIGINT", () => { + process.stderr.write("\nInterrupted. Exiting.\n"); + void flushTelemetry(500).finally(() => process.exit(130)); +}); + +// 优雅处理 stdout EPIPE(例如管道到提前退出的 `mpv`) +process.stdout.on("error", (e: NodeJS.ErrnoException) => { + if (e.code === "EPIPE") process.exit(0); + else throw e; +}); + +// 自己接管鉴权 或 根本不需要 API key 的命令 +const NO_AUTH_SETUP = [ + ["auth", "login"], + ["auth", "logout"], + ["config", "show"], + ["config", "set"], + ["config", "export-schema"], + ["update"], + ["knowledge", "retrieve"], + ["pipeline", "run"], + ["pipeline", "validate"], + ["model", "list"], + ["app", "list"], + ["console", "call"], + ["usage", "free"], +]; + +async function main() { + const argv = process.argv.slice(2); + + if (argv.includes("--version") || argv.includes("-v")) { + process.stdout.write(`bl ${CLI_VERSION}\n`); + process.exit(0); + } + + const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS); + + if (argv.includes("--help") || argv.includes("-h")) { + const ri = argv.indexOf("--region"); + const region = ((ri >= 0 && argv[ri + 1]) || + process.env.DASHSCOPE_REGION || + readConfigFile().region || + "cn") as Region; + registry.printHelp(commandPath, process.stderr, region); + process.exit(0); + } + + // 未传任何命令:展示帮助信息与登录引导 + if (commandPath.length === 0) { + registry.printHelp([], process.stderr); + + const flags = parseFlags(argv, GLOBAL_OPTIONS); + const config = loadConfig(flags); + config.clientName = "bailian-cli"; + config.clientVersion = CLI_VERSION; + + const hasKey = !!( + config.apiKey || + config.fileApiKey || + config.fileAccessToken || + config.accessTokenEnv + ); + if (hasKey) printQuickStart(); + else printWelcomeBanner(); + process.exit(0); + } + + // 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出 + if (registry.isGroupPath(commandPath)) { + const ri = argv.indexOf("--region"); + const region = ((ri >= 0 && argv[ri + 1]) || + process.env.DASHSCOPE_REGION || + readConfigFile().region || + "cn") as Region; + registry.printHelp(commandPath, process.stderr, region); + process.exit(0); + } + + const { command, extra } = registry.resolve(commandPath); + const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]); + + if (extra.length > 0) (flags as Record)._positional = extra; + + const config = loadConfig(flags); + config.clientName = "bailian-cli"; + config.clientVersion = CLI_VERSION; + + const needsAuthSetup = !NO_AUTH_SETUP.some((cmd) => cmd.every((c, i) => commandPath[i] === c)); + if (needsAuthSetup) { + await ensureApiKey(config); + try { + const credential = await resolveCredential(config); + maybeShowStatusBar(config, credential.token, credential); + } catch { + /* 没有凭证,不展示状态栏 */ + } + } + + const updateCheckPromise = checkForUpdate(CLI_VERSION).catch(() => {}); + + setExecutingCommandPath(commandPath); + + if ( + commandPath[0] === "auth" && + commandPath[1] === "login" && + !flags.console && + !String((flags.apiKey as string | undefined) ?? "").trim() && + !String(config.apiKey ?? "").trim() && + !process.env.DASHSCOPE_API_KEY?.trim() + ) { + printCurrentCommandHelp(process.stderr); + process.exit(0); + } + + await trackCommandExecution(config, commandPath, flags, () => command.execute(config, flags)); + + await updateCheckPromise; + const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update"; + const newVersion = getPendingUpdateNotification(); + if (newVersion && !config.quiet && !isUpdateCommand) { + const isTTY = process.stderr.isTTY; + const yellow = isTTY ? "\x1b[33m" : ""; + const cyan = isTTY ? "\x1b[36m" : ""; + const reset = isTTY ? "\x1b[0m" : ""; + process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`); + process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`); + } + + // 进程退出前尽力等待在途的埋点完成。 + // 使用较短超时兜底,避免慢网拖慢用户感知。 + await flushTelemetry(1000); +} + +main().catch((err) => { + // 在 handleError() 调用 process.exit() 之前刷出在途埋点。 + // 命令抛出的错误已被 trackCommandExecution 的 finally 块记录, + // 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话, + // 错误事件会随进程退出丢掉。 + void flushTelemetry(1000).finally(() => handleError(err)); +}); diff --git a/packages/cli/src/output/banner.ts b/packages/cli/src/output/banner.ts new file mode 100644 index 0000000..15f2601 --- /dev/null +++ b/packages/cli/src/output/banner.ts @@ -0,0 +1,34 @@ +import { API_KEY_PAGE } from "../urls.ts"; + +const QUICK_START_TASKS = [ + "帮我生成一套鸭舌帽的亚马逊电商主图(白底 + 场景图 + 模特上身图)", + "帮我生成一段 3 分钟的幽默相声音频", + "帮我生成一套小红帽故事绘本 PDF(含插图)", + "帮我分析这个视频的内容并写一篇小红书文案", +]; + +function colors() { + const isTTY = process.stderr.isTTY; + return { + purple: isTTY ? "\x1b[38;2;147;51;234m" : "", + dim: isTTY ? "\x1b[2m" : "", + reset: isTTY ? "\x1b[0m" : "", + }; +} + +export function printWelcomeBanner(): void { + const { purple, reset } = colors(); + process.stderr.write(`\n Welcome to ${purple}Bailian${reset} CLI!\n\n`); + process.stderr.write(" Get started in 2 steps:\n"); + process.stderr.write(` 1. Get your API Key: ${API_KEY_PAGE}\n`); + process.stderr.write(" 2. Login: bl auth login --api-key \n\n"); +} + +export function printQuickStart(): void { + const { dim, reset } = colors(); + process.stderr.write("\n🎯 Try these with your AI coding assistant:\n\n"); + QUICK_START_TASKS.forEach((task, i) => { + process.stderr.write(`${dim}${i + 1}${reset} ${task}\n`); + }); + process.stderr.write("\n"); +} diff --git a/packages/cli/src/output/output.ts b/packages/cli/src/output/output.ts new file mode 100644 index 0000000..54de51a --- /dev/null +++ b/packages/cli/src/output/output.ts @@ -0,0 +1,23 @@ +import { formatOutput, type OutputFormat } from "bailian-cli-core"; + +/** + * Emit the primary result of a command. + * + * Design principle: + * stdout → structured data only (JSON when piped, text when TTY) + * stderr → human info (progress, logs, tips) — handled elsewhere + * + * This ensures `bl cmd ... | jq .` always receives clean JSON, + * while interactive users see human-readable text. + */ +export function emitResult(data: unknown, format: OutputFormat): void { + process.stdout.write(formatOutput(data, format) + "\n"); +} + +/** + * Emit a bare value (file path, plain text) to stdout. + * Used in --quiet mode or when the result is a single scalar. + */ +export function emitBare(value: string): void { + process.stdout.write(value + "\n"); +} diff --git a/packages/cli/src/output/progress.ts b/packages/cli/src/output/progress.ts new file mode 100644 index 0000000..d450799 --- /dev/null +++ b/packages/cli/src/output/progress.ts @@ -0,0 +1,66 @@ +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +export interface Spinner { + start(): void; + update(text: string): void; + stop(finalText?: string): void; +} + +export function createSpinner(label: string): Spinner { + const isTTY = process.stderr.isTTY; + let frame = 0; + let interval: ReturnType | null = null; + let currentLabel = label; + + return { + start() { + if (!isTTY) return; + interval = setInterval(() => { + process.stderr.write(`\r${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentLabel}`); + frame++; + }, 80); + }, + update(text: string) { + currentLabel = text; + }, + stop(finalText?: string) { + if (interval) { + clearInterval(interval); + interval = null; + } + if (isTTY) { + process.stderr.write("\r\x1b[K"); + if (finalText) { + process.stderr.write(`${finalText}\n`); + } + } + }, + }; +} + +export interface ProgressBar { + update(current: number): void; + finish(): void; +} + +export function createProgressBar(total: number, label = ""): ProgressBar { + const isTTY = process.stderr.isTTY; + const width = 30; + + return { + update(current: number) { + if (!isTTY) return; + const pct = Math.min(1, current / total); + const filled = Math.round(width * pct); + const empty = width - filled; + const bar = "█".repeat(filled) + "░".repeat(empty); + const pctStr = `${Math.round(pct * 100)}%`; + process.stderr.write(`\r${label} ${bar} ${pctStr}`); + }, + finish() { + if (isTTY) { + process.stderr.write("\n"); + } + }, + }; +} diff --git a/packages/cli/src/output/prompt.ts b/packages/cli/src/output/prompt.ts new file mode 100644 index 0000000..909e29c --- /dev/null +++ b/packages/cli/src/output/prompt.ts @@ -0,0 +1,114 @@ +/** + * Interactive prompt utilities. + * + * Wraps @clack/prompts with environment-awareness: + * - In interactive mode: shows prompts and lets users input values. + * - In non-interactive / CI / Agent mode: fails fast with a clear error. + * + * All functions here are no-ops (return undefined) when non-interactive, + * so callers must check isInteractive() first or handle the missing-value + * case explicitly. + */ + +import { BailianError, ExitCode, isInteractive } from "bailian-cli-core"; +import { printCurrentCommandHelp, getExecutingCommandPath } from "../utils/command-help.ts"; + +// Dynamic import to avoid loading @clack/prompts in non-interactive envs unnecessarily +// (though for CLI tools the startup cost is usually acceptable) + +/** + * Prompt the user for a text value. + * Only call this when isInteractive() is true; otherwise the function returns + * undefined immediately so the caller can fail fast. + */ +export async function promptText(options: { + message: string; + defaultValue?: string; +}): Promise { + if (!isInteractive()) return undefined; + + const { defaultValue, message } = options; + const inquirer = (await import("@clack/prompts")) as { + text: (opts: { + message: string; + default?: string; + placeholder?: string; + }) => Promise; + }; + const val = await inquirer.text({ + message, + default: defaultValue, + placeholder: defaultValue, + }); + + // @clack/prompts returns a Symbol.cancel when the user presses Ctrl+C + if (typeof val === "symbol") return undefined; + return val as string; +} + +/** + * Like promptText but confirms with y/N before proceeding. + */ +export async function promptConfirm(options: { + message: string; + initialValue?: boolean; +}): Promise { + if (!isInteractive()) return undefined; + + const { message, initialValue } = options; + const inquirer = (await import("@clack/prompts")) as { + confirm: (opts: { message: string; initialValue?: boolean }) => Promise; + }; + const val = await inquirer.confirm({ message, initialValue }); + + if (typeof val === "symbol") return undefined; + return val as boolean; +} + +/** + * Prompt the user to select one value from a list. + * Only call this when isInteractive() is true; otherwise the function returns + * undefined immediately so the caller can fail fast. + */ +export async function promptSelect(options: { + message: string; + choices: Array<{ value: string; label: string; hint?: string }>; + defaultValue?: string; +}): Promise { + if (!isInteractive()) return undefined; + + const { message, choices, defaultValue } = options; + const clack = (await import("@clack/prompts")) as { + select: (opts: { + message: string; + initialValue?: string; + options: Array<{ value: string; label: string; hint?: string }>; + }) => Promise; + }; + const val = await clack.select({ + message, + initialValue: defaultValue, + options: choices, + }); + + if (typeof val === "symbol") return undefined; + return val as string; +} + +/** + * Fail fast with a user-friendly error when a required option is missing + * in non-interactive (agent / CI) mode. + */ +export function failIfMissing(flagName: string, context: string): never { + if (getExecutingCommandPath().length > 0) { + printCurrentCommandHelp(process.stderr); + process.exit(0); + } + throw new BailianError( + `Missing required argument: --${flagName}\n` + + `Hint: In non-interactive (CI / agent) environments all required flags must be provided.\n` + + ` In an interactive terminal, run without --${flagName} and the CLI will prompt for it.`, + ExitCode.USAGE, + context, + ); +} diff --git a/packages/cli/src/output/status-bar.ts b/packages/cli/src/output/status-bar.ts new file mode 100644 index 0000000..9797ead --- /dev/null +++ b/packages/cli/src/output/status-bar.ts @@ -0,0 +1,39 @@ +import { homedir } from "os"; +import { maskToken, type Config, type ResolvedCredential } from "bailian-cli-core"; + +const reset = "\x1b[0m"; +const dim = "\x1b[2m"; +const bold = "\x1b[1m"; +const mmBlue = "\x1b[38;2;43;82;255m"; +const mmCyan = "\x1b[38;2;6;184;212m"; +const mmPink = "\x1b[38;2;236;72;153m"; + +function tildePath(p: string): string { + return p.startsWith(homedir()) ? p.replace(homedir(), "~") : p; +} + +export function maybeShowStatusBar( + config: Config, + token: string, + resolved?: ResolvedCredential, +): void { + if (config.quiet || !process.stderr.isTTY) return; + + const filePath = config.configPath ? tildePath(config.configPath) : "~/.bailian/config.json"; + const regionSrc = config.fileRegion ? `${config.fileRegion} (file)` : "cn (default)"; + const authTag = resolved + ? `${resolved.source} · ${resolved.method}` + : config.apiKey + ? "flag · api-key" + : "config"; + const maskedKey = maskToken(token); + + process.stderr.write( + `${bold}${mmBlue}BAILIAN${reset} ` + + `${dim}${filePath}${reset} ` + + `${dim}|${reset} ` + + `${dim}Region:${reset} ${mmCyan}${regionSrc}${reset} ` + + `${dim}|${reset} ` + + `${dim}Auth:${reset} ${mmPink}${maskedKey}${reset} ${dim}${authTag}${reset}\n`, + ); +} diff --git a/packages/cli/src/pipeline/bl-config.ts b/packages/cli/src/pipeline/bl-config.ts new file mode 100644 index 0000000..10007ec --- /dev/null +++ b/packages/cli/src/pipeline/bl-config.ts @@ -0,0 +1,24 @@ +import { loadConfig, type Config, type GlobalFlags } from "bailian-cli-core"; + +const PIPELINE_FLAGS: GlobalFlags = { + output: "json", + nonInteractive: true, + noColor: true, + quiet: true, + verbose: false, + yes: false, + dryRun: false, + help: false, + async: false, +}; + +/** + * Build a Config suitable for in-process API calls from within pipeline steps. + * Uses the same config resolution (env vars, config file) as the CLI itself, + * but forces JSON output + non-interactive + quiet mode. + */ +export function buildPipelineConfig(): Config { + const config = loadConfig(PIPELINE_FLAGS); + config.clientName = "bailian-cli"; + return config; +} diff --git a/packages/cli/src/pipeline/dispatcher.ts b/packages/cli/src/pipeline/dispatcher.ts new file mode 100644 index 0000000..b0ad592 --- /dev/null +++ b/packages/cli/src/pipeline/dispatcher.ts @@ -0,0 +1,96 @@ +import { PipelineError } from "./errors.ts"; +import type { StepHandler, StepResult, StepContext, StepOutputSchema } from "./types.ts"; + +export class StepDispatcher { + private readonly handlers = new Map(); + private readonly outputSchemas = new Map(); + + registerStep(id: string, handler: StepHandler, outputSchema?: StepOutputSchema): void { + this.handlers.set(id, handler); + if (outputSchema) this.outputSchemas.set(id, outputSchema); + } + + executeStep( + id: string, + input: Record, + ctx: StepContext, + ): Promise | StepResult { + const handler = this.handlers.get(id); + if (!handler) { + throw new PipelineError("unknown_step", `Unknown step type: ${id}`, { + details: { available: this.listSteps() }, + }); + } + return handler(input, ctx); + } + + hasStep(id: string): boolean { + return this.handlers.has(id); + } + + listSteps(): string[] { + return Array.from(this.handlers.keys()).sort(); + } + + getOutputSchema(id: string): StepOutputSchema | undefined { + return this.outputSchemas.get(id); + } +} + +const defaultDispatcher = new StepDispatcher(); + +export function createStepDispatcher(): StepDispatcher { + return new StepDispatcher(); +} + +export function getDefaultStepDispatcher(): StepDispatcher { + return defaultDispatcher; +} + +export function registerStep( + id: string, + handler: StepHandler, + dispatcher?: StepDispatcher, + outputSchema?: StepOutputSchema, +): void; +export function registerStep( + id: string, + handler: StepHandler, + outputSchema?: StepOutputSchema, +): void; +export function registerStep( + id: string, + handler: StepHandler, + dispatcherOrSchema?: StepDispatcher | StepOutputSchema, + outputSchema?: StepOutputSchema, +): void { + let dispatcher: StepDispatcher; + let schema: StepOutputSchema | undefined; + + if (dispatcherOrSchema instanceof StepDispatcher) { + dispatcher = dispatcherOrSchema; + schema = outputSchema; + } else { + dispatcher = defaultDispatcher; + schema = dispatcherOrSchema ?? outputSchema; + } + + dispatcher.registerStep(id, handler, schema); +} + +export function executeStep( + id: string, + input: Record, + ctx: StepContext, + dispatcher = defaultDispatcher, +): Promise | StepResult { + return dispatcher.executeStep(id, input, ctx); +} + +export function hasStep(id: string, dispatcher = defaultDispatcher): boolean { + return dispatcher.hasStep(id); +} + +export function listSteps(dispatcher = defaultDispatcher): string[] { + return dispatcher.listSteps(); +} diff --git a/packages/cli/src/pipeline/errors.ts b/packages/cli/src/pipeline/errors.ts new file mode 100644 index 0000000..1c2e759 --- /dev/null +++ b/packages/cli/src/pipeline/errors.ts @@ -0,0 +1,43 @@ +import type { StructuredStepErrorShape } from "./types.ts"; + +export class PipelineError extends Error { + readonly code: string; + readonly step?: string; + readonly details?: Record; + + constructor( + code: string, + message: string, + options?: { step?: string; details?: Record }, + ) { + super(message); + this.name = "PipelineError"; + this.code = code; + this.step = options?.step; + this.details = options?.details; + } + + toJSON(): StructuredStepErrorShape { + return { + code: this.code, + message: this.message, + ...(this.step ? { step: this.step } : {}), + ...(this.details ? { details: this.details } : {}), + }; + } +} + +export class PipelineValidationError extends PipelineError { + readonly issues: string[]; + + constructor(message: string, issues: string[], step?: string) { + super("pipeline_validation_error", message, { step, details: { issues } }); + this.issues = issues; + } +} + +export function toPipelineError(err: unknown, step?: string): PipelineError { + if (err instanceof PipelineError) return err; + const message = err instanceof Error ? err.message : String(err); + return new PipelineError("step_execution_error", message, { step }); +} diff --git a/packages/cli/src/pipeline/executor.ts b/packages/cli/src/pipeline/executor.ts new file mode 100644 index 0000000..2ce59e0 --- /dev/null +++ b/packages/cli/src/pipeline/executor.ts @@ -0,0 +1,706 @@ +import { PipelineError, toPipelineError } from "./errors.ts"; +import { buildPipelineConfig } from "./bl-config.ts"; +import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts"; +import { + evaluateCondition, + redactSensitiveOutput, + redactStructuredError, + resolvePlannedStepInput, + resolveStepInput, +} from "./expressions.ts"; +import { + buildExecutionPlan, + nextReadySteps, + normalizeConcurrency, + orderReports, + topologicalOrder, +} from "./scheduler.ts"; +import { + OUTPUT_PATH_KEYS, + checkInputPaths, + parseTimeoutSeconds, + preflightCheckInputFiles, + resolveInputPaths, +} from "./utils.ts"; +import { collectPipelineIssues, validatePipelineRuntimeInput } from "./validation.ts"; +import { + WORKFLOW_VERSION, + type ExecutePipelineOptions, + type PipelineDefinition, + type PipelineEventInputSummary, + type PipelineEventOutputSummary, + type PipelineEventStep, + type PipelineEventTiming, + type PipelineExecutionReport, + type PipelineLifecycleEvent, + type PipelinePlanStep, + type PipelineRetryPolicy, + type PipelineStep, + type PipelineStepReport, + type StepArtifact, + type StepContext, + type StepResult, + type StructuredStepErrorShape, +} from "./types.ts"; +export { validatePipeline } from "./validation.ts"; + +const RETRY_BACKOFF_BASE_MS = 100; + +export async function executePipeline( + pipeline: PipelineDefinition, + runtimeInput: Record = {}, + options: ExecutePipelineOptions = {}, +): Promise { + return await executePipelineInternal(pipeline, runtimeInput, options); +} + +export async function* streamPipelineEvents( + pipeline: PipelineDefinition, + runtimeInput: Record = {}, + options: Pick< + ExecutePipelineOptions, + | "concurrency" + | "basePath" + | "dryRun" + | "signal" + | "timeoutSeconds" + | "blRequestTimeoutSeconds" + | "stepDispatcher" + > = {}, +): AsyncGenerator { + const queue = new AsyncEventQueue(1024); + const controller = new AbortController(); + const abortFromParent = () => controller.abort(); + if (options.signal?.aborted) abortFromParent(); + else options.signal?.addEventListener("abort", abortFromParent, { once: true }); + let done = false; + let error: unknown; + + void executePipelineInternal(pipeline, runtimeInput, { + ...options, + signal: controller.signal, + onEvent: async (event) => { + await queue.push(event); + }, + }).then( + () => { + done = true; + queue.close(); + }, + (err) => { + error = err; + done = true; + queue.close(); + }, + ); + + try { + while (!done || queue.size > 0) { + const event = await queue.shift(); + if (event) { + yield event; + continue; + } + if (error) throw error; + } + if (error) throw error; + } finally { + controller.abort(); + options.signal?.removeEventListener("abort", abortFromParent); + queue.close(); + } +} + +async function executePipelineInternal( + pipeline: PipelineDefinition, + runtimeInput: Record, + options: ExecutePipelineOptions, +): Promise { + const stepDispatcher = options.stepDispatcher ?? getDefaultStepDispatcher(); + const issues = collectPipelineIssues(pipeline, stepDispatcher); + if (issues.length > 0) { + throw new PipelineError("pipeline_validation_error", "Invalid pipeline definition", { + details: { issues }, + }); + } + const normalizedRuntimeInput = validatePipelineRuntimeInput(pipeline, runtimeInput); + + if (options.basePath) { + const preflightIssues = await preflightCheckInputFiles(pipeline.steps, options.basePath); + if (preflightIssues.length > 0) { + throw new PipelineError("pipeline_file_not_found", preflightIssues.join("; "), { + details: { issues: preflightIssues }, + }); + } + } + + const blConfig = buildPipelineConfig(); + const plan = buildExecutionPlan(pipeline); + const concurrency = normalizeConcurrency(options.concurrency); + const reports: PipelineStepReport[] = []; + const reportByStep = new Map(); + const outputs = new Map(); + const artifacts: StepArtifact[] = []; + const emit = async (event: PipelineLifecycleEvent) => { + await options.onEvent?.(event); + }; + + await emit({ + type: "pipeline.started", + timestamp: now(), + status: options.dryRun ? "planned" : "running", + stepCount: pipeline.steps.length, + dryRun: !!options.dryRun, + }); + + if (options.dryRun) { + for (const planStep of topologicalOrder(plan)) { + const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput); + const report: PipelineStepReport = { + id: planStep.step.id, + type: planStep.step.type, + status: "planned", + dependencies: planStep.dependencies, + input: resolved.redacted, + ...(planStep.step.when !== undefined ? { condition: "pending" } : {}), + }; + reports.push(report); + await emit({ + type: "step.input.resolved", + timestamp: now(), + status: "planned", + step: stepEvent(planStep), + input: inputSummary(resolved.redacted, resolved.sensitiveKeys), + }); + await emit({ + type: "step.planned", + timestamp: now(), + status: "planned", + step: stepEvent(planStep), + input: inputSummary(resolved.redacted, resolved.sensitiveKeys), + ...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}), + }); + } + await emit({ + type: "pipeline.planned", + timestamp: now(), + status: "planned", + stepCount: reports.length, + artifactCount: artifacts.length, + }); + return { status: "planned", version: WORKFLOW_VERSION, steps: reports, artifacts }; + } + + const remaining = new Set(plan.map((item) => item.step.id)); + const inFlight = new Map>(); + + while (remaining.size > 0 || inFlight.size > 0) { + let progressed = false; + for (const planStep of nextReadySteps(plan, remaining, reportByStep)) { + if (inFlight.size >= concurrency) break; + if (!remaining.has(planStep.step.id)) continue; + + const dependencyReports = planStep.dependencies.map((id) => reportByStep.get(id)); + const failedDependency = dependencyReports.find((report) => report?.status === "failed"); + if (failedDependency) { + const reason = `dependency ${failedDependency.id} failed`; + pushReport(reports, reportByStep, { + id: planStep.step.id, + type: planStep.step.type, + status: "skipped", + dependencies: planStep.dependencies, + skipReason: reason, + }); + await emit({ + type: "step.skipped", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + reason, + }); + remaining.delete(planStep.step.id); + progressed = true; + continue; + } + + const allDependenciesSkipped = + dependencyReports.length > 0 && + dependencyReports.every((report) => report?.status === "skipped"); + if (allDependenciesSkipped) { + const reason = "all dependencies skipped"; + pushReport(reports, reportByStep, { + id: planStep.step.id, + type: planStep.step.type, + status: "skipped", + dependencies: planStep.dependencies, + skipReason: reason, + }); + await emit({ + type: "step.skipped", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + reason, + }); + remaining.delete(planStep.step.id); + progressed = true; + continue; + } + + const condition = + planStep.step.when !== undefined + ? evaluateCondition(planStep.step.when, pipeline, normalizedRuntimeInput, outputs) + : true; + if (!condition) { + const reason = "condition evaluated to false"; + pushReport(reports, reportByStep, { + id: planStep.step.id, + type: planStep.step.type, + status: "skipped", + dependencies: planStep.dependencies, + skipReason: reason, + condition: "false", + }); + await emit({ + type: "step.skipped", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + reason, + }); + remaining.delete(planStep.step.id); + progressed = true; + continue; + } + + const executing = executePlanStep( + planStep, + pipeline, + normalizedRuntimeInput, + outputs, + artifacts, + emit, + options, + blConfig, + stepDispatcher, + ); + inFlight.set(planStep.step.id, executing); + remaining.delete(planStep.step.id); + progressed = true; + } + if (inFlight.size > 0) { + const report = await Promise.race(inFlight.values()); + inFlight.delete(report.id); + pushReport(reports, reportByStep, report); + progressed = true; + } + if (!progressed) { + throw new PipelineError("pipeline_graph_error", "Workflow graph did not make progress", { + details: { remaining: Array.from(remaining) }, + }); + } + } + + const orderedReports = orderReports(plan, reports); + const failedReport = orderedReports.find((report) => report.status === "failed"); + if (failedReport?.error) { + const failedPlanStep = plan.find((item) => item.step.id === failedReport.id); + await emit({ + type: "pipeline.failed", + timestamp: now(), + status: "failed", + stepCount: orderedReports.length, + artifactCount: artifacts.length, + failedStep: failedPlanStep + ? stepEvent(failedPlanStep) + : { + id: failedReport.id, + type: failedReport.type, + dependencies: failedReport.dependencies, + index: orderedReports.findIndex((report) => report.id === failedReport.id) + 1, + total: orderedReports.length, + }, + error: failedReport.error, + }); + const failedResult: PipelineExecutionReport = { + status: "failed", + version: WORKFLOW_VERSION, + steps: orderedReports, + artifacts, + }; + return failedResult; + } + + await emit({ + type: "pipeline.succeeded", + timestamp: now(), + status: "succeeded", + stepCount: orderedReports.length, + artifactCount: artifacts.length, + }); + const successResult: PipelineExecutionReport = { + status: "succeeded", + version: WORKFLOW_VERSION, + steps: orderedReports, + artifacts, + }; + return successResult; +} + +async function executePlanStep( + planStep: PipelinePlanStep, + pipeline: PipelineDefinition, + runtimeInput: Record, + outputs: Map, + artifacts: StepArtifact[], + emit: (event: PipelineLifecycleEvent) => Promise, + options: ExecutePipelineOptions, + blConfig: unknown, + stepDispatcher: StepDispatcher, +): Promise { + const maxAttempts = Math.max(1, Math.floor(planStep.step.retry?.maxAttempts ?? 1)); + let lastError: StructuredStepErrorShape | undefined; + let lastRedactedInput: Record | undefined; + let lastSensitive = false; + let startedAt = new Date().toISOString(); + let finishedAt = startedAt; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + startedAt = new Date().toISOString(); + await emit({ + type: "step.started", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + timing: { startedAt }, + attempt, + }); + + try { + const resolved = resolveStepInput(planStep.step, pipeline, runtimeInput, outputs); + let resolvedInput = resolved.value; + let resolvedRedacted = resolved.redacted; + + if (options.basePath) { + const paths = resolveInputPaths(resolvedInput, options.basePath); + resolvedInput = paths.input; + resolvedRedacted = resolveInputPaths( + resolved.redacted as Record, + options.basePath, + ).input; + const fileIssues = await checkInputPaths( + resolvedInput, + paths.resolvedKeys.filter((k) => !OUTPUT_PATH_KEYS.includes(k)), + planStep.step.id, + ); + if (fileIssues.length > 0) { + throw new PipelineError("pipeline_file_not_found", fileIssues.join("; "), { + step: planStep.step.type, + details: { issues: fileIssues }, + }); + } + } + + lastRedactedInput = resolvedRedacted as Record; + lastSensitive = resolved.sensitive; + await emit({ + type: "step.input.resolved", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + input: inputSummary(resolvedRedacted as Record, resolved.sensitiveKeys), + }); + + const output = await executeWithTimeout( + planStep.step, + resolvedInput, + options, + stepEvent(planStep), + emit, + blConfig, + stepDispatcher, + ); + outputs.set(planStep.step.id, output); + const reportOutput = resolved.sensitive ? redactSensitiveOutput(output) : output; + if (reportOutput.artifacts) artifacts.push(...reportOutput.artifacts); + finishedAt = new Date().toISOString(); + + for (const artifact of reportOutput.artifacts ?? []) { + await emit({ + type: "artifact.created", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + artifact, + }); + } + await emit({ + type: "step.succeeded", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + timing: timing(startedAt, finishedAt), + output: outputSummary(reportOutput), + attempt, + ...(reportOutput.warnings && reportOutput.warnings.length > 0 + ? { warnings: reportOutput.warnings } + : {}), + }); + + return { + id: planStep.step.id, + type: planStep.step.type, + status: "succeeded", + dependencies: planStep.dependencies, + input: resolved.redacted, + output: reportOutput, + startedAt, + finishedAt, + attempts: attempt, + ...(planStep.step.when !== undefined ? { condition: "true" as const } : {}), + }; + } catch (err) { + const pipelineError = toPipelineError(err, planStep.step.type); + lastError = lastSensitive + ? redactStructuredError(pipelineError.toJSON()) + : pipelineError.toJSON(); + finishedAt = new Date().toISOString(); + if (attempt < maxAttempts) { + await emit({ + type: "step.retrying", + timestamp: now(), + status: "running", + step: stepEvent(planStep), + attempt, + nextAttempt: attempt + 1, + error: lastError, + }); + const delayMs = retryDelayMs( + planStep.step.retry?.backoff, + attempt, + options.retryDelayBaseMs ?? RETRY_BACKOFF_BASE_MS, + ); + if (delayMs > 0) await (options.sleep ?? sleep)(delayMs); + continue; + } + } + } + + const error = lastError ?? { + code: "pipeline_step_failed", + message: `Step ${planStep.step.id} failed`, + step: planStep.step.type, + details: {}, + }; + await emit({ + type: "step.failed", + timestamp: now(), + status: "failed", + step: stepEvent(planStep), + timing: timing(startedAt, finishedAt), + attempt: maxAttempts, + error, + }); + return { + id: planStep.step.id, + type: planStep.step.type, + status: "failed", + dependencies: planStep.dependencies, + ...(lastRedactedInput ? { input: lastRedactedInput } : {}), + error, + startedAt, + finishedAt, + attempts: maxAttempts, + }; +} + +async function executeWithTimeout( + step: PipelineStep, + input: Record, + options: ExecutePipelineOptions, + planStepEvent: PipelineEventStep, + emit: (event: PipelineLifecycleEvent) => Promise, + blConfig: unknown, + stepDispatcher: StepDispatcher, +): Promise { + const timeoutSeconds = parseTimeoutSeconds(step.timeout) ?? options.timeoutSeconds; + const emitEvent = async (event: Record) => { + if (event.type === "step.polling") { + await emit({ + ...event, + type: "step.polling", + timestamp: (event.timestamp as string) ?? now(), + status: "running", + step: planStepEvent, + taskId: event.taskId as string, + taskStatus: event.taskStatus as string, + elapsedMs: event.elapsedMs as number, + pollAttempt: event.pollAttempt as number, + }); + } + }; + + const ctx: StepContext = { + dryRun: false, + signal: options.signal, + timeoutSeconds, + blRequestTimeoutSeconds: options.blRequestTimeoutSeconds, + emitEvent, + blConfig, + }; + + if (!timeoutSeconds) return await stepDispatcher.executeStep(step.type, input, ctx); + + const controller = new AbortController(); + const abortFromParent = () => controller.abort(); + if (options.signal?.aborted) abortFromParent(); + else options.signal?.addEventListener("abort", abortFromParent, { once: true }); + const timeoutMs = timeoutSeconds * 1000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + ctx.signal = controller.signal; + + try { + return await Promise.race([ + stepDispatcher.executeStep(step.type, input, ctx), + new Promise((_, reject) => { + controller.signal.addEventListener( + "abort", + () => { + reject( + new PipelineError( + "step_timeout", + `Step ${step.id} exceeded timeout ${timeoutSeconds}s`, + { step: step.type }, + ), + ); + }, + { once: true }, + ); + }), + ]); + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortFromParent); + } +} + +function pushReport( + reports: PipelineStepReport[], + reportByStep: Map, + report: PipelineStepReport, +): void { + reports.push(report); + reportByStep.set(report.id, report); +} + +function now(): string { + return new Date().toISOString(); +} + +function stepEvent(planStep: PipelinePlanStep): PipelineEventStep { + return { + id: planStep.step.id, + type: planStep.step.type, + dependencies: planStep.dependencies, + index: planStep.index, + total: planStep.total, + }; +} + +function inputSummary( + input: Record, + sensitiveKeys: string[] = [], +): PipelineEventInputSummary { + return { + keys: Object.keys(input).sort(), + ...(sensitiveKeys.length > 0 ? { redactedKeys: sensitiveKeys.sort() } : {}), + }; +} + +function outputSummary(output: StepResult): PipelineEventOutputSummary { + return { + ...(output.data !== undefined ? { dataType: dataType(output.data) } : {}), + artifactCount: output.artifacts?.length ?? 0, + warningCount: output.warnings?.length ?? 0, + ...(output.metadata ? { metadata: output.metadata } : {}), + }; +} + +function dataType(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; +} + +function timing(startedAt: string, finishedAt: string): PipelineEventTiming { + return { + startedAt, + finishedAt, + durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)), + }; +} + +function retryDelayMs( + backoff: PipelineRetryPolicy["backoff"] = "none", + failedAttempt: number, + baseMs: number, +): number { + if (backoff === "none") return 0; + if (backoff === "linear") return baseMs * failedAttempt; + return baseMs * 2 ** (failedAttempt - 1); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class AsyncEventQueue { + private readonly items: T[] = []; + private readonly takers: Array<(value: T | undefined) => void> = []; + private readonly pushWaiters: Array<() => void> = []; + private closed = false; + private readonly maxSize: number; + + constructor(maxSize: number) { + this.maxSize = maxSize; + } + + get size(): number { + return this.items.length; + } + + async push(item: T): Promise { + while (!this.closed && this.items.length >= this.maxSize) { + await new Promise((resolve) => this.pushWaiters.push(resolve)); + } + if (this.closed) return; + const taker = this.takers.shift(); + if (taker) { + taker(item); + return; + } + this.items.push(item); + } + + async shift(): Promise { + const item = this.items.shift(); + if (item !== undefined) { + this.wakePushWaiter(); + return item; + } + if (this.closed) return undefined; + return await new Promise((resolve) => this.takers.push(resolve)); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const taker of this.takers.splice(0)) taker(undefined); + for (const waiter of this.pushWaiters.splice(0)) waiter(); + } + + private wakePushWaiter(): void { + this.pushWaiters.shift()?.(); + } +} diff --git a/packages/cli/src/pipeline/expressions.ts b/packages/cli/src/pipeline/expressions.ts new file mode 100644 index 0000000..ef11cf1 --- /dev/null +++ b/packages/cli/src/pipeline/expressions.ts @@ -0,0 +1,503 @@ +import { PipelineError } from "./errors.ts"; +import { getByJsonPointer } from "./schema.ts"; +import type { + StepArtifact, + StepResult, + StructuredStepErrorShape, + PipelineBinding, + PipelineConditionExpression, + PipelineDefinition, + PipelineInputExpression, + PipelineStep, + ResolvedExpression, +} from "./types.ts"; +import { isRecord } from "./utils.ts"; + +const REDACTED = "[redacted]"; + +function stringifyConcatValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value) ?? ""; + return String(value as string | number | boolean | bigint | symbol); +} + +export function resolveStepInput( + step: PipelineStep, + pipeline: PipelineDefinition, + runtimeInput: Record, + outputs: Map, +): { + value: Record; + redacted: Record; + sensitiveKeys: string[]; + sensitive: boolean; +} { + const value: Record = {}; + const redacted: Record = {}; + const sensitiveKeys: string[] = []; + let sensitive = false; + for (const [key, expression] of Object.entries(step.input)) { + const resolved = resolveExpression(expression, pipeline, runtimeInput, outputs); + value[key] = resolved.value; + redacted[key] = resolved.redacted; + if (resolved.sensitive) { + sensitive = true; + sensitiveKeys.push(key); + } + } + return { value, redacted, sensitiveKeys, sensitive }; +} + +export function resolvePlannedStepInput( + step: PipelineStep, + pipeline: PipelineDefinition, + runtimeInput: Record, +): { redacted: Record; sensitiveKeys: string[] } { + const redacted: Record = {}; + const sensitiveKeys: string[] = []; + for (const [key, expression] of Object.entries(step.input)) { + const resolved = resolvePlannedExpression(expression, pipeline, runtimeInput); + redacted[key] = resolved.redacted; + if (resolved.sensitive) sensitiveKeys.push(key); + } + return { redacted, sensitiveKeys }; +} + +export function evaluateCondition( + expression: PipelineConditionExpression, + pipeline: PipelineDefinition, + runtimeInput: Record, + outputs: Map, +): boolean { + return evaluateConditionWithResolver(expression, (inputExpression) => + resolveExpression(inputExpression, pipeline, runtimeInput, outputs), + ); +} + +export function evaluateResolvedCondition(expression: PipelineConditionExpression): boolean { + return evaluateConditionWithResolver(expression, (inputExpression) => + combineResolved(inputExpression, inputExpression, false, inputExpression !== undefined), + ); +} + +export function redactSensitiveOutput(output: StepResult): StepResult { + return { + ...(output.data !== undefined ? { data: REDACTED } : {}), + ...(output.artifacts ? { artifacts: output.artifacts.map(redactArtifact) } : {}), + ...(output.warnings + ? { + warnings: output.warnings.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + } + : {}), + metadata: { + ...(output.metadata?.step ? { step: output.metadata.step } : {}), + redacted: true, + }, + }; +} + +export function redactStructuredError(error: StructuredStepErrorShape): StructuredStepErrorShape { + return { + code: error.code, + message: REDACTED, + ...(error.step ? { step: error.step } : {}), + details: { redacted: true }, + }; +} + +function resolveExpression( + expression: PipelineInputExpression, + pipeline: PipelineDefinition, + runtimeInput: Record, + outputs: Map, +): ResolvedExpression { + if (Array.isArray(expression)) { + const items = expression.map((item) => + resolveExpression(item, pipeline, runtimeInput, outputs), + ); + return combineResolved( + items.map((item) => item.value), + items.map((item) => item.redacted), + items.some((item) => item.sensitive), + ); + } + if (!isExpressionObject(expression)) { + if (!isRecord(expression)) + return combineResolved(expression, expression, false, expression !== undefined); + const value: Record = {}; + const redacted: Record = {}; + let sensitive = false; + for (const [key, child] of Object.entries(expression)) { + const resolved = resolveExpression(child, pipeline, runtimeInput, outputs); + value[key] = resolved.value; + redacted[key] = resolved.redacted; + sensitive = sensitive || resolved.sensitive; + } + return combineResolved(value, redacted, sensitive); + } + if ("$input" in expression) { + const value = getByJsonPointer(runtimeInput, expression.$input as string); + return combineResolved(value, value, false, value !== undefined); + } + if ("$from" in expression) { + const from = expression.$from as string; + const output = outputs.get(from); + if (!output) + throw new PipelineError("pipeline_reference_error", `Step output not found: ${from}`); + const pointer = typeof expression.path === "string" ? expression.path : ""; + const value = getByJsonPointer(output, pointer); + if (value === undefined) + throw new PipelineError( + "pipeline_reference_error", + `Step output path not found: ${from}${pointer}`, + ); + return combineResolved(value, value, false, true); + } + if ("$env" in expression) { + const value = resolveBinding(pipeline.env, expression.$env as string, false); + return combineResolved(value, value, false, value !== undefined); + } + if ("$secret" in expression) { + const value = resolveBinding(pipeline.secrets, expression.$secret as string, true); + return combineResolved(value, REDACTED, true, value !== undefined); + } + if ("$concat" in expression) { + const items = (expression.$concat as PipelineInputExpression[]).map((item) => + resolveExpression(item, pipeline, runtimeInput, outputs), + ); + const value = items.map((item) => stringifyConcatValue(item.value)).join(""); + const sensitive = items.some((item) => item.sensitive); + const redacted = items + .map((item) => (item.sensitive ? REDACTED : stringifyConcatValue(item.value))) + .join(""); + return combineResolved(value, redacted, sensitive); + } + if ("$coalesce" in expression) { + const candidates = expression.$coalesce as PipelineInputExpression[]; + let lastError: unknown; + for (const candidate of candidates) { + try { + const resolved = resolveExpression(candidate, pipeline, runtimeInput, outputs); + if (resolved.exists && resolved.value !== undefined && resolved.value !== null) { + return resolved; + } + } catch (err) { + lastError = err; + continue; + } + } + if (lastError) throw lastError; + return combineResolved(undefined, undefined, false, false); + } + if ("$js" in expression) { + const code = expression.$js as string; + const argsExpressions = (expression.args ?? {}) as Record; + const resolvedArgs: Record = {}; + const redactedArgs: Record = {}; + let sensitive = false; + for (const [key, argExpr] of Object.entries(argsExpressions)) { + const resolved = resolveExpression(argExpr, pipeline, runtimeInput, outputs); + resolvedArgs[key] = resolved.value; + redactedArgs[key] = resolved.redacted; + 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 (err) { + const message = err instanceof Error ? err.message : String(err); + throw new PipelineError("pipeline_js_error", `$js expression failed: ${message}`); + } + } + return combineResolved(expression, expression, false); +} + +function resolvePlannedExpression( + expression: PipelineInputExpression, + pipeline: PipelineDefinition, + runtimeInput: Record, +): ResolvedExpression { + if (Array.isArray(expression)) { + const items = expression.map((item) => resolvePlannedExpression(item, pipeline, runtimeInput)); + return combineResolved( + items.map((item) => item.value), + items.map((item) => item.redacted), + items.some((item) => item.sensitive), + ); + } + if (!isExpressionObject(expression)) { + if (!isRecord(expression)) + return combineResolved(expression, expression, false, expression !== undefined); + const redacted: Record = {}; + let sensitive = false; + for (const [key, child] of Object.entries(expression)) { + const resolved = resolvePlannedExpression(child, pipeline, runtimeInput); + redacted[key] = resolved.redacted; + sensitive = sensitive || resolved.sensitive; + } + return combineResolved(redacted, redacted, sensitive); + } + if ("$input" in expression) { + const value = getByJsonPointer(runtimeInput, expression.$input as string); + return combineResolved(value, value, false, value !== undefined); + } + if ("$from" in expression) return combineResolved({ ...expression }, { ...expression }, false); + if ("$env" in expression) { + const value = resolveBinding(pipeline.env, expression.$env as string, false); + return combineResolved(value, value, false, value !== undefined); + } + if ("$secret" in expression) { + resolveBinding(pipeline.secrets, expression.$secret as string, true); + return combineResolved(REDACTED, REDACTED, true, true); + } + if ("$concat" in expression) { + const items = (expression.$concat as PipelineInputExpression[]).map((item) => + resolvePlannedExpression(item, pipeline, runtimeInput), + ); + const hasFrom = items.some((_, i) => { + const raw = (expression.$concat as PipelineInputExpression[])[i]; + return isRecord(raw) && "$from" in raw; + }); + if (hasFrom) + return combineResolved( + { ...expression }, + { ...expression }, + items.some((item) => item.sensitive), + ); + const value = items.map((item) => stringifyConcatValue(item.value)).join(""); + const sensitive = items.some((item) => item.sensitive); + const redacted = items + .map((item) => (item.sensitive ? REDACTED : stringifyConcatValue(item.value))) + .join(""); + return combineResolved(value, redacted, sensitive); + } + if ("$coalesce" in expression) { + const candidates = expression.$coalesce as PipelineInputExpression[]; + const hasFrom = candidates.some((c) => isRecord(c) && "$from" in c); + if (hasFrom) return combineResolved({ ...expression }, { ...expression }, false); + for (const candidate of candidates) { + const resolved = resolvePlannedExpression(candidate, pipeline, runtimeInput); + if (resolved.exists && resolved.value !== undefined && resolved.value !== null) { + return resolved; + } + } + 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); + } + } + return combineResolved(expression, expression, false); +} + +function resolveBinding( + bindings: Record | undefined, + name: string, + secret: boolean, +): unknown { + if (!hasBinding(bindings, name)) { + throw new PipelineError( + secret ? "pipeline_secret_error" : "pipeline_env_error", + `${secret ? "Secret" : "Environment"} binding is not declared: ${name}`, + ); + } + const binding = bindings![name]!; + const spec = + typeof binding === "string" ? { from: "env" as const, name: binding, required: true } : binding; + const value = spec.from === "env" || spec.from === undefined ? process.env[spec.name] : undefined; + if (value !== undefined && value !== "") return value; + if (spec.default !== undefined) return spec.default; + if (spec.required !== false) { + throw new PipelineError( + secret ? "pipeline_secret_error" : "pipeline_env_error", + `${secret ? "Secret" : "Environment"} binding not found: ${name}`, + ); + } + return undefined; +} + +function hasBinding( + bindings: Record | undefined, + name: string, +): boolean { + return !!bindings && Object.prototype.hasOwnProperty.call(bindings, name); +} + +function combineResolved( + value: unknown, + redacted: unknown, + sensitive: boolean, + exists = true, +): ResolvedExpression { + return { value, redacted, sensitive, exists }; +} + +function evaluateConditionWithResolver( + expression: PipelineConditionExpression, + resolveInput: (expression: PipelineInputExpression) => ResolvedExpression, +): boolean { + if (typeof expression === "boolean") return expression; + if (isRecord(expression) && "$exists" in expression) { + try { + return resolveInput(expression.$exists as PipelineInputExpression).exists; + } catch (err) { + if (err instanceof PipelineError && err.code === "pipeline_reference_error") return false; + throw err; + } + } + if (isRecord(expression) && "$and" in expression) { + if (!Array.isArray(expression.$and) || expression.$and.length === 0) + throw conditionError("$and must be a non-empty array"); + return (expression.$and as PipelineConditionExpression[]).every((condition) => + evaluateConditionWithResolver(condition, resolveInput), + ); + } + if (isRecord(expression) && "$or" in expression) { + if (!Array.isArray(expression.$or) || expression.$or.length === 0) + throw conditionError("$or must be a non-empty array"); + return (expression.$or as PipelineConditionExpression[]).some((condition) => + evaluateConditionWithResolver(condition, resolveInput), + ); + } + if (isRecord(expression) && "$not" in expression) { + return !evaluateConditionWithResolver( + expression.$not as PipelineConditionExpression, + resolveInput, + ); + } + + const binary = binaryCondition(expression); + if (binary) { + const [leftExpression, rightExpression] = binary.operands; + const left = resolveInput(leftExpression).value; + const right = resolveInput(rightExpression).value; + switch (binary.operator) { + case "$eq": + return jsonEqual(left, right); + case "$ne": + return !jsonEqual(left, right); + case "$gt": + return compareOrdered(left, right, binary.operator) > 0; + case "$gte": + return compareOrdered(left, right, binary.operator) >= 0; + case "$lt": + return compareOrdered(left, right, binary.operator) < 0; + case "$lte": + return compareOrdered(left, right, binary.operator) <= 0; + case "$in": + if (!Array.isArray(right)) throw conditionError("$in right operand must be an array"); + return right.some((item) => jsonEqual(item, left)); + case "$contains": + if (Array.isArray(left)) return left.some((item) => jsonEqual(item, right)); + if (typeof left === "string" && typeof right === "string") return left.includes(right); + throw conditionError("$contains left operand must be an array or string"); + } + } + + return Boolean(resolveInput(expression as PipelineInputExpression).value); +} + +type BinaryConditionOperator = + | "$eq" + | "$ne" + | "$gt" + | "$gte" + | "$lt" + | "$lte" + | "$in" + | "$contains"; + +function binaryCondition(expression: PipelineConditionExpression): + | { + operator: BinaryConditionOperator; + operands: [PipelineInputExpression, PipelineInputExpression]; + } + | undefined { + if (!isRecord(expression)) return undefined; + for (const operator of [ + "$eq", + "$ne", + "$gt", + "$gte", + "$lt", + "$lte", + "$in", + "$contains", + ] as const) { + if (operator in expression) { + if (!Array.isArray(expression[operator]) || expression[operator].length !== 2) { + throw conditionError(`${operator} must be a two-item array`); + } + return { + operator, + operands: expression[operator] as [PipelineInputExpression, PipelineInputExpression], + }; + } + } + return undefined; +} + +function jsonEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function compareOrdered(left: unknown, right: unknown, operator: string): number { + if ( + typeof left === "number" && + Number.isFinite(left) && + typeof right === "number" && + Number.isFinite(right) + ) { + return left - right; + } + if (typeof left === "string" && typeof right === "string") return left.localeCompare(right); + throw conditionError(`${operator} operands must both be finite numbers or both be strings`); +} + +function conditionError(message: string): PipelineError { + return new PipelineError("pipeline_condition_error", message); +} + +function redactArtifact(artifact: StepArtifact): StepArtifact { + return { + id: artifact.id, + kind: artifact.kind, + mediaType: artifact.mediaType, + metadata: artifact.metadata ? { redacted: true } : undefined, + }; +} + +function isExpressionObject(value: unknown): value is Record { + return ( + isRecord(value) && + ("$input" in value || + "$from" in value || + "$env" in value || + "$secret" in value || + "$concat" in value || + "$coalesce" in value || + "$js" in value) + ); +} diff --git a/packages/cli/src/pipeline/init.ts b/packages/cli/src/pipeline/init.ts new file mode 100644 index 0000000..8aaa987 --- /dev/null +++ b/packages/cli/src/pipeline/init.ts @@ -0,0 +1,15 @@ +import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts"; +import { registerBlSteps } from "./steps/bl-steps.ts"; +import { registerLogicSteps } from "./steps/logic.ts"; +import { registerScriptJsStep } from "./steps/script-js.ts"; + +const initializedDispatchers = new WeakSet(); + +export function initPipelineSteps(dispatcher = getDefaultStepDispatcher()): StepDispatcher { + if (initializedDispatchers.has(dispatcher)) return dispatcher; + initializedDispatchers.add(dispatcher); + registerBlSteps(dispatcher); + registerLogicSteps(dispatcher); + registerScriptJsStep(dispatcher); + return dispatcher; +} diff --git a/packages/cli/src/pipeline/scheduler.ts b/packages/cli/src/pipeline/scheduler.ts new file mode 100644 index 0000000..8a7ab7a --- /dev/null +++ b/packages/cli/src/pipeline/scheduler.ts @@ -0,0 +1,81 @@ +import { PipelineError } from "./errors.ts"; +import type { PipelineDefinition, PipelinePlanStep, PipelineStepReport } from "./types.ts"; +import { collectFromDependencies } from "./utils.ts"; + +export function buildExecutionPlan(pipeline: PipelineDefinition): PipelinePlanStep[] { + const steps = pipeline.steps.map((step, index) => ({ + step, + dependencies: [] as string[], + dependents: [] as string[], + index: index + 1, + total: pipeline.steps.length, + })); + const byId = new Map(steps.map((item) => [item.step.id, item])); + for (const item of steps) { + const dependencies = new Set(item.step.dependsOn ?? []); + collectFromDependencies(item.step.input, dependencies); + if (item.step.when !== undefined) collectFromDependencies(item.step.when, dependencies); + item.dependencies = Array.from(dependencies).sort( + (a, b) => + pipeline.steps.findIndex((step) => step.id === a) - + pipeline.steps.findIndex((step) => step.id === b), + ); + for (const dependency of item.dependencies) byId.get(dependency)?.dependents.push(item.step.id); + } + return steps; +} + +export function topologicalOrder(plan: PipelinePlanStep[]): PipelinePlanStep[] { + const remaining = new Set(plan.map((item) => item.step.id)); + const done = new Set(); + const ordered: PipelinePlanStep[] = []; + while (remaining.size > 0) { + let progressed = false; + for (const item of plan) { + if (!remaining.has(item.step.id)) continue; + if (!item.dependencies.every((dependency) => done.has(dependency))) continue; + ordered.push(item); + done.add(item.step.id); + remaining.delete(item.step.id); + progressed = true; + } + if (!progressed) break; + } + return ordered; +} + +export function nextReadySteps( + plan: PipelinePlanStep[], + remaining: Set, + reportByStep: Map, +): PipelinePlanStep[] { + return plan.filter((item) => { + if (!remaining.has(item.step.id)) return false; + return item.dependencies.every((id) => { + const report = reportByStep.get(id); + return ( + report?.status === "succeeded" || + report?.status === "failed" || + report?.status === "skipped" + ); + }); + }); +} + +export function orderReports( + plan: PipelinePlanStep[], + reports: PipelineStepReport[], +): PipelineStepReport[] { + const index = new Map(plan.map((item, order) => [item.step.id, order])); + return [...reports].sort((a, b) => (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0)); +} + +export function normalizeConcurrency(value: number | undefined): number { + if (value === undefined) return 1; + if (!Number.isInteger(value) || value < 1) { + throw new PipelineError("pipeline_validation_error", "Invalid pipeline execution options", { + details: { issues: ["concurrency must be a positive integer"] }, + }); + } + return value; +} diff --git a/packages/cli/src/pipeline/schema.ts b/packages/cli/src/pipeline/schema.ts new file mode 100644 index 0000000..744d826 --- /dev/null +++ b/packages/cli/src/pipeline/schema.ts @@ -0,0 +1,278 @@ +import AjvModule, { type ErrorObject } from "ajv"; +import { isRecord } from "./utils.ts"; +import type { JsonSchema, JsonSchemaPrimitiveType } from "./types.ts"; + +interface AjvConstructor { + new (opts?: object): { + validateSchema(schema: object): boolean; + errors?: ErrorObject[] | null; + }; +} + +const Ajv = AjvModule as unknown as AjvConstructor; + +const SUPPORTED_SCHEMA_KEYS = new Set([ + "type", + "properties", + "items", + "required", + "enum", + "default", + "description", + "format", + "additionalProperties", +]); + +const SUPPORTED_TYPES = new Set([ + "string", + "number", + "integer", + "boolean", + "array", + "object", + "null", +]); + +const ajv = new Ajv({ + allErrors: true, + coerceTypes: false, + strict: false, + useDefaults: false, +}); + +export function validateJsonSchema(schema: unknown, path = "schema"): string[] { + const issues: string[] = []; + validateJsonSchemaInto(schema, path, issues); + if (issues.length === 0 && !ajv.validateSchema(schema as object)) { + issues.push(...formatAjvErrors(ajv.errors, path)); + } + return issues; +} + +export function validateInputAgainstSchema( + schema: JsonSchema, + rawInput: Record, +): { ok: true; value: Record } | { ok: false; issues: string[] } { + const result = coerceAgainstSchema(rawInput, schema, "input"); + if (!result.ok) return { ok: false, issues: result.issues }; + return isRecord(result.value) + ? { ok: true, value: result.value } + : { ok: false, issues: ["input must be an object"] }; +} + +export interface JsonPointerParseResult { + ok: boolean; + segments: string[]; + issue?: string; +} + +export function parseJsonPointer(pointer: string): JsonPointerParseResult { + if (pointer === "") return { ok: true, segments: [] }; + if (!pointer.startsWith("/")) return { ok: false, segments: [], issue: "must start with /" }; + const segments = pointer.slice(1).split("/"); + const decoded: string[] = []; + for (const segment of segments) { + const invalidEscape = segment.match(/~(?![01])/); + if (invalidEscape) + return { ok: false, segments: [], issue: "contains invalid escape sequence" }; + decoded.push(segment.replace(/~1/g, "/").replace(/~0/g, "~")); + } + return { ok: true, segments: decoded }; +} + +export function getByJsonPointer(value: unknown, pointer: string): unknown { + const parsed = parseJsonPointer(pointer); + if (!parsed.ok) return undefined; + let current = value; + for (const segment of parsed.segments) { + if (Array.isArray(current)) { + if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined; + current = current[Number(segment)]; + continue; + } + if (isRecord(current)) { + current = current[segment]; + continue; + } + return undefined; + } + return current; +} + +export function isValidJsonPointer(pointer: string): boolean { + return parseJsonPointer(pointer).ok; +} + +function validateJsonSchemaInto(schema: unknown, path: string, issues: string[]): void { + if (!isRecord(schema)) { + issues.push(`${path} must be an object`); + return; + } + for (const key of Object.keys(schema)) { + if (!SUPPORTED_SCHEMA_KEYS.has(key)) issues.push(`${path} has unsupported keyword "${key}"`); + } + if ("type" in schema) validateType(schema.type, `${path}.type`, issues); + if ("properties" in schema) { + if (!isRecord(schema.properties)) { + issues.push(`${path}.properties must be an object`); + } else { + for (const [name, child] of Object.entries(schema.properties)) { + validateJsonSchemaInto(child, `${path}.properties.${name}`, issues); + } + } + } + if ("items" in schema) validateJsonSchemaInto(schema.items, `${path}.items`, issues); + if ( + "required" in schema && + (!Array.isArray(schema.required) || !schema.required.every((item) => typeof item === "string")) + ) { + issues.push(`${path}.required must be an array of strings`); + } + if ("enum" in schema && !Array.isArray(schema.enum)) issues.push(`${path}.enum must be an array`); + if ("description" in schema && typeof schema.description !== "string") + issues.push(`${path}.description must be a string`); + if ("format" in schema && typeof schema.format !== "string") + issues.push(`${path}.format must be a string`); + if ("additionalProperties" in schema) { + const additional = schema.additionalProperties; + if (additional !== true && additional !== false && !isRecord(additional)) { + issues.push(`${path}.additionalProperties must be a boolean or schema object`); + } else if (isRecord(additional)) { + validateJsonSchemaInto(additional, `${path}.additionalProperties`, issues); + } + } +} + +function formatAjvErrors(errors: ErrorObject[] | null | undefined, path: string): string[] { + if (!errors || errors.length === 0) return [`${path} is invalid`]; + return errors.map((error) => { + const location = error.instancePath ? `${path}${error.instancePath.replace(/\//g, ".")}` : path; + const detail = error.message ?? "is invalid"; + const keyword = error.keyword ? ` (${error.keyword})` : ""; + return `${location} ${detail}${keyword}`; + }); +} + +function validateType(type: unknown, path: string, issues: string[]): void { + const values = Array.isArray(type) ? type : [type]; + if (values.length === 0) { + issues.push(`${path} must not be empty`); + return; + } + for (const value of values) { + if (!SUPPORTED_TYPES.has(value as JsonSchemaPrimitiveType)) { + issues.push(`${path} has unsupported type "${String(value)}"`); + } + } +} + +function coerceAgainstSchema( + value: unknown, + schema: JsonSchema, + path: string, +): { ok: true; value: unknown } | { ok: false; issues: string[] } { + if ((value === undefined || value === null || value === "") && schema.default !== undefined) { + value = schema.default; + } + + if (schema.enum && value !== undefined) { + const enumStrs = schema.enum.map(stringifyEnumValue); + if (!enumStrs.includes(stringifyEnumValue(value))) { + return { ok: false, issues: [`${path} must be one of: ${enumStrs.join(", ")}`] }; + } + } + + const types = schemaTypes(schema); + if (types.size === 0) return { ok: true, value }; + + if (types.size > 1) { + if (Array.isArray(value) && types.has("array")) { + if (!schema.items) return { ok: true, value }; + const normalized: unknown[] = []; + const issues: string[] = []; + value.forEach((item, index) => { + const result = coerceAgainstSchema(item, schema.items!, `${path}.${index}`); + if (result.ok) normalized[index] = result.value; + else issues.push(...result.issues); + }); + return issues.length > 0 ? { ok: false, issues } : { ok: true, value: normalized }; + } + if (isRecord(value) && types.has("object")) { + // fall through to the existing object branch below + } else if (types.has("string") && !Array.isArray(value) && !isRecord(value)) { + return { ok: true, value: String(value) }; + } + } + + if (types.has("object")) { + if (!isRecord(value)) return { ok: false, issues: [`${path} must be an object`] }; + const normalized: Record = { ...value }; + const issues: string[] = []; + for (const name of schema.required ?? []) { + const child = normalized[name]; + if (child === undefined || child === null || child === "") + issues.push(`Missing required input "${name}"`); + } + for (const [name, childSchema] of Object.entries(schema.properties ?? {})) { + const child = normalized[name]; + if ( + (child === undefined || child === null || child === "") && + childSchema.default === undefined + ) + continue; + const result = coerceAgainstSchema(child, childSchema, `${path}.${name}`); + if (result.ok) normalized[name] = result.value; + else issues.push(...result.issues); + } + if (schema.additionalProperties === false) { + for (const key of Object.keys(normalized)) { + if (!schema.properties?.[key]) issues.push(`Unknown input "${key}"`); + } + } + return issues.length > 0 ? { ok: false, issues } : { ok: true, value: normalized }; + } + + if (types.has("array")) { + if (!Array.isArray(value)) return { ok: false, issues: [`${path} must be an array`] }; + if (!schema.items) return { ok: true, value }; + const normalized: unknown[] = []; + const issues: string[] = []; + value.forEach((item, index) => { + const result = coerceAgainstSchema(item, schema.items!, `${path}.${index}`); + if (result.ok) normalized[index] = result.value; + else issues.push(...result.issues); + }); + return issues.length > 0 ? { ok: false, issues } : { ok: true, value: normalized }; + } + + if (types.has("string")) return { ok: true, value: String(value) }; + if (types.has("number") || types.has("integer")) { + const num = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(num)) return { ok: false, issues: [`${path} must be a number`] }; + if (types.has("integer") && !Number.isInteger(num)) + return { ok: false, issues: [`${path} must be an integer`] }; + return { ok: true, value: num }; + } + if (types.has("boolean")) { + if (typeof value === "boolean") return { ok: true, value }; + if (value === "true" || value === "1") return { ok: true, value: true }; + if (value === "false" || value === "0") return { ok: true, value: false }; + return { ok: false, issues: [`${path} must be a boolean`] }; + } + if (types.has("null")) { + return value === null ? { ok: true, value } : { ok: false, issues: [`${path} must be null`] }; + } + return { ok: true, value }; +} + +function schemaTypes(schema: JsonSchema): Set { + const raw = + schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]; + return new Set(raw); +} + +function stringifyEnumValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value) ?? ""; + return String(value as string | number | boolean | bigint | symbol); +} diff --git a/packages/cli/src/pipeline/steps/bl-api.ts b/packages/cli/src/pipeline/steps/bl-api.ts new file mode 100644 index 0000000..ee1aa85 --- /dev/null +++ b/packages/cli/src/pipeline/steps/bl-api.ts @@ -0,0 +1,711 @@ +/** + * Direct in-process API call implementations for pipeline steps. + * Bypasses the CLI command handler layer and calls requestJson/request directly. + */ +import { + requestJson, + chatEndpoint, + imageEndpoint, + imageSyncEndpoint, + videoGenerateEndpoint, + taskEndpoint, + speechSynthesizeEndpoint, + speechRecognizeEndpoint, + resolveFileUrl, + resolveCredential, + stripUndefined, + type Config, + type ChatRequest, + type ChatResponse, + type DashScopeImageRequest, + type DashScopeImageSyncResponse, + type DashScopeAsyncResponse, + type DashScopeTaskResponse, + type DashScopeVideoRequest, + type DashScopeTTSRequest, + type DashScopeTTSResponse, + type DashScopeASRRequest, + type ChatMessageContent, + isLocalFile, +} from "bailian-cli-core"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { PipelineError } from "../errors.ts"; +import type { StepContext } from "../types.ts"; +import { resolveImageSize } from "../../utils/image-size.ts"; +import { downloadFile } from "../../utils/download.ts"; + +// --- text/chat --- + +export interface TextChatInput { + message?: string; + model?: string; + system?: string; + "max-tokens"?: number; + temperature?: number; + "top-p"?: number; + "enable-thinking"?: boolean; + "thinking-budget"?: number; +} + +export async function textChat( + config: Config, + input: TextChatInput, + ctx: StepContext, +): Promise { + if (!input.message) { + throw new PipelineError("missing_input", "text/chat requires 'message' input", { + step: "text/chat", + }); + } + + const model = input.model || "qwen3.7-max"; + const messages: Array<{ role: string; content: string }> = []; + if (input.system) messages.push({ role: "system", content: input.system }); + messages.push({ role: "user", content: input.message }); + + const body: ChatRequest = { + model, + messages: messages as ChatRequest["messages"], + max_tokens: input["max-tokens"] ?? 4096, + stream: false, + }; + if (input.temperature !== undefined) body.temperature = input.temperature; + if (input["top-p"] !== undefined) body.top_p = input["top-p"]; + if (input["enable-thinking"]) { + body.enable_thinking = true; + if (input["thinking-budget"] !== undefined) { + body.thinking_budget = input["thinking-budget"]; + } + } + + const url = chatEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + timeout: ctx.blRequestTimeoutSeconds, + signal: ctx.signal, + }); + return response; +} + +// --- vision/describe --- + +export interface VisionDescribeInput { + image?: string | string[]; + video?: string; + prompt?: string; + model?: string; +} + +export async function visionDescribe( + config: Config, + input: VisionDescribeInput, + ctx: StepContext, +): Promise { + const model = input.model || "qwen-vl-max"; + 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."; + const prompt = input.prompt || defaultPrompt; + + const contentArray: ChatMessageContent[] = []; + + // Handle video + if (input.video) { + let videoUrl = input.video; + if (isLocalFile(videoUrl)) { + const credential = await resolveCredential(config); + videoUrl = await resolveFileUrl(videoUrl, credential.token, model, { signal: ctx.signal }); + } + contentArray.push({ type: "video_url", video_url: { url: videoUrl } }); + } + + // Handle images + for (const img of images) { + let imageUrl = img; + if (isLocalFile(img)) { + const credential = await resolveCredential(config); + imageUrl = await resolveFileUrl(img, credential.token, model, { signal: ctx.signal }); + } + contentArray.push({ type: "image_url", image_url: { url: imageUrl } }); + } + + contentArray.push({ type: "text", text: prompt }); + + const body: ChatRequest = { + model, + messages: [{ role: "user", content: contentArray }], + }; + + const url = chatEndpoint(config.baseUrl); + return await requestJson(config, { + url, + method: "POST", + body, + signal: ctx.signal, + }); +} + +// --- image/generate --- + +const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; + +function isSyncImageModel(model: string): boolean { + return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p)); +} + +export interface ImageGenerateInput { + prompt?: string; + model?: string; + size?: string; + n?: number; + seed?: number; + "negative-prompt"?: string; + "prompt-extend"?: boolean; + "no-prompt-extend"?: boolean; + watermark?: boolean; + "out-dir"?: string; + "out-prefix"?: string; +} + +export async function imageGenerate( + config: Config, + input: ImageGenerateInput, + ctx: StepContext, +): Promise { + if (!input.prompt) { + throw new PipelineError("missing_input", "image/generate requires 'prompt' input", { + step: "image/generate", + }); + } + + const model = input.model || "qwen-image-2.0"; + const useSync = isSyncImageModel(model); + const n = input.n ?? 1; + + let promptExtend: boolean | undefined; + if (input["no-prompt-extend"]) { + promptExtend = false; + } else if (input["prompt-extend"]) { + promptExtend = true; + } else if (useSync) { + promptExtend = true; + } + + const body: DashScopeImageRequest = { + model, + input: { + messages: [{ role: "user", content: [{ text: input.prompt }] }], + }, + parameters: { + size: resolveImageSize(input.size, useSync), + n, + seed: input.seed, + prompt_extend: promptExtend, + watermark: input.watermark === true ? true : undefined, + negative_prompt: input["negative-prompt"] || undefined, + }, + }; + + if (useSync) { + const url = imageSyncEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + signal: ctx.signal, + }); + const urls = response.output.choices + .flatMap((c) => c.message?.content || []) + .map((item) => item.image) + .filter(Boolean); + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; + } else { + // Async mode: submit then poll + const url = imageEndpoint(config.baseUrl); + const asyncResp = await requestJson(config, { + url, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + const result = await pollTask(config, taskId, ctx); + const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + if (saved) result.saved = saved; + return result; + } +} + +// --- image/edit --- + +export interface ImageEditInput { + image?: string | string[]; + prompt?: string; + model?: string; + size?: string; + n?: number; + seed?: number; + "negative-prompt"?: string; + "prompt-extend"?: boolean; + "no-prompt-extend"?: boolean; + watermark?: boolean; + "out-dir"?: string; + "out-prefix"?: string; +} + +export async function imageEdit( + config: Config, + input: ImageEditInput, + ctx: StepContext, +): Promise { + if (!input.prompt) { + throw new PipelineError("missing_input", "image/edit requires 'prompt' input", { + step: "image/edit", + }); + } + + const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : []; + const model = input.model || "qwen-image-2.0"; + const useSync = isSyncImageModel(model); + const n = input.n ?? 1; + + let promptExtend: boolean | undefined; + if (input["no-prompt-extend"]) { + promptExtend = false; + } else if (input["prompt-extend"]) { + promptExtend = true; + } else if (useSync) { + promptExtend = true; + } + + const content: Array<{ text?: string; image?: string }> = []; + for (const img of images) { + let imageUrl = img; + if (isLocalFile(img)) { + const credential = await resolveCredential(config); + imageUrl = await resolveFileUrl(img, credential.token, model, { signal: ctx.signal }); + } + content.push({ image: imageUrl }); + } + content.push({ text: input.prompt }); + + const body: DashScopeImageRequest = { + model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + size: resolveImageSize(input.size, useSync), + n, + seed: input.seed, + prompt_extend: promptExtend, + watermark: input.watermark === true ? true : undefined, + negative_prompt: input["negative-prompt"] || undefined, + }, + }; + + if (useSync) { + const url = imageSyncEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + signal: ctx.signal, + }); + const urls = response.output.choices + .flatMap((c) => c.message?.content || []) + .map((item) => item.image) + .filter(Boolean); + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; + } else { + const url = imageEndpoint(config.baseUrl); + const asyncResp = await requestJson(config, { + url, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + const result = await pollTask(config, taskId, ctx); + const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + if (saved) result.saved = saved; + return result; + } +} + +/** + * If `outDir` is provided, download each URL to disk under `outDir/`. + * Naming: single image -> `.png`; multiple -> `_001.png`, ... + * Default prefix is `image`. + */ +async function maybeDownloadImages( + urls: string[], + outDir: string | undefined, + outPrefix: string | undefined, +): Promise { + if (!outDir || urls.length === 0) return undefined; + await mkdir(outDir, { recursive: true }); + const prefix = outPrefix && outPrefix.length > 0 ? outPrefix : "image"; + const items = + urls.length === 1 + ? [{ url: urls[0], destPath: join(outDir, `${prefix}.png`) }] + : urls.map((url, i) => ({ + url, + destPath: join(outDir, `${prefix}_${String(i + 1).padStart(3, "0")}.png`), + })); + const saved: string[] = []; + for (const { url, destPath } of items) { + await downloadFile(url, destPath, { quiet: true }); + saved.push(destPath); + } + return saved; +} + +// --- video/generate (async) --- + +export interface VideoGenerateInput { + prompt?: string; + model?: string; + image?: string; + "negative-prompt"?: string; + resolution?: string; + ratio?: string; + duration?: number; + "prompt-extend"?: boolean; + watermark?: boolean; + seed?: number; + "poll-interval"?: number; +} + +export async function videoGenerate( + config: Config, + input: VideoGenerateInput, + ctx: StepContext, +): Promise { + if (!input.prompt) { + throw new PipelineError("missing_input", "video/generate requires 'prompt' input", { + step: "video/generate", + }); + } + + const model = input.model || (input.image ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v"); + + let resolvedImageUrl: string | undefined; + if (input.image) { + if (isLocalFile(input.image)) { + const credential = await resolveCredential(config); + resolvedImageUrl = await resolveFileUrl(input.image, credential.token, model, { + signal: ctx.signal, + }); + } else { + resolvedImageUrl = input.image; + } + } + + const body: DashScopeVideoRequest = { + model, + input: { + prompt: input.prompt, + negative_prompt: input["negative-prompt"] || undefined, + ...(resolvedImageUrl + ? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] } + : {}), + }, + parameters: { + resolution: input.resolution || undefined, + ratio: input.ratio || undefined, + duration: input.duration, + prompt_extend: input["prompt-extend"], + watermark: input.watermark, + seed: input.seed, + }, + }; + stripUndefined(body.parameters as Record); + + const url = videoGenerateEndpoint(config.baseUrl); + const asyncResp = await requestJson(config, { + url, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + + const pollIntervalMs = (input["poll-interval"] ?? 10) * 1000; + const timeoutMs = (ctx.timeoutSeconds ?? 900) * 1000; + + return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx); +} + +// --- speech/synthesize --- + +export interface SpeechSynthesizeInput { + text?: string; + "text-file"?: string; + model?: string; + voice?: string; + format?: string; + "sample-rate"?: number; + volume?: number; + rate?: number; + pitch?: number; + seed?: number; + language?: string; + instruction?: string; + "enable-ssml"?: boolean; + out?: string; +} + +export async function speechSynthesize( + config: Config, + input: SpeechSynthesizeInput, + ctx: StepContext, +): Promise { + let text = input.text; + if (!text && input["text-file"]) { + const { readFileSync } = await import("node:fs"); + text = readFileSync(input["text-file"], "utf-8").trim(); + } + if (!text) { + throw new PipelineError("missing_input", "speech/synthesize requires 'text' input", { + step: "speech/synthesize", + }); + } + + const model = input.model || "cosyvoice-v3-flash"; + + const body: DashScopeTTSRequest = { + model, + input: { + text, + voice: input.voice, + format: input.format as DashScopeTTSRequest["input"]["format"], + sample_rate: input["sample-rate"], + volume: input.volume, + rate: input.rate, + pitch: input.pitch, + seed: input.seed, + language_hints: input.language ? [input.language] : undefined, + instruction: input.instruction, + enable_ssml: input["enable-ssml"], + }, + }; + stripUndefined(body.input as Record); + + const url = speechSynthesizeEndpoint(config.baseUrl); + const response = await requestJson(config, { + url, + method: "POST", + body, + signal: ctx.signal, + }); + + return { + audio_url: response.output.audio.url, + url_expires_at: response.output.audio.expires_at, + model, + voice: input.voice, + request_id: response.request_id, + }; +} + +// --- speech/recognize --- + +export interface SpeechRecognizeInput { + url?: string | string[]; + model?: string; + language?: string; + diarization?: boolean; + "speaker-count"?: number; + "vocabulary-id"?: string; + "channel-id"?: number; + "poll-interval"?: number; +} + +export async function speechRecognize( + config: Config, + input: SpeechRecognizeInput, + ctx: StepContext, +): Promise { + const rawUrls = Array.isArray(input.url) ? input.url : input.url ? [input.url] : []; + if (rawUrls.length === 0) { + throw new PipelineError("missing_input", "speech/recognize requires 'url' input", { + step: "speech/recognize", + }); + } + + // Resolve local files to upload URLs + const fileUrls: string[] = []; + for (const u of rawUrls) { + if (isLocalFile(u)) { + const credential = await resolveCredential(config); + fileUrls.push( + await resolveFileUrl(u, credential.token, input.model || "fun-asr", { + signal: ctx.signal, + }), + ); + } else { + fileUrls.push(u); + } + } + + const model = input.model || "fun-asr"; + const body: DashScopeASRRequest = { + model, + input: { file_urls: fileUrls }, + parameters: { + channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined, + language_hints: input.language ? [input.language] : undefined, + diarization_enabled: input.diarization, + speaker_count: input["speaker-count"], + vocabulary_id: input["vocabulary-id"], + }, + }; + stripUndefined(body.parameters as Record); + + const url = speechRecognizeEndpoint(config.baseUrl); + const asyncResp = await requestJson(config, { + url, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + + const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000; + const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000; + + return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx); +} + +// --- Shared: task polling --- + +/** + * Flatten DashScopeTaskResponse into a top-level object for artifact extraction. + * The CLI `--output json` used to emit output fields at the top level; + * we replicate that shape so artifactsFromBlData works correctly. + */ +function flattenTaskResponse(resp: DashScopeTaskResponse): Record { + const { output, request_id, usage } = resp; + const flat: Record = { + task_id: output.task_id, + task_status: output.task_status, + request_id, + }; + if (output.video_url) flat.video_url = output.video_url; + if (output.choices) { + const urls = output.choices + .flatMap((c) => c.message?.content || []) + .map((item) => item.image) + .filter(Boolean); + if (urls.length > 0) flat.urls = urls; + } + if (output.results) { + const urls = output.results.map((r) => r.url).filter(Boolean); + if (urls.length > 0 && !flat.urls) flat.urls = urls; + } + if (output.task_metrics) flat.task_metrics = output.task_metrics; + if (usage) flat.usage = usage; + return flat; +} + +async function pollTask( + config: Config, + taskId: string, + ctx?: StepContext, +): Promise> { + const pollIntervalMs = 3000; + const timeoutMs = config.timeout * 1000; + return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx); +} + +async function pollTaskWithOptions( + config: Config, + taskId: string, + pollIntervalMs: number, + timeoutMs: number, + ctx?: StepContext, +): Promise> { + const started = Date.now(); + let attempt = 0; + + while (true) { + if (ctx?.signal?.aborted) { + throw new PipelineError("aborted", "Task polling was aborted", { + details: { taskId }, + }); + } + + await delay(pollIntervalMs, ctx?.signal); + attempt++; + + const url = taskEndpoint(config.baseUrl, taskId); + const result = await requestJson(config, { + url, + method: "GET", + signal: ctx?.signal, + }); + const status = result.output.task_status; + + if (status === "SUCCEEDED") { + return flattenTaskResponse(result); + } + + if (status === "FAILED") { + const msg = result.output.message || result.output.code || "Task failed"; + throw new PipelineError("async_task_failed", msg, { + details: { taskId, data: result }, + }); + } + + const elapsedMs = Date.now() - started; + await ctx?.emitEvent?.({ + type: "step.polling", + timestamp: new Date().toISOString(), + status: "running", + taskId, + taskStatus: status, + elapsedMs, + pollAttempt: attempt, + }); + + if (elapsedMs > timeoutMs) { + throw new PipelineError( + "async_poll_timeout", + `Task ${taskId} timed out after ${Math.round(timeoutMs / 1000)}s`, + { details: { taskId, timeoutMs, pollAttempts: attempt } }, + ); + } + } +} + +function delay(ms: number, signal?: AbortSignal): Promise { + if (!signal) return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const abort = () => { + cleanup(); + reject(new PipelineError("aborted", "Poll delay was aborted")); + }; + const cleanup = () => { + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + }; + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); +} diff --git a/packages/cli/src/pipeline/steps/bl-steps.ts b/packages/cli/src/pipeline/steps/bl-steps.ts new file mode 100644 index 0000000..4720a77 --- /dev/null +++ b/packages/cli/src/pipeline/steps/bl-steps.ts @@ -0,0 +1,306 @@ +import { registerStep } from "../dispatcher.ts"; +import { buildPipelineConfig } from "../bl-config.ts"; +import { isRecord } from "../utils.ts"; +import { + textChat, + visionDescribe, + imageGenerate, + imageEdit, + videoGenerate, + speechSynthesize, + speechRecognize, +} from "./bl-api.ts"; +import type { Config } from "bailian-cli-core"; +import type { StepDispatcher } from "../dispatcher.ts"; +import type { StepArtifact, StepContext, StepOutputSchema, StepResult } from "../types.ts"; + +// --- Direct API call dispatch --- + +type DirectApiHandler = ( + config: Config, + input: Record, + ctx: StepContext, +) => Promise; + +const DIRECT_API_HANDLERS: Record = { + "text/chat": (config, input, ctx) => textChat(config, input, ctx), + "vision/describe": (config, input, ctx) => visionDescribe(config, input, ctx), + "image/generate": (config, input, ctx) => imageGenerate(config, input, ctx), + "image/edit": (config, input, ctx) => imageEdit(config, input, ctx), + "video/generate": (config, input, ctx) => videoGenerate(config, input, ctx), + "speech/synthesize": (config, input, ctx) => speechSynthesize(config, input, ctx), + "speech/recognize": (config, input, ctx) => speechRecognize(config, input, ctx), +}; + +// Build result with artifact extraction from the raw API data +const RESULT_BUILDERS: Record StepResult> = { + "image/generate": (data) => ({ + data, + artifacts: artifactsFromBlData(data), + warnings: [], + metadata: {}, + }), + "image/edit": (data) => ({ + data, + artifacts: artifactsFromBlData(data), + warnings: [], + metadata: {}, + }), + "video/generate": (data) => ({ + data, + artifacts: artifactsFromBlData(data), + warnings: [], + metadata: {}, + }), + "speech/synthesize": (data) => ({ + data, + artifacts: speechArtifactsFromBlData(data, "audio"), + warnings: [], + metadata: {}, + }), + "speech/recognize": (data) => ({ + data, + artifacts: speechArtifactsFromBlData(data, "transcript"), + warnings: [], + metadata: {}, + }), +}; + +// --- Output schemas --- + +const OUTPUT_SCHEMAS: Record = { + "text/chat": { + description: "LLM text completion via chat API", + paths: [ + { path: "/data/choices/0/message/content", description: "Generated text content" }, + { path: "/data/choices/0/message/role", description: "Message role (assistant)" }, + { path: "/data/usage/total_tokens", description: "Total token usage" }, + ], + }, + "vision/describe": { + description: "Multimodal vision understanding (image/video to text)", + paths: [ + { path: "/data/choices/0/message/content", description: "Generated description text" }, + { path: "/data/choices/0/message/role", description: "Message role (assistant)" }, + { path: "/data/usage/total_tokens", description: "Total token usage" }, + ], + }, + "image/generate": { + description: "Text-to-image generation", + paths: [ + { path: "/data/urls", description: "Array of generated image URLs" }, + { path: "/data/urls/0", description: "First generated image URL" }, + { path: "/data/task_id", description: "Async task ID (if applicable)" }, + { path: "/artifacts/0/url", description: "First artifact URL" }, + { path: "/artifacts/0/path", description: "First artifact local path (if saved)" }, + ], + }, + "image/edit": { + description: "Image editing with reference image(s)", + paths: [ + { path: "/data/urls", description: "Array of generated image URLs" }, + { path: "/data/urls/0", description: "First generated image URL" }, + { path: "/data/task_id", description: "Async task ID (if applicable)" }, + { path: "/artifacts/0/url", description: "First artifact URL" }, + { path: "/artifacts/0/path", description: "First artifact local path (if saved)" }, + ], + }, + "video/generate": { + description: "Video generation (text-to-video or image-to-video)", + paths: [ + { path: "/data/video_url", description: "Generated video URL" }, + { path: "/data/task_id", description: "Async task ID" }, + { path: "/artifacts/0/url", description: "First artifact URL" }, + { path: "/artifacts/0/path", description: "First artifact local path (if saved)" }, + ], + }, + "speech/synthesize": { + description: "Text-to-speech synthesis", + paths: [ + { path: "/data/audio_url", description: "Generated audio URL" }, + { path: "/data/url_expires_at", description: "Audio URL expiration time" }, + { path: "/artifacts/0/url", description: "First artifact URL" }, + ], + }, + "speech/recognize": { + description: "Speech recognition (ASR)", + paths: [ + { path: "/data/text", description: "Full transcribed text" }, + { path: "/data/task_id", description: "Async task ID" }, + { path: "/artifacts/0/taskId", description: "Task ID from artifact" }, + ], + }, +}; + +// --- Registration --- + +export function registerBlSteps(dispatcher?: StepDispatcher): void { + for (const id of Object.keys(DIRECT_API_HANDLERS)) { + registerStep( + id, + async (input, ctx) => { + return await executeDirectBlStep(id, input, ctx); + }, + dispatcher, + OUTPUT_SCHEMAS[id], + ); + } +} + +// --- Execution --- + +async function executeDirectBlStep( + id: string, + input: Record, + ctx: StepContext, +): Promise { + if (ctx.dryRun) { + return { + metadata: { dryRun: true, step: id, plannedInput: input }, + warnings: [ + { code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` }, + ], + }; + } + + const handler = DIRECT_API_HANDLERS[id]; + if (!handler) { + throw new Error(`No direct API handler registered for step: ${id}`); + } + + const config = (ctx.blConfig as Config | undefined) ?? buildPipelineConfig(); + const data = await handler(config, input, ctx); + const builder = RESULT_BUILDERS[id]; + if (builder) { + return builder(data); + } + return { data, warnings: [], metadata: {} }; +} + +// --- Artifact extraction helpers --- + +function artifactsFromBlData(data: unknown): StepArtifact[] | undefined { + if (!isRecord(data)) return undefined; + const artifacts: StepArtifact[] = []; + const taskId = stringValue(data.task_id) ?? stringValue(data.taskId); + + for (const url of stringArray(data.urls)) { + artifacts.push({ url, taskId, kind: mediaKindFromUrl(url) }); + } + for (const path of stringArray(data.saved)) { + artifacts.push({ path, taskId, kind: mediaKindFromPath(path) }); + } + const videoUrl = stringValue(data.video_url) ?? stringValue(data.videoUrl); + if (videoUrl) { + artifacts.push({ url: videoUrl, taskId, kind: "video" }); + } + const savedPath = stringValue(data.saved); + if (savedPath) { + artifacts.push({ path: savedPath, taskId, kind: mediaKindFromPath(savedPath) }); + } + + if (Array.isArray(data.videos)) { + for (const item of data.videos) { + if (!isRecord(item)) continue; + const itemTaskId = stringValue(item.task_id) ?? stringValue(item.taskId); + const itemUrl = stringValue(item.video_url) ?? stringValue(item.videoUrl); + const itemPath = stringValue(item.saved); + if (itemUrl) artifacts.push({ url: itemUrl, taskId: itemTaskId, kind: "video" }); + if (itemPath) artifacts.push({ path: itemPath, taskId: itemTaskId, kind: "video" }); + } + } + + const taskIds = Array.isArray(data.task_ids) + ? data.task_ids.map(stringValue).filter(isString) + : []; + for (const id of taskIds) { + artifacts.push({ taskId: id, kind: "task" }); + } + + if (artifacts.length === 0 && taskId) { + artifacts.push({ taskId, kind: "task" }); + } + + return artifacts.length > 0 ? artifacts : undefined; +} + +function speechArtifactsFromBlData( + data: unknown, + defaultKind: "audio" | "transcript", +): StepArtifact[] | undefined { + if (!isRecord(data)) return undefined; + const artifacts: StepArtifact[] = []; + const taskId = stringValue(data.task_id) ?? stringValue(data.taskId); + + const urls = [ + stringValue(data.url), + stringValue(data.audio_url), + stringValue(data.audioUrl), + stringValue(data.output_url), + stringValue(data.outputUrl), + ].filter(isString); + for (const url of urls) { + artifacts.push({ url, taskId, kind: defaultKind }); + } + + const paths = [ + stringValue(data.saved), + stringValue(data.path), + stringValue(data.output_path), + stringValue(data.outputPath), + ].filter(isString); + for (const path of paths) { + artifacts.push({ path, taskId, kind: defaultKind }); + } + + if (artifacts.length === 0 && taskId) { + artifacts.push({ taskId, kind: "task" }); + } + + return artifacts.length > 0 ? uniqueArtifacts(artifacts) : undefined; +} + +function uniqueArtifacts(artifacts: StepArtifact[]): StepArtifact[] { + const seen = new Set(); + const unique: StepArtifact[] = []; + for (const artifact of artifacts) { + const key = `${artifact.kind ?? ""}:${artifact.taskId ?? ""}:${artifact.url ?? ""}:${artifact.path ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(artifact); + } + return unique; +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map(stringValue).filter(isString) : []; +} + +function mediaKindFromUrl(url: string): string { + try { + return mediaKindFromPath(new URL(url).pathname); + } catch { + return "artifact"; + } +} + +function mediaKindFromPath(path: string): string { + const lower = path.toLowerCase(); + if (lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".webm")) return "video"; + if ( + lower.endsWith(".png") || + lower.endsWith(".jpg") || + lower.endsWith(".jpeg") || + lower.endsWith(".webp") + ) + return "image"; + return "artifact"; +} diff --git a/packages/cli/src/pipeline/steps/logic.ts b/packages/cli/src/pipeline/steps/logic.ts new file mode 100644 index 0000000..dae8e39 --- /dev/null +++ b/packages/cli/src/pipeline/steps/logic.ts @@ -0,0 +1,156 @@ +import { PipelineError } from "../errors.ts"; +import { registerStep } from "../dispatcher.ts"; +import { isRecord } from "../utils.ts"; +import type { StepDispatcher } from "../dispatcher.ts"; +import type { StepOutputSchema, StepResult, PipelineConditionExpression } from "../types.ts"; +import { evaluateResolvedCondition } from "../expressions.ts"; + +const SWITCH_OUTPUT_SCHEMA: StepOutputSchema = { + description: "Conditional branch selection from a list of cases", + paths: [ + { path: "/data/selected", description: "Key of the matched case" }, + { path: "/data/value", description: "Value of the matched case" }, + { path: "/data/matched", description: "Whether a case was matched (boolean)" }, + ], +}; + +const SELECT_OUTPUT_SCHEMA: StepOutputSchema = { + description: "Select first available value from candidates", + paths: [ + { path: "/data/source", description: "Source identifier of the selected candidate" }, + { path: "/data/value", description: "Value of the selected candidate" }, + { path: "/data/matched", description: "Whether a candidate was matched (boolean)" }, + ], +}; + +const ASSERT_OUTPUT_SCHEMA: StepOutputSchema = { + description: "Assert a condition is true", + paths: [ + { path: "/data/ok", description: "Whether the assertion passed (boolean)" }, + { path: "/data/message", description: "Assertion message" }, + ], +}; + +export function registerLogicSteps(dispatcher?: StepDispatcher): void { + registerStep( + "logic/switch", + (_input) => { + const cases = asSwitchCases(_input.cases); + for (const entry of cases) { + if (evaluateResolvedCondition(entry.condition)) { + return { data: { selected: entry.key, value: entry.value, matched: true } }; + } + } + if ("defaultValue" in _input) { + return { + data: { + selected: typeof _input.defaultKey === "string" ? _input.defaultKey : "default", + value: _input.defaultValue, + matched: false, + }, + }; + } + throw new PipelineError("logic_no_match", "logic/switch did not match any case", { + step: "logic/switch", + details: { caseCount: cases.length }, + }); + }, + dispatcher, + SWITCH_OUTPUT_SCHEMA, + ); + + registerStep( + "logic/select", + (_input) => { + const candidates = asSelectCandidates(_input.candidates); + for (const candidate of candidates) { + if (candidate.available !== undefined && !evaluateResolvedCondition(candidate.available)) + continue; + if (candidate.value !== undefined && candidate.value !== null) { + return { data: { source: candidate.source, value: candidate.value, matched: true } }; + } + } + if ("defaultValue" in _input) { + return { + data: { + source: typeof _input.defaultSource === "string" ? _input.defaultSource : "default", + value: _input.defaultValue, + matched: false, + }, + }; + } + throw new PipelineError( + "logic_no_candidate", + "logic/select did not receive an available candidate", + { step: "logic/select", details: { candidateCount: candidates.length } }, + ); + }, + dispatcher, + SELECT_OUTPUT_SCHEMA, + ); + + registerStep( + "logic/assert", + (_input): StepResult => { + const message = + typeof _input.message === "string" ? _input.message : "Logic assertion failed"; + const metadata = isRecord(_input.metadata) ? _input.metadata : undefined; + if (!evaluateResolvedCondition(asCondition(_input.condition))) { + if (_input.soft === true) { + return { + data: { ok: false, message, ...(metadata ? { metadata } : {}) }, + }; + } + throw new PipelineError("logic_assertion_failed", message, { + step: "logic/assert", + details: metadata ?? {}, + }); + } + return { + data: { + ok: true, + ...(_input.message !== undefined ? { message } : {}), + ...(metadata ? { metadata } : {}), + }, + }; + }, + dispatcher, + ASSERT_OUTPUT_SCHEMA, + ); +} + +interface LogicSwitchCase { + key: string; + condition: PipelineConditionExpression; + value: unknown; +} + +interface LogicSelectCandidate { + source: string; + value: unknown; + available?: PipelineConditionExpression; +} + +function asSwitchCases(value: unknown): LogicSwitchCase[] { + if (!Array.isArray(value)) + throw new PipelineError("logic_input_error", "cases must be an array", { + step: "logic/switch", + }); + return value as LogicSwitchCase[]; +} + +function asSelectCandidates(value: unknown): LogicSelectCandidate[] { + if (!Array.isArray(value)) + throw new PipelineError("logic_input_error", "candidates must be an array", { + step: "logic/select", + }); + return value as LogicSelectCandidate[]; +} + +function asCondition(value: unknown): PipelineConditionExpression { + if (value === undefined) + throw new PipelineError("logic_input_error", "condition is required", { + step: "logic/assert", + }); + return value as PipelineConditionExpression; +} diff --git a/packages/cli/src/pipeline/steps/script-js.ts b/packages/cli/src/pipeline/steps/script-js.ts new file mode 100644 index 0000000..e395558 --- /dev/null +++ b/packages/cli/src/pipeline/steps/script-js.ts @@ -0,0 +1,37 @@ +import { PipelineError } from "../errors.ts"; +import { registerStep } from "../dispatcher.ts"; +import type { StepDispatcher } from "../dispatcher.ts"; +import type { StepOutputSchema } from "../types.ts"; + +const SCRIPT_JS_OUTPUT_SCHEMA: StepOutputSchema = { + description: "Execute inline JavaScript; output shape is user-defined by the return value", + paths: [{ path: "/data", description: "Return value of the script (user-defined structure)" }], +}; + +export function registerScriptJsStep(dispatcher?: StepDispatcher): void { + registerStep( + "script/js", + (_input) => { + const code = _input.code as string; + if (!code || typeof code !== "string") { + throw new PipelineError("script_js_error", "script/js requires a 'code' string input", { + step: "script/js", + }); + } + const args = (_input.args ?? {}) as Record; + try { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const fn = new Function("args", code); + const result = fn(args); + return { data: result ?? {} }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new PipelineError("script_js_error", `script/js execution failed: ${message}`, { + step: "script/js", + }); + } + }, + dispatcher, + SCRIPT_JS_OUTPUT_SCHEMA, + ); +} diff --git a/packages/cli/src/pipeline/types.ts b/packages/cli/src/pipeline/types.ts new file mode 100644 index 0000000..f271d81 --- /dev/null +++ b/packages/cli/src/pipeline/types.ts @@ -0,0 +1,341 @@ +// Pipeline types — minimal extraction from packages/pipeline +import type { StepDispatcher } from "./dispatcher.ts"; + +export const WORKFLOW_VERSION = "workflow/v1"; + +// --- Step result types (replacing AdapterResult) --- + +export interface StepArtifact { + id?: string; + path?: string; + url?: string; + mediaType?: string; + taskId?: string; + kind?: string; + expiresAt?: string; + metadata?: Record; +} + +export interface StepWarning { + code: string; + message: string; + details?: Record; +} + +export interface StepResult { + data?: unknown; + artifacts?: StepArtifact[]; + warnings?: StepWarning[]; + metadata?: Record; +} + +export interface StructuredStepErrorShape { + code: string; + message: string; + step?: string; + details?: Record; +} + +// --- JSON Schema types --- + +export type JsonSchemaPrimitiveType = + | "string" + | "number" + | "integer" + | "boolean" + | "array" + | "object" + | "null"; + +export interface JsonSchema { + type?: JsonSchemaPrimitiveType | JsonSchemaPrimitiveType[]; + properties?: Record; + items?: JsonSchema; + required?: string[]; + enum?: unknown[]; + default?: unknown; + description?: string; + format?: string; + additionalProperties?: boolean | JsonSchema; +} + +// --- Pipeline definition types --- + +export type PipelineInputExpression = + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | unknown + | { $input: string } + | { $from: string; path?: string } + | { $env: string } + | { $secret: string } + | { $concat: PipelineInputExpression[] } + | { $coalesce: PipelineInputExpression[] } + | { $js: string; args?: Record }; + +export type PipelineConditionExpression = + | boolean + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | PipelineInputExpression + | { $exists: PipelineInputExpression } + | { $eq: [PipelineInputExpression, PipelineInputExpression] } + | { $ne: [PipelineInputExpression, PipelineInputExpression] } + | { $gt: [PipelineInputExpression, PipelineInputExpression] } + | { $gte: [PipelineInputExpression, PipelineInputExpression] } + | { $lt: [PipelineInputExpression, PipelineInputExpression] } + | { $lte: [PipelineInputExpression, PipelineInputExpression] } + | { $in: [PipelineInputExpression, PipelineInputExpression] } + | { $contains: [PipelineInputExpression, PipelineInputExpression] } + | { $and: PipelineConditionExpression[] } + | { $or: PipelineConditionExpression[] } + | { $not: PipelineConditionExpression }; + +export interface PipelineRetryPolicy { + maxAttempts?: number; + backoff?: "none" | "linear" | "exponential"; +} + +export interface PipelineBinding { + from?: "env"; + name: string; + required?: boolean; + default?: unknown; +} + +export interface PipelineStep { + id: string; + type: string; + input: Record; + dependsOn?: string[]; + when?: PipelineConditionExpression; + retry?: PipelineRetryPolicy; + timeout?: string | number; +} + +export interface PipelineDefinition { + version: typeof WORKFLOW_VERSION; + inputs?: JsonSchema; + env?: Record; + secrets?: Record; + steps: PipelineStep[]; +} + +// --- Execution report types --- + +export interface PipelineStepReport { + id: string; + type: string; + status: "planned" | "succeeded" | "failed" | "skipped"; + dependencies?: string[]; + input?: Record; + output?: StepResult; + error?: StructuredStepErrorShape; + startedAt?: string; + finishedAt?: string; + attempts?: number; + skipReason?: string; + condition?: "pending" | "true" | "false"; +} + +export interface PipelineExecutionReport { + status: "planned" | "succeeded" | "failed"; + version: typeof WORKFLOW_VERSION; + steps: PipelineStepReport[]; + artifacts: StepArtifact[]; +} + +// --- Lifecycle event types --- + +export interface PipelineEventStep { + id: string; + type: string; + dependencies?: string[]; + index?: number; + total?: number; +} + +export interface PipelineEventTiming { + startedAt?: string; + finishedAt?: string; + durationMs?: number; +} + +export interface PipelineEventInputSummary { + keys: string[]; + redactedKeys?: string[]; +} + +export interface PipelineEventOutputSummary { + dataType?: string; + artifactCount: number; + warningCount: number; + metadata?: Record; +} + +export type PipelineLifecycleEvent = + | { + type: "pipeline.started"; + timestamp: string; + status: "running" | "planned"; + stepCount: number; + dryRun: boolean; + } + | { + type: "step.input.resolved"; + timestamp: string; + status: "running" | "planned"; + step: PipelineEventStep; + input: PipelineEventInputSummary; + } + | { + type: "step.planned"; + timestamp: string; + status: "planned"; + step: PipelineEventStep; + input: PipelineEventInputSummary; + condition?: "pending"; + } + | { + type: "step.started"; + timestamp: string; + status: "running"; + step: PipelineEventStep; + timing: PipelineEventTiming; + attempt: number; + } + | { + type: "step.retrying"; + timestamp: string; + status: "running"; + step: PipelineEventStep; + attempt: number; + nextAttempt: number; + error: StructuredStepErrorShape; + } + | { + type: "artifact.created"; + timestamp: string; + status: "running"; + step: PipelineEventStep; + artifact: StepArtifact; + } + | { + type: "step.succeeded"; + timestamp: string; + status: "running"; + step: PipelineEventStep; + timing: PipelineEventTiming; + output: PipelineEventOutputSummary; + attempt: number; + warnings?: StepWarning[]; + } + | { + type: "step.skipped"; + timestamp: string; + status: "running" | "planned"; + step: PipelineEventStep; + reason: string; + } + | { + type: "step.failed"; + timestamp: string; + status: "failed"; + step: PipelineEventStep; + timing: PipelineEventTiming; + attempt: number; + error: StructuredStepErrorShape; + } + | { + type: "pipeline.planned"; + timestamp: string; + status: "planned"; + stepCount: number; + artifactCount: number; + } + | { + type: "pipeline.succeeded"; + timestamp: string; + status: "succeeded"; + stepCount: number; + artifactCount: number; + } + | { + type: "step.polling"; + timestamp: string; + status: "running"; + step: PipelineEventStep; + taskId: string; + taskStatus: string; + elapsedMs: number; + pollAttempt: number; + } + | { + type: "pipeline.failed"; + timestamp: string; + status: "failed"; + stepCount: number; + artifactCount: number; + failedStep: PipelineEventStep; + error: StructuredStepErrorShape; + }; + +export type PipelineEventHandler = (event: PipelineLifecycleEvent) => void | Promise; + +// --- Execution options --- + +export interface ExecutePipelineOptions { + onEvent?: PipelineEventHandler; + concurrency?: number; + retryDelayBaseMs?: number; + sleep?: (ms: number) => Promise; + basePath?: string; + dryRun?: boolean; + signal?: AbortSignal; + timeoutSeconds?: number; + blRequestTimeoutSeconds?: number; + stepDispatcher?: StepDispatcher; +} + +// --- Scheduler types --- + +export interface PipelinePlanStep { + step: PipelineStep; + dependencies: string[]; + dependents: string[]; + index: number; + total: number; +} + +export type ResolvedExpression = { + value: unknown; + redacted: unknown; + sensitive: boolean; + exists: boolean; +}; + +// --- Step output schema types --- + +export interface StepOutputPath { + path: string; + description: string; +} + +export interface StepOutputSchema { + description?: string; + paths: StepOutputPath[]; +} + +// --- Step handler types --- + +export type StepHandler = ( + input: Record, + ctx: StepContext, +) => Promise | StepResult; + +export interface StepContext { + dryRun: boolean; + signal?: AbortSignal; + timeoutSeconds?: number; + blRequestTimeoutSeconds?: number; + emitEvent?: (event: Record) => void | Promise; + blConfig?: unknown; +} diff --git a/packages/cli/src/pipeline/utils.ts b/packages/cli/src/pipeline/utils.ts new file mode 100644 index 0000000..97391bb --- /dev/null +++ b/packages/cli/src/pipeline/utils.ts @@ -0,0 +1,104 @@ +import { access } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { PipelineStep } from "./types.ts"; + +/** Keys that represent output destination paths (not input files to be read). */ +export const OUTPUT_PATH_KEYS = ["download", "out-dir", "output"]; + +function isRelativePath(value: string): boolean { + return value.startsWith("./") || value.startsWith("../"); +} + +export interface ResolvedInputPaths { + input: Record; + resolvedKeys: string[]; +} + +export function resolveInputPaths( + input: Record, + basePath: string, +): ResolvedInputPaths { + const result: Record = {}; + const resolvedKeys: string[] = []; + for (const [key, value] of Object.entries(input)) { + if (typeof value === "string" && isRelativePath(value)) { + result[key] = resolve(basePath, value); + resolvedKeys.push(key); + } else { + result[key] = value; + } + } + return { input: result, resolvedKeys }; +} + +export async function checkInputPaths( + input: Record, + keys: string[], + stepId: string, +): Promise { + const issues: string[] = []; + for (const key of keys) { + const value = input[key]; + if (typeof value !== "string") continue; + const exists = await access(value).then( + () => true, + () => false, + ); + if (!exists) { + issues.push(`Step "${stepId}" input "${key}" references file that does not exist: ${value}`); + } + } + return issues; +} + +export function parseTimeoutSeconds(value: string | number | undefined): number | undefined { + if (value === undefined) return undefined; + if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : undefined; + const match = value.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/); + if (!match) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) return undefined; + const unit = match[2] ?? "s"; + if (unit === "ms") return amount / 1000; + if (unit === "m") return amount * 60; + return amount; +} + +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function collectFromDependencies(value: unknown, dependencies: Set): void { + if (Array.isArray(value)) { + value.forEach((item) => collectFromDependencies(item, dependencies)); + return; + } + if (!isRecord(value)) return; + if ("$from" in value && typeof value.$from === "string") dependencies.add(value.$from); + for (const child of Object.values(value)) collectFromDependencies(child, dependencies); +} + +export async function preflightCheckInputFiles( + steps: PipelineStep[], + basePath: string, +): Promise { + const issues: string[] = []; + for (const step of steps) { + for (const [key, value] of Object.entries(step.input)) { + if (OUTPUT_PATH_KEYS.includes(key)) continue; + if (typeof value !== "string") continue; + if (!isRelativePath(value)) continue; + const absPath = resolve(basePath, value); + const exists = await access(absPath).then( + () => true, + () => false, + ); + if (!exists) { + issues.push( + `Step "${step.id}" input "${key}" references file that does not exist: ${absPath}`, + ); + } + } + } + return issues; +} diff --git a/packages/cli/src/pipeline/validation.ts b/packages/cli/src/pipeline/validation.ts new file mode 100644 index 0000000..87512d2 --- /dev/null +++ b/packages/cli/src/pipeline/validation.ts @@ -0,0 +1,262 @@ +import { PipelineError } from "./errors.ts"; +import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts"; +import { validateInputAgainstSchema } from "./schema.ts"; +import { validateJsonSchema } from "./schema.ts"; +import type { PipelineDefinition } from "./types.ts"; +import { WORKFLOW_VERSION } from "./types.ts"; +import { collectFromDependencies, isRecord, parseTimeoutSeconds } from "./utils.ts"; + +export function collectPipelineIssues( + pipeline: PipelineDefinition, + dispatcher: StepDispatcher = getDefaultStepDispatcher(), +): string[] { + const structuralIssues = validateWorkflowStructure(pipeline); + if (structuralIssues.length > 0) return structuralIssues; + return collectPipelineSemanticIssues(pipeline, dispatcher); +} + +/** + * Collect non-blocking hints about potentially invalid $from paths. + * These do not prevent execution but help workflow authors catch typos. + */ +export function collectPipelineHints( + pipeline: PipelineDefinition, + dispatcher: StepDispatcher = getDefaultStepDispatcher(), +): string[] { + const structuralIssues = validateWorkflowStructure(pipeline); + if (structuralIssues.length > 0) return []; + const hints: string[] = []; + const stepTypeById = new Map(pipeline.steps.map((s) => [s.id, s.type])); + for (const step of pipeline.steps) { + validateFromPaths(step.input, step.id, stepTypeById, dispatcher, hints); + } + return hints; +} + +export function validatePipelineRuntimeInput( + pipeline: PipelineDefinition, + runtimeInput: Record, +): Record { + if (!pipeline.inputs) return runtimeInput; + const result = validateInputAgainstSchema(pipeline.inputs, runtimeInput); + if (result.ok) return result.value; + throw new PipelineError("pipeline_validation_error", "Invalid pipeline runtime input", { + details: { issues: result.issues }, + }); +} + +export function validatePipeline( + pipeline: PipelineDefinition, + dispatcher: StepDispatcher = getDefaultStepDispatcher(), +): void { + const issues = collectPipelineIssues(pipeline, dispatcher); + if (issues.length > 0) { + throw new PipelineError("pipeline_validation_error", "Invalid pipeline definition", { + details: { issues }, + }); + } +} + +function validateWorkflowStructure(pipeline: unknown): string[] { + const issues: string[] = []; + if (!isRecord(pipeline)) { + issues.push("structural: pipeline must be an object"); + return issues; + } + if (pipeline.version !== WORKFLOW_VERSION) { + issues.push(`structural: pipeline.version must be "${WORKFLOW_VERSION}"`); + } + if (!Array.isArray(pipeline.steps)) { + issues.push("structural: pipeline.steps must be an array"); + return issues; + } + if (pipeline.steps.length === 0) { + issues.push("structural: pipeline.steps must contain at least one step"); + } + for (const [index, step] of pipeline.steps.entries()) { + if (!isRecord(step)) { + issues.push(`structural: step at index ${index} must be an object`); + continue; + } + if (!step.id || typeof step.id !== "string") { + issues.push(`structural: step at index ${index} must have a string id`); + } + if (!step.type || typeof step.type !== "string") { + issues.push(`structural: step at index ${index} must have a string type`); + } + if (!isRecord(step.input)) { + issues.push(`structural: step at index ${index} must have an input object`); + } + } + return issues; +} + +function collectPipelineSemanticIssues( + pipeline: PipelineDefinition, + dispatcher: StepDispatcher, +): string[] { + const issues: string[] = []; + if (pipeline.inputs) { + issues.push(...validateJsonSchema(pipeline.inputs, "pipeline.inputs")); + } + const steps = pipeline.steps; + const stepIds = new Set(); + + for (const [index, step] of steps.entries()) { + const stepLabel = step.id || `#${index}`; + + if (stepIds.has(step.id)) issues.push(`duplicate step id "${step.id}"`); + stepIds.add(step.id); + + if (!dispatcher.hasStep(step.type)) { + issues.push(`semantic: step "${stepLabel}" references unknown type "${step.type}"`); + } + + if ( + step.dependsOn !== undefined && + (!Array.isArray(step.dependsOn) || !step.dependsOn.every((item) => typeof item === "string")) + ) { + issues.push(`semantic: step "${stepLabel}" dependsOn must be an array of step id strings`); + } + + if (step.retry !== undefined) validateRetryPolicy(step.retry, stepLabel, issues); + if (step.timeout !== undefined && parseTimeoutSeconds(step.timeout) === undefined) { + issues.push( + `semantic: step "${stepLabel}" timeout must be a positive number of seconds or duration string`, + ); + } + } + + // Check dependency references + for (const step of steps) { + for (const dep of step.dependsOn ?? []) { + if (!stepIds.has(dep)) { + issues.push(`semantic: step "${step.id}" references missing step "${dep}"`); + } + if (dep === step.id) { + issues.push(`semantic: step "${step.id}" references itself`); + } + } + // Check $from references in input + collectFromReferences(step.input, step.id, stepIds, issues); + } + + // Cycle detection + const dependencies = new Map>(); + for (const step of steps) { + const deps = new Set(step.dependsOn ?? []); + collectFromDependencies(step.input, deps); + if (step.when !== undefined) collectFromDependencies(step.when, deps); + dependencies.set(step.id, deps); + } + for (const cycle of findCycles(dependencies)) { + issues.push(`semantic: pipeline graph contains cycle: ${cycle.join(" -> ")}`); + } + + return issues; +} + +function collectFromReferences( + value: unknown, + stepId: string, + stepIds: Set, + issues: string[], +): void { + if (Array.isArray(value)) { + value.forEach((item) => collectFromReferences(item, stepId, stepIds, issues)); + return; + } + if (!isRecord(value)) return; + if ("$from" in value && typeof value.$from === "string") { + if (!stepIds.has(value.$from)) { + issues.push(`semantic: step "${stepId}" $from references missing step "${value.$from}"`); + } + } + for (const child of Object.values(value)) { + collectFromReferences(child, stepId, stepIds, issues); + } +} + +function validateRetryPolicy(policy: unknown, stepLabel: string, issues: string[]): void { + if (!isRecord(policy)) { + issues.push(`step "${stepLabel}" retry must be an object`); + return; + } + const maxAttempts = policy.maxAttempts; + if ( + maxAttempts !== undefined && + (typeof maxAttempts !== "number" || !Number.isInteger(maxAttempts) || maxAttempts < 1) + ) { + issues.push(`step "${stepLabel}" retry.maxAttempts must be a positive integer`); + } + if ( + policy.backoff !== undefined && + policy.backoff !== "none" && + policy.backoff !== "linear" && + policy.backoff !== "exponential" + ) { + issues.push(`step "${stepLabel}" retry.backoff must be none, linear, or exponential`); + } +} + +function validateFromPaths( + value: unknown, + stepId: string, + stepTypeById: Map, + dispatcher: StepDispatcher, + issues: string[], +): void { + if (Array.isArray(value)) { + value.forEach((item) => validateFromPaths(item, stepId, stepTypeById, dispatcher, issues)); + return; + } + if (!isRecord(value)) return; + if ("$from" in value && typeof value.$from === "string" && typeof value.path === "string") { + const sourceType = stepTypeById.get(value.$from); + if (sourceType) { + const schema = dispatcher.getOutputSchema(sourceType); + if (schema) { + const path = value.path as string; + const matched = schema.paths.some((p) => path === p.path || path.startsWith(p.path + "/")); + if (!matched) { + const available = schema.paths.map((p) => p.path).join(", "); + issues.push( + `hint: step "${stepId}" references path "${path}" from "${value.$from}" (${sourceType}), ` + + `which is not a known output path. Known paths: ${available}`, + ); + } + } + } + return; + } + for (const child of Object.values(value)) { + validateFromPaths(child, stepId, stepTypeById, dispatcher, issues); + } +} + +function findCycles(dependencies: Map>): string[][] { + const cycles: string[][] = []; + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + + const visit = (id: string) => { + if (visiting.has(id)) { + const start = stack.indexOf(id); + cycles.push([...stack.slice(start), id]); + return; + } + if (visited.has(id)) return; + visiting.add(id); + stack.push(id); + for (const dependency of dependencies.get(id) ?? []) { + if (dependencies.has(dependency)) visit(dependency); + } + stack.pop(); + visiting.delete(id); + visited.add(id); + }; + + for (const id of dependencies.keys()) visit(id); + return cycles; +} diff --git a/packages/cli/src/registry.ts b/packages/cli/src/registry.ts new file mode 100644 index 0000000..d485ece --- /dev/null +++ b/packages/cli/src/registry.ts @@ -0,0 +1,282 @@ +import type { Command } from "bailian-cli-core"; +import { BailianError } from "bailian-cli-core"; +import { ExitCode } from "bailian-cli-core"; +import { DOCS_HOSTS, GLOBAL_OPTIONS, type Region } from "bailian-cli-core"; +import { commands } from "./commands/catalog.ts"; + +export type { Command, OptionDef } from "bailian-cli-core"; + +interface CommandNode { + command?: Command; + children: Map; +} + +class CommandRegistry { + private root: CommandNode = { children: new Map() }; + + constructor(commands: Record) { + for (const [path, cmd] of Object.entries(commands)) { + this.register(path, cmd); + } + } + + private register(path: string, command: Command): void { + const parts = path.split(" "); + let node = this.root; + for (const part of parts) { + if (!node.children.has(part)) { + node.children.set(part, { children: new Map() }); + } + node = node.children.get(part)!; + } + node.command = command; + } + + getAllCommands(): Command[] { + const commands: Command[] = []; + const traverse = (node: CommandNode) => { + if (node.command) commands.push(node.command); + for (const child of node.children.values()) { + traverse(child); + } + }; + traverse(this.root); + return commands; + } + + isGroupPath(commandPath: string[]): boolean { + let node = this.root; + for (const part of commandPath) { + const child = node.children.get(part); + if (!child) return false; + node = child; + } + return !node.command && node.children.size > 0; + } + + resolve(commandPath: string[]): { command: Command; extra: string[] } { + let node = this.root; + const matched: string[] = []; + + for (const part of commandPath) { + const child = node.children.get(part); + if (!child) break; + node = child; + matched.push(part); + } + + if (node.command) { + return { command: node.command, extra: commandPath.slice(matched.length) }; + } + + // Single child: auto-forward (e.g. `bl config` → `bl config show`) + if (matched.length > 0 && node.children.size === 1) { + const [, child] = node.children.entries().next().value as [string, CommandNode]; + if (child.command) { + return { command: child.command, extra: commandPath.slice(matched.length) }; + } + } + + // If we matched some path but no command, show help for that group + if (matched.length > 0 && node.children.size > 0) { + const subcommands = Array.from(node.children.entries()) + .map(([name, n]) => { + if (n.command) return ` ${matched.join(" ")} ${name} ${n.command.description}`; + const subs = Array.from(n.children.keys()).join(", "); + return ` ${matched.join(" ")} ${name} [${subs}]`; + }) + .join("\n"); + throw new BailianError( + `Unknown command: bl ${commandPath.join(" ")}\n\nAvailable commands:\n${subcommands}`, + ExitCode.USAGE, + `bl ${matched.join(" ")} --help`, + ); + } + + throw new BailianError( + `Unknown command: bl ${commandPath.join(" ")}`, + ExitCode.USAGE, + "bl --help", + ); + } + + private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string { + const entries: Array<{ path: string; desc: string }> = []; + + const collect = (node: CommandNode, prefix: string) => { + for (const [name, child] of node.children) { + const fullPath = prefix ? `${prefix} ${name}` : name; + if (child.command) { + entries.push({ path: fullPath, desc: child.command.description }); + } + if (child.children.size > 0) { + collect(child, fullPath); + } + } + }; + collect(this.root, ""); + + const maxLen = Math.max(...entries.map((e) => e.path.length)); + return entries.map((e) => ` ${a(e.path.padEnd(maxLen + 2))} ${d(e.desc)}`).join("\n"); + } + + private buildGlobalFlagLines(a: (s: string) => string, d: (s: string) => string): string { + const maxLen = Math.max(...GLOBAL_OPTIONS.map((o) => o.flag.length)); + return GLOBAL_OPTIONS.map((o) => ` ${a(o.flag.padEnd(maxLen + 2))} ${d(o.description)}`).join( + "\n", + ); + } + + // Color helpers — no-ops when output is not a TTY + private bold = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[1m${s}\x1b[0m` : s); + private accent = (s: string, out: NodeJS.WriteStream) => + out.isTTY ? `\x1b[38;2;59;130;246m${s}\x1b[0m` : s; + private dim = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[2m${s}\x1b[0m` : s); + + printHelp( + commandPath: string[], + out: NodeJS.WriteStream = process.stdout, + region: Region = "cn", + ): void { + if (commandPath.length === 0) { + this.printRootHelp(out); + return; + } + + let node = this.root; + for (const part of commandPath) { + const child = node.children.get(part); + if (!child) { + this.printRootHelp(out); + return; + } + node = child; + } + + if (node.command) { + this.printCommandHelp(node.command, out, region); + return; + } + + // Group help (e.g. `bl auth --help`) + const prefix = commandPath.join(" "); + out.write(`\n${this.bold("Usage:", out)} bl ${prefix} [flags]\n\n`); + out.write(`${this.bold("Commands:", out)}\n`); + this.printChildren(node, prefix, out); + if (prefix === "pipeline") { + this.printPipelineQuickStart(out); + } + out.write("\n"); + } + + private printPipelineQuickStart(out: NodeJS.WriteStream): void { + const b = (s: string) => this.bold(s, out); + const d = (s: string) => this.dim(s, out); + + out.write(` +${b("Minimal workflow.yaml:")} +${d(" version: workflow/v1")} +${d(" steps:")} +${d(" - id: chat")} +${d(" type: text/chat")} +${d(" input:")} +${d(' message: "Who are you?"')} +${d(' system: "You are a concise assistant."')} + +${b("Try it:")} +${d(" bl pipeline validate workflow.yaml")} +${d(" bl pipeline run workflow.yaml --dry-run --output json")} +`); + } + + private printRootHelp(out: NodeJS.WriteStream): void { + // Bailian brand color: #615ced → RGB(97, 92, 237) + const LOGO = [ + "██████╗ █████╗ ██╗██╗ ██╗ █████╗ ███╗ ██╗", + "██╔══██╗██╔══██╗██║██║ ██║██╔══██╗████╗ ██║", + "██████╔╝███████║██║██║ ██║███████║██╔██╗ ██║", + "██╔══██╗██╔══██║██║██║ ██║██╔══██║██║╚██╗██║", + "██████╔╝██║ ██║██║███████╗██║██║ ██║██║ ╚████║", + "╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝", + ]; + const PURPLE = "\x1b[38;2;97;92;237m"; + const RESET = "\x1b[0m"; + + out.write("\n"); + for (const line of LOGO) { + if (out.isTTY) { + out.write(`${PURPLE}${line}${RESET}\n`); + } else { + out.write(line + "\n"); + } + } + + const b = (s: string) => this.bold(s, out); + const a = (s: string) => this.accent(s, out); + const d = (s: string) => this.dim(s, out); + + const commandLines = this.buildResourceLines(a, d); + const globalFlagLines = this.buildGlobalFlagLines(a, d); + + out.write(` +${b("Usage:")} bl [flags] + +${b("Commands:")} +${commandLines} + +${b("Global Flags:")} +${globalFlagLines} + +${b("Getting Help:")} + ${d("Add --help after any command to see its full list of options, defaults,")} + ${d("and usage examples. For example:")} bl text chat --help +`); + } + + private printCommandHelp(cmd: Command, out: NodeJS.WriteStream, region: Region = "cn"): void { + const b = (s: string) => this.bold(s, out); + const a = (s: string) => this.accent(s, out); + const d = (s: string) => this.dim(s, out); + + out.write(`\n${cmd.description}\n`); + if (cmd.usage) out.write(`${b("Usage:")} ${cmd.usage}\n`); + if (cmd.options && cmd.options.length > 0) { + const maxLen = Math.max(...cmd.options.map((o) => o.flag.length)); + out.write(`\n${b("Options:")}\n`); + for (const opt of cmd.options) { + out.write(` ${a(opt.flag.padEnd(maxLen + 2))} ${d(opt.description)}\n`); + } + } + if (cmd.examples && cmd.examples.length > 0) { + out.write(`\n${b("Examples:")}\n`); + for (const ex of cmd.examples) { + out.write(` ${d(ex)}\n`); + } + } + if (cmd.apiDocs) { + out.write(`\n${b("API Reference:")} ${d(DOCS_HOSTS[region] + cmd.apiDocs)}\n`); + } + out.write( + `\n${d("Global flags (--api-key, --output, --quiet, etc.) are always available.")}\n`, + ); + out.write(`${d("Run")} bl --help ${d("for the full list.")}\n`); + } + + private printChildren(node: CommandNode, prefix: string, out: NodeJS.WriteStream): void { + const entries: Array<{ fullName: string; description: string }> = []; + const collect = (n: CommandNode, p: string) => { + for (const [name, child] of n.children) { + if (child.command) + entries.push({ fullName: `${p} ${name}`, description: child.command.description }); + if (child.children.size > 0) collect(child, `${p} ${name}`); + } + }; + collect(node, prefix); + const maxLen = Math.max(...entries.map((e) => e.fullName.length)); + for (const { fullName, description } of entries) { + out.write(` ${this.accent(fullName.padEnd(maxLen), out)} ${this.dim(description, out)}\n`); + } + } +} + +export const registry = new CommandRegistry(commands); diff --git a/packages/cli/src/urls.ts b/packages/cli/src/urls.ts new file mode 100644 index 0000000..3d21a29 --- /dev/null +++ b/packages/cli/src/urls.ts @@ -0,0 +1,16 @@ +/** + * User-facing Aliyun Bailian / Model Studio console URLs. + * + * Single source of truth for all `bailian.console.aliyun.com/*` references + * across cli source. Currently pinned to cn-beijing — expand to a region map + * if/when overseas console support is added. + */ + +/** Root entry — generic console landing, region picker. */ +export const BAILIAN_CONSOLE_ROOT = "https://bailian.console.aliyun.com"; + +/** Region-pinned console base. Add a region map if/when overseas is supported. */ +export const BAILIAN_CONSOLE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing`; + +/** Direct deep link to API key management page. */ +export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`; diff --git a/packages/cli/src/utils/command-help.ts b/packages/cli/src/utils/command-help.ts new file mode 100644 index 0000000..e51feb3 --- /dev/null +++ b/packages/cli/src/utils/command-help.ts @@ -0,0 +1,25 @@ +/** Current command path (e.g. `["auth","login"]`) for help-on-missing; set by `main` before `execute`. */ +let executingCommandPath: string[] = []; + +let printCommandHelpImpl: ((commandPath: string[], out: NodeJS.WriteStream) => void) | null = null; + +export function setExecutingCommandPath(path: string[]): void { + executingCommandPath = path; +} + +export function getExecutingCommandPath(): string[] { + return executingCommandPath; +} + +export function registerCommandHelpPrinter( + fn: (commandPath: string[], out: NodeJS.WriteStream) => void, +): void { + printCommandHelpImpl = fn; +} + +/** Print help for the command currently being executed (must call `setExecutingCommandPath` first). */ +export function printCurrentCommandHelp(out: NodeJS.WriteStream = process.stderr): void { + if (printCommandHelpImpl && executingCommandPath.length > 0) { + printCommandHelpImpl(executingCommandPath, out); + } +} diff --git a/packages/cli/src/utils/concurrent.ts b/packages/cli/src/utils/concurrent.ts new file mode 100644 index 0000000..79a0dd2 --- /dev/null +++ b/packages/cli/src/utils/concurrent.ts @@ -0,0 +1,64 @@ +/** + * Generic concurrent execution utility. + * + * Allows any command to run N parallel API requests and aggregate results. + * Used via the global `--concurrent ` flag. + * + * @example + * const results = await runConcurrent(3, config, () => callApi()); + */ + +import type { Config, GlobalFlags } from "bailian-cli-core"; + +/** Resolve concurrency from flags (defaults to 1). */ +export function getConcurrency(flags: GlobalFlags): number { + const n = flags.concurrent as number | undefined; + return Math.max(1, n ?? 1); +} + +/** + * Run an async task N times concurrently. + * Returns all resolved results in order. + * If any single task fails, the error propagates (Promise.all semantics). + * + * @param n Number of concurrent executions + * @param config CLI config (for logging) + * @param task Async factory to execute + * @param label Optional label for status output (e.g. "requests", "tasks") + */ +export async function runConcurrent( + n: number, + config: Config, + task: (index: number) => Promise, + label = "requests", +): Promise { + if (n <= 1) { + const result = await task(0); + return [result]; + } + + if (!config.quiet) { + process.stderr.write(`[Concurrent: ${n} ${label}]\n`); + } + + const tasks = Array.from({ length: n }, (_, i) => task(i)); + return Promise.all(tasks); +} + +/** + * Parallel download helper — downloads multiple URLs concurrently. + * + * @param items Array of { url, destPath } pairs + * @param downloadFn Download function (url, destPath, opts) => Promise + * @param opts Options passed to download function + * @returns Array of saved file paths in order + */ +export async function downloadParallel( + items: Array<{ url: string; destPath: string }>, + downloadFn: (url: string, destPath: string, opts?: { quiet?: boolean }) => Promise, + opts?: { quiet?: boolean }, +): Promise { + return Promise.all( + items.map(({ url, destPath }) => downloadFn(url, destPath, opts).then(() => destPath)), + ); +} diff --git a/packages/cli/src/utils/download.ts b/packages/cli/src/utils/download.ts new file mode 100644 index 0000000..55224c2 --- /dev/null +++ b/packages/cli/src/utils/download.ts @@ -0,0 +1,78 @@ +import { createWriteStream, mkdirSync, unlinkSync } from "fs"; +import { dirname } from "path"; +import { BailianError, ExitCode, trackingHeaders } from "bailian-cli-core"; +import { createProgressBar } from "../output/progress.ts"; +import type { ReadableStreamReadResult } from "stream/web"; + +export async function downloadFile( + url: string, + destPath: string, + opts?: { quiet?: boolean }, +): Promise<{ size: number }> { + const res = await fetch(url, { + headers: trackingHeaders(), + }); + + if (!res.ok) { + throw new BailianError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); + } + + const contentLength = Number(res.headers.get("content-length") || 0); + const reader = res.body?.getReader(); + if (!reader) throw new BailianError("No response body", ExitCode.GENERAL); + + mkdirSync(dirname(destPath), { recursive: true }); + const writer = createWriteStream(destPath); + const progress = + contentLength > 0 && !opts?.quiet ? createProgressBar(contentLength, "Downloading") : null; + + let received = 0; + let completed = false; + + try { + const writeError = new Promise((_, reject) => { + writer.on("error", reject); + }); + + while (true) { + const { done, value } = (await Promise.race([ + reader.read(), + writeError, + ])) as ReadableStreamReadResult; + if (done) break; + + const ok = writer.write(value); + if (!ok) await new Promise((resolve) => writer.once("drain", () => resolve())); + + received += value.byteLength; + progress?.update(received); + } + completed = true; + } finally { + reader.releaseLock(); + progress?.finish(); + + await new Promise((resolve, reject) => { + writer.on("finish", resolve); + writer.on("error", reject); + writer.end(); + }); + + if (!completed) { + try { + unlinkSync(destPath); + } catch { + /* best effort */ + } + } + } + + return { size: received }; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} diff --git a/packages/cli/src/utils/ensure-key.ts b/packages/cli/src/utils/ensure-key.ts new file mode 100644 index 0000000..972f5f9 --- /dev/null +++ b/packages/cli/src/utils/ensure-key.ts @@ -0,0 +1,51 @@ +import { + BailianError, + ExitCode, + isInteractive, + maskToken, + readConfigFile, + writeConfigFile, + type Config, +} from "bailian-cli-core"; +import { promptText, promptConfirm } from "../output/prompt.ts"; + +export async function ensureApiKey(config: Config): Promise { + if (config.apiKey || config.fileApiKey || config.accessTokenEnv || config.fileAccessToken) return; + + const envKey = process.env.DASHSCOPE_API_KEY; + let key: string | undefined; + + if (envKey) { + if (!isInteractive({ nonInteractive: config.nonInteractive })) { + key = envKey; + } else { + const use = await promptConfirm({ + message: `Found DASHSCOPE_API_KEY in environment (${maskToken(envKey)}). Save it to config file?`, + }); + if (use) key = envKey; + } + } + + if (!key) { + if (!isInteractive({ nonInteractive: config.nonInteractive })) { + throw new BailianError( + "No API key found.", + ExitCode.AUTH, + "Set DASHSCOPE_API_KEY environment variable, pass --api-key, or run interactively to be prompted.", + ); + } + const input = await promptText({ message: "Enter your DashScope API key:" }); + if (!input) throw new BailianError("API key is required.", ExitCode.AUTH); + key = input; + } + + const data: Record = { + ...(readConfigFile() as Record), + api_key: key, + }; + await writeConfigFile(data); + config.fileApiKey = key; + + const path = config.configPath ?? "~/.bailian/config.json"; + process.stderr.write(`API key saved to ${path}\n`); +} diff --git a/packages/cli/src/utils/image-size.ts b/packages/cli/src/utils/image-size.ts new file mode 100644 index 0000000..b986b5c --- /dev/null +++ b/packages/cli/src/utils/image-size.ts @@ -0,0 +1,39 @@ +/** + * Resolve image `size` flag for image generate/edit. + * + * Users may pass either a ratio (e.g. "1:1", "3:4", "16:9") or a pixel size + * (e.g. "2048*2048"). The DashScope API only accepts the pixel format, so we + * map known ratios to the recommended pixel size for each model family. + * + * Sync models (qwen-image-2.0 / qwen-image-max / qwen-image-edit-2.0): + * higher-resolution presets. + * + * Async models (wanx2.x): smaller presets. + * + * Pixel-format input is passed through unchanged. + */ + +export const SYNC_RATIO_MAP: Record = { + "16:9": "2688*1536", + "9:16": "1536*2688", + "1:1": "2048*2048", + "4:3": "2368*1728", + "3:4": "1728*2368", +}; + +export const ASYNC_RATIO_MAP: Record = { + "16:9": "1664*928", + "4:3": "1472*1104", + "1:1": "1328*1328", + "3:4": "1104*1472", + "9:16": "928*1664", +}; + +/** Resolve `--size` value: accept ratio (3:4) or pixel (W*H) format. */ +export function resolveImageSize(input: string, useSync: boolean): string; +export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined; +export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined { + if (!input) return undefined; + const map = useSync ? SYNC_RATIO_MAP : ASYNC_RATIO_MAP; + return map[input] ?? input; +} diff --git a/packages/cli/src/utils/polling.ts b/packages/cli/src/utils/polling.ts new file mode 100644 index 0000000..1387e65 --- /dev/null +++ b/packages/cli/src/utils/polling.ts @@ -0,0 +1,57 @@ +import { BailianError, ExitCode, requestJson, type Config } from "bailian-cli-core"; +import { createSpinner } from "../output/progress.ts"; + +export interface PollOptions { + url: string; + intervalSec: number; + timeoutSec: number; + isComplete: (data: unknown) => boolean; + isFailed: (data: unknown) => boolean; + getStatus?: (data: unknown) => string; + getErrorMessage?: (data: unknown) => string | undefined; +} + +export async function poll(config: Config, opts: PollOptions): Promise { + const deadline = Date.now() + opts.timeoutSec * 1000; + const spinner = createSpinner("Polling..."); + + if (!config.quiet) spinner.start(); + + try { + while (Date.now() < deadline) { + const data = await requestJson(config, { url: opts.url }); + + if (opts.getStatus && !config.quiet) { + spinner.update(`Status: ${opts.getStatus(data)}`); + } + + if (opts.isComplete(data)) { + spinner.stop("Done."); + return data; + } + + if (opts.isFailed(data)) { + spinner.stop("Failed."); + if (config.verbose) { + process.stderr.write(`[verbose] Task response: ${JSON.stringify(data, null, 2)}\n`); + } + const errMsg = opts.getErrorMessage?.(data); + throw new BailianError( + errMsg ? `Task failed: ${errMsg}` : "Task failed.", + ExitCode.GENERAL, + errMsg ? undefined : "Use --verbose to see full API response details.", + ); + } + + await new Promise((r) => setTimeout(r, opts.intervalSec * 1000)); + } + } finally { + spinner.stop(); + } + + throw new BailianError( + "Polling timed out.", + ExitCode.TIMEOUT, + "Try increasing --timeout or check task status manually.", + ); +} diff --git a/packages/cli/src/utils/update-checker.ts b/packages/cli/src/utils/update-checker.ts new file mode 100644 index 0000000..0d194c4 --- /dev/null +++ b/packages/cli/src/utils/update-checker.ts @@ -0,0 +1,97 @@ +import { join } from "path"; +import { readFileSync, writeFileSync } from "fs"; +import { getConfigDir, trackingHeaders } from "bailian-cli-core"; + +export const NPM_REGISTRY = "https://registry.npmjs.org"; +export const NPM_PACKAGE = "bailian-cli"; + +const STATE_FILE = () => join(getConfigDir(), "update-state.json"); +const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h +const FETCH_TIMEOUT_MS = 3000; + +/** + * Simple semver comparison: returns true if a > b. + * Supports standard x.y.z format. + */ +function isNewerVersion(a: string, b: string): boolean { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true; + if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false; + } + return false; // equal +} + +interface UpdateState { + lastChecked: number; + latestVersion: string; +} + +function readState(): UpdateState | null { + try { + const raw = readFileSync(STATE_FILE(), "utf-8"); + return JSON.parse(raw) as UpdateState; + } catch { + return null; + } +} + +function writeState(state: UpdateState): void { + try { + writeFileSync(STATE_FILE(), JSON.stringify(state)); + } catch { + /* ignore */ + } +} + +export async function fetchLatestVersion( + timeoutMs: number = FETCH_TIMEOUT_MS, +): Promise { + try { + const encoded = NPM_PACKAGE.replace("/", "%2f"); + const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, { + headers: { + Accept: "application/json", + ...trackingHeaders(), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) return null; + const data = (await res.json()) as { version?: string }; + return data.version ?? null; + } catch { + return null; + } +} + +let pendingNotification: string | null = null; + +export function getPendingUpdateNotification(): string | null { + return pendingNotification; +} + +export async function checkForUpdate(currentVersion: string): Promise { + // Skip in CI / non-TTY environments + if (process.env.CI || !process.stderr.isTTY) return; + + const state = readState(); + const now = Date.now(); + + // Throttle: skip if checked within the last 4 hours + if (state && now - state.lastChecked < CHECK_INTERVAL_MS) { + if (state.latestVersion && isNewerVersion(state.latestVersion, currentVersion)) { + pendingNotification = state.latestVersion; + } + return; + } + + const latest = await fetchLatestVersion(); + if (!latest) return; + + writeState({ lastChecked: now, latestVersion: latest }); + + if (latest && isNewerVersion(latest, currentVersion)) { + pendingNotification = latest; + } +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000..92c2041 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,3 @@ +import pkg from "../package.json" with { type: "json" }; + +export const CLI_VERSION = pkg.version; diff --git a/packages/cli/tests/e2e/.smoke-32.png b/packages/cli/tests/e2e/.smoke-32.png new file mode 100644 index 0000000..81971ab Binary files /dev/null and b/packages/cli/tests/e2e/.smoke-32.png differ diff --git a/packages/cli/tests/e2e/auth.e2e.test.ts b/packages/cli/tests/e2e/auth.e2e.test.ts new file mode 100644 index 0000000..3946e75 --- /dev/null +++ b/packages/cli/tests/e2e/auth.e2e.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; + +/** + * Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。 + */ + +describe("e2e: auth", () => { + test("auth 分组展示子命令帮助且退出码为 0", async () => { + const { stdout, stderr, exitCode } = await runCli(["auth"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/auth|Authentication|login|logout|status/i); + }); + + test("auth login --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["auth", "login", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/login|api-key/i); + }); + + test("auth logout --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/logout|dry-run|yes/i); + }); + + test("auth status --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["auth", "status", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/status|output/i); + }); + + test("auth login 缺少 --api-key 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli(["auth", "login", "--non-interactive"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--api-key|Usage:/i); + }); + + test("auth login --dry-run --api-key 不发起校验与落盘", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "login", + "--dry-run", + "--api-key", + "sk-e2e-dry-run-placeholder", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Would validate and save API key."); + }); + + test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "login", + "--dry-run", + "--api-key", + "sk-e2e-dry-run-placeholder", + "--non-interactive", + "--output", + "json", + "--timeout", + "120", + "--no-color", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Would validate and save API key."); + }); + + test("auth login 缺少密钥且 --output json 时仍打印子命令帮助 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "auth", + "login", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--api-key|Usage:/i); + }); + + test("auth logout --dry-run 不写入配置", async () => { + const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run"]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("No changes made."); + expect(stderr).not.toContain("Cleared api_key"); + }); + + test("auth logout --dry-run --yes --non-interactive", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "logout", + "--dry-run", + "--yes", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("No changes made."); + expect(stderr).not.toContain("Cleared api_key"); + }); + + test("auth logout --dry-run --quiet --no-color", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "logout", + "--dry-run", + "--quiet", + "--no-color", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("No changes made."); + }); + + test("auth logout --dry-run --output json(不清除密钥)", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "logout", + "--dry-run", + "--output", + "json", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("No changes made."); + expect(stderr).not.toContain("Cleared api_key"); + }); + + test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "status", + "--non-interactive", + "--output", + "text", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toMatch( + /Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/, + ); + }); + + test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "status", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + authenticated?: boolean; + api_key?: { configured?: boolean }; + dashscope_commands?: { method?: string }; + }>(stdout); + expect(data.authenticated).toBe(true); + expect(data.api_key?.configured).toBe(true); + expect(data.dashscope_commands?.method).toBeDefined(); + }); + + test.skipIf(!isDashScopeE2EReady())("auth status --output json --quiet --region cn", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "auth", + "status", + "--non-interactive", + "--output", + "json", + "--quiet", + "--region", + "cn", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(stdout); + expect(data.authenticated).toBe(true); + expect(data.dashscope_commands).toBeDefined(); + }); +}); diff --git a/packages/cli/tests/e2e/config.e2e.test.ts b/packages/cli/tests/e2e/config.e2e.test.ts new file mode 100644 index 0000000..f024174 --- /dev/null +++ b/packages/cli/tests/e2e/config.e2e.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCli } from "./helpers.ts"; + +/** + * Config 相关 E2E + */ + +describe("e2e: config", () => { + test("config 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["config"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/config|show|set|export-schema/i); + }); + + test("config show --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["config", "show", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/show|config/i); + }); + + test("config set --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["config", "set", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/set|--key|--value/i); + }); + + test("config export-schema --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["config", "export-schema", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/export-schema|--command/i); + }); + + test("config show --output json", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "config", + "show", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + region?: string; + config_file?: string; + base_url?: string; + timeout?: number; + }>(stdout); + expect(data.region).toBeDefined(); + expect(data.config_file).toBeDefined(); + expect(data.base_url).toBeDefined(); + expect(data.timeout).toBeDefined(); + }); + + test("config show --output text --no-color", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "config", + "show", + "--non-interactive", + "--output", + "text", + "--no-color", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toMatch(/region|config_file|timeout|base_url/i); + }); + + test("config set 缺少 --key / --value 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli(["config", "set", "--non-interactive"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--key|--value|required/i); + }); + + test("config set 非法 key 时退出为用法错误", async () => { + const { stderr, exitCode } = await runCli([ + "config", + "set", + "--non-interactive", + "--key", + "not-a-real-key", + "--value", + "x", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid config key|not-a-real-key/i); + }); + + test("config set 非法 region", async () => { + const { stderr, exitCode } = await runCli([ + "config", + "set", + "--non-interactive", + "--key", + "region", + "--value", + "invalid-region", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid region|cn, us, intl/i); + }); + + test("config set 非法 output", async () => { + const { stderr, exitCode } = await runCli([ + "config", + "set", + "--non-interactive", + "--key", + "output", + "--value", + "yaml", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid output|text, json/i); + }); + + test("config set 非法 timeout", async () => { + const { stderr, exitCode } = await runCli([ + "config", + "set", + "--non-interactive", + "--key", + "timeout", + "--value", + "0", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid timeout|positive/i); + }); + + test("config set --dry-run 不落盘(仅输出 would_set)", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "config", + "set", + "--dry-run", + "--non-interactive", + "--key", + "output", + "--value", + "json", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ would_set?: { output?: string } }>(stdout); + expect(data.would_set?.output).toBe("json"); + }); + + test("config set --dry-run 支持连字符别名 key", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "config", + "set", + "--dry-run", + "--non-interactive", + "--key", + "default-text-model", + "--value", + "qwen3.7-max", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout); + expect(data.would_set?.default_text_model).toBe("qwen3.7-max"); + }); + + test("config export-schema --command 导出单条工具 JSON", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "config", + "export-schema", + "--command", + "text chat", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + const schema = parseStdoutJson<{ name?: string; input_schema?: { type?: string } }>(stdout); + expect(schema.name).toMatch(/bailian_text_chat/); + expect(schema.input_schema?.type).toBe("object"); + }); + + test("config export-schema 不存在的子命令时报错", async () => { + const { stderr, exitCode } = await runCli([ + "config", + "export-schema", + "--command", + "this-command-does-not-exist-xyz", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode).toBe(2); + const err = JSON.parse(stderr.trim()) as { error?: { message?: string } }; + expect(err.error?.message).toMatch(/not found/i); + }); + + test("config export-schema 导出全部为 JSON 数组", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "config", + "export-schema", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + const arr = parseStdoutJson>(stdout); + expect(Array.isArray(arr)).toBe(true); + expect(arr.length).toBeGreaterThan(0); + expect(arr[0]?.name).toMatch(/^bailian_/); + }); +}); diff --git a/packages/cli/tests/e2e/file-upload.e2e.test.ts b/packages/cli/tests/e2e/file-upload.e2e.test.ts new file mode 100644 index 0000000..fed7c1f --- /dev/null +++ b/packages/cli/tests/e2e/file-upload.e2e.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vite-plus/test"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * File upload E2E + */ + +describe("e2e: file upload", () => { + test("file 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["file"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/file|upload/i); + }); + + test("file upload --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["file", "upload", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/upload|--file|--model/i); + }); +}); + +describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => { + test("file upload 缺少 --file 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "file", + "upload", + "--model", + "qwen-vl-max", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--file|Usage:/i); + }); + + test("file upload 缺少 --model 时打印子命令帮助并退出 (0)", async () => { + const testFile = join(__dirname, ".smoke-32.png"); + const { stderr, exitCode } = await runCli([ + "file", + "upload", + "--file", + testFile, + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--model|Usage:/i); + }); + + test("上传文件成功返回oss临时 URL", async () => { + const testFile = join(__dirname, ".smoke-32.png"); + const { stdout, stderr, exitCode } = await runCli([ + "file", + "upload", + "--file", + testFile, + "--model", + "qwen-vl-max", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + 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.expires_in).toBe("48 hours"); + }, 120_000); +}); diff --git a/packages/cli/tests/e2e/global-setup.ts b/packages/cli/tests/e2e/global-setup.ts new file mode 100644 index 0000000..0992a3c --- /dev/null +++ b/packages/cli/tests/e2e/global-setup.ts @@ -0,0 +1,64 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"; +import { join } from "path"; +import { parseEnv } from "util"; +import { E2E_RUN_SESSION_FILENAME, monorepoRoot } from "./helpers.ts"; + +/** + * Vitest 在所有 worker 启动前执行一次:写入共享会话 id,使多进程并行时仍共用一个 `test/output/<会话>/`。 + * 结束后删除标记文件,避免非 Vitest 流程误用上一次会话 id。 + */ + +export default function vitestGlobalSetup(): () => void { + // 加载根目录 `.env` 合并变量(包含shell 注入) + const rootEnv = join(monorepoRoot(), ".env"); + if (existsSync(rootEnv)) { + const parsed = parseEnv(readFileSync(rootEnv, "utf8")); + Object.assign(process.env, parsed); + } else { + process.env.BAILIAN_E2E = "1"; + process.env.BAILIAN_E2E_MEDIA = "1"; + process.env.BAILIAN_E2E_VIDEO = "1"; + // 如根目录不存在 .env,则生成一个 .env + const envContent = `# 是否开启 E2E 测试 +BAILIAN_E2E=1 +# 是否开启图片/语音 E2E 测试 +BAILIAN_E2E_MEDIA=1 +# 是否开启视频 E2E 测试 +BAILIAN_E2E_VIDEO=1 +# DashScope API Key +DASHSCOPE_API_KEY= +# ------------------------------- +BAILIAN_E2E_VIDEO_TASK_ID=b499a8cb-1fc4-4d43-9495-e23c7f78ae0d +# ------------------------------- +# 阿里云 AK +ALIBABA_CLOUD_ACCESS_KEY_ID= +# 阿里云 SK +ALIBABA_CLOUD_ACCESS_KEY_SECRET= +# ------------------------------- +# 知识库 ID +BAILIAN_WORKSPACE_ID= +# 索引 ID +BAILIAN_E2E_INDEX_ID= +# ------------------------------- + `; + writeFileSync(rootEnv, envContent, "utf8"); + } + + // 创建生成内容目录 + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, "0"); + const dateStr = [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join("-"); + const timeStr = [pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds())].join(":"); + const runId = `e2e-run-${dateStr} ${timeStr}`; + const outDir = join(monorepoRoot(), "test", "output"); + mkdirSync(outDir, { recursive: true }); + const marker = join(outDir, E2E_RUN_SESSION_FILENAME); + writeFileSync(marker, `${runId}\n`, "utf8"); + return () => { + try { + unlinkSync(marker); + } catch { + /* 忽略:已删或权限等 */ + } + }; +} diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts new file mode 100644 index 0000000..631d0ea --- /dev/null +++ b/packages/cli/tests/e2e/helpers.ts @@ -0,0 +1,169 @@ +import { execFile } from "child_process"; +import { mkdirSync, readFileSync } from "fs"; +import { promisify } from "util"; +import { basename, dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { readConfigFile } from "bailian-cli-core"; + +const execFileAsync = promisify(execFile); + +/** + * Vitest `global-setup.ts` 写入 `test/output/` 下本文件名,供各 worker 进程读取同一会话 id。 + * (仅模块内变量无法跨 Vitest 多进程 worker 共享。) + */ +export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session"; + +/** + * 单次 `vp test` / Vitest 运行共用的 E2E 输出会话目录名(惰性缓存于当前进程)。 + */ +let e2eOutputSessionId: string | undefined; + +/** `packages/cli` 根目录(含 `src/main.ts`) */ +export const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const mainTs = join(cliPackageRoot, "src", "main.ts"); + +/** Monorepo 根(含根 `package.json`) */ +export function monorepoRoot(): string { + return join(cliPackageRoot, "..", ".."); +} + +function readE2eRunSessionFromOutputDir(): string | undefined { + try { + const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME); + const t = readFileSync(p, "utf8").trim(); + return t.length > 0 ? t : undefined; + } catch { + return undefined; + } +} + +function getE2eOutputSessionId(): string { + if (!e2eOutputSessionId) { + const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim(); + if (fromEnv) { + e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-"); + } else { + const fromFile = readE2eRunSessionFromOutputDir(); + if (fromFile) { + e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-"); + } else { + e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`; + } + } + } + return e2eOutputSessionId; +} + +/** + * 在 `test/output/<会话>/` 下创建用例子目录。 + * 会话 id 优先 `BAILIAN_E2E_RUN_ID`,否则读 Vitest globalSetup 写入的 `test/output/.e2e-run-session`, + * 再否则回退为单进程 id(非 Vitest 直接跑用例时)。 + * 若已设 `BAILIAN_E2E_OUT` 则直接使用(不再套会话目录)。 + */ +export function makeE2eOutputDir(label: string): string { + const fromEnv = process.env.BAILIAN_E2E_OUT?.trim(); + if (fromEnv) { + mkdirSync(fromEnv, { recursive: true }); + return fromEnv; + } + const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId()); + mkdirSync(sessionDir, { recursive: true }); + const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** 全局 `--timeout` 秒数(视频等长任务) */ +export function cliTimeoutSeconds(): string { + return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600"; +} + +export function cliTimeoutPrefix(): string[] { + return ["--timeout", cliTimeoutSeconds()]; +} + +/** 显式开启后才跑真实网络 E2E,避免默认 `vp test` 依赖密钥或打外网 */ +export function isBailianE2EEnabled(): boolean { + return process.env.BAILIAN_E2E === "1"; +} + +/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */ +export function isDashScopeE2EReady(): boolean { + if (!isBailianE2EEnabled()) return false; + if (process.env.DASHSCOPE_API_KEY?.trim()) return true; + try { + const f = readConfigFile(); + return typeof f.api_key === "string" && f.api_key.length > 0; + } catch { + return false; + } +} + +/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */ +export function isBailianE2EMediaEnabled(): boolean { + if (process.env.BAILIAN_E2E_MEDIA === "0") return false; + return isBailianE2EEnabled(); +} + +/** 文生视频 / 图生视频 / 参考视频 / 视频编辑(耗时长,默认关闭) */ +export function isBailianE2EVideoEnabled(): boolean { + return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1"; +} + +/** 从 `import.meta.url` 生成 OUT 子目录标签,避免并行用例目录冲突 */ +export function e2eLabelFromMetaUrl(metaUrl: string): string { + return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, ""); +} + +/** 知识库用例:须显式索引 ID + AK/SK(workspace 可读 config / env,故不在此强制校验) */ +export function isKnowledgeE2EReady(): boolean { + return ( + isBailianE2EEnabled() && + !!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID && + !!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET && + !!process.env.BAILIAN_E2E_INDEX_ID + ); +} + +export interface RunCliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * 子进程执行 CLI(等价于在 `packages/cli` 下 `node src/main.ts ...`)。 + * request_id 等诊断信息在 stderr;`--output json` 时 JSON 在 stdout。 + */ +export async function runCli( + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, +): Promise { + try { + const { stdout, stderr } = await execFileAsync("node", [mainTs, ...args], { + cwd: cliPackageRoot, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + env: { ...process.env, NODE_NO_WARNINGS: "1", ...envOverrides }, + }); + return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 }; + } catch (err: unknown) { + const e = err as { + stdout?: string; + stderr?: string; + code?: number; + }; + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: typeof e.code === "number" ? e.code : 1, + }; + } +} + +export function parseStdoutJson(stdout: string): T { + const t = stdout.trim(); + return JSON.parse(t) as T; +} diff --git a/packages/cli/tests/e2e/image-edit.e2e.test.ts b/packages/cli/tests/e2e/image-edit.e2e.test.ts new file mode 100644 index 0000000..8c7acf1 --- /dev/null +++ b/packages/cli/tests/e2e/image-edit.e2e.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vite-plus/test"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { + e2eLabelFromMetaUrl, + isBailianE2EMediaEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Image edit E2E + */ + +describe("e2e: image edit", () => { + test("image 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["image"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/image|generate|edit/i); + }); + + test("image edit --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["image", "edit", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/edit|--image|--prompt/i); + }); +}); + +describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => { + test("image edit 缺少 --image 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "image", + "edit", + "--prompt", + "仅提示词", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--image|Usage:/i); + }); + + test("image edit 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { + const testPng = join(__dirname, ".smoke-32.png"); + const { stderr, exitCode } = await runCli([ + "image", + "edit", + "--image", + testPng, + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--prompt|Usage:/i); + }); + + test("【qwen-image-2.0】图片编辑", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const gen = await runCli([ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--prompt", + "一只简笔画小猫,白底", + "--out-dir", + outDir, + "--out-prefix", + "e2e-gen", + "--non-interactive", + "--output", + "json", + ]); + expect(gen.exitCode, gen.stderr).toBe(0); + const genData = parseStdoutJson<{ urls?: string[] }>(gen.stdout); + const imagePath = genData.urls?.[0]; + + expect(imagePath).toBeTruthy(); + + const ed = await runCli([ + "image", + "edit", + "--model", + "qwen-image-2.0", + "--image", + imagePath!, + "--prompt", + "把背景改成浅蓝色", + "--out-dir", + outDir, + "--out-prefix", + "e2e-edit", + "--non-interactive", + "--output", + "json", + ]); + expect(ed.exitCode, ed.stderr).toBe(0); + const edData = parseStdoutJson<{ saved?: string[] }>(ed.stdout); + expect(edData.saved?.length ?? 0).toBeGreaterThan(0); + }, 600_000); +}); diff --git a/packages/cli/tests/e2e/image-generate.e2e.test.ts b/packages/cli/tests/e2e/image-generate.e2e.test.ts new file mode 100644 index 0000000..fae8b15 --- /dev/null +++ b/packages/cli/tests/e2e/image-generate.e2e.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + e2eLabelFromMetaUrl, + isBailianE2EMediaEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Image generate:先做 help / 分组等常规检测(不依赖密钥、不调生成接口)。 + * 需 DashScope + 媒体 E2E 的缺参、dry-run 与真实生成放在 skip 块内; + * 真实生成用例保持原逻辑与顺序,放在块内最后。 + */ + +describe("e2e: image generate", () => { + test("image 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["image"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/image|generate|edit/i); + }); + + test("image generate --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["image", "generate", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/generate|--prompt|--model/i); + }); +}); + +describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( + "e2e: image generate", + () => { + test("image generate 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--prompt|Usage:/i); + }); + + test("【qwen-image-2.0】图片生成", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const { stdout, stderr, exitCode } = await runCli([ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--prompt", + "一只简笔画小猫,白底", + "--out-dir", + outDir, + "--out-prefix", + "e2e-gen", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ saved?: string[] }>(stdout); + expect(data.saved?.length ?? 0).toBeGreaterThan(0); + expect(data.saved?.[0]).toContain("e2e-gen"); + }, 300_000); + }, +); diff --git a/packages/cli/tests/e2e/knowledge.e2e.test.ts b/packages/cli/tests/e2e/knowledge.e2e.test.ts new file mode 100644 index 0000000..27c6fd2 --- /dev/null +++ b/packages/cli/tests/e2e/knowledge.e2e.test.ts @@ -0,0 +1,55 @@ +import { join } from "path"; +import { describe, expect, test } from "vite-plus/test"; +import { + isBailianE2EEnabled, + isKnowledgeE2EReady, + monorepoRoot, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +// 已开启 E2E 但 AK/SK、索引等未齐时提醒配置根目录 .env(否则本文件整组 describe 会被 skip) +if (isBailianE2EEnabled() && !isKnowledgeE2EReady()) { + const envFile = join(monorepoRoot(), ".env"); + console.warn( + [ + "[e2e:knowledge] 知识库检索需要 RAM 的 AK/SK、索引 ID,以及工作空间 ID;当前未就绪,本组用例将被跳过。", + `请在 monorepo 根目录的 .env 中配置(${envFile}):`, + " ALIBABA_CLOUD_ACCESS_KEY_ID", + " ALIBABA_CLOUD_ACCESS_KEY_SECRET", + " BAILIAN_E2E_INDEX_ID", + " BAILIAN_WORKSPACE_ID(也可执行: bl config set workspace_id <工作空间 id>)", + ].join("\n"), + ); +} + +interface KnowledgeRetrieveBody { + Success?: boolean; + Code?: string; + Data?: { Nodes?: unknown[] }; +} + +/** 知识库检索(需 AK/SK + workspace + 索引;未就绪则整组跳过) */ +describe.skipIf(!isKnowledgeE2EReady())("e2e: knowledge retrieve", () => { + test("知识库检索", async () => { + const indexId = process.env.BAILIAN_E2E_INDEX_ID!; + const { stdout, stderr, exitCode } = await runCli([ + "knowledge", + "retrieve", + "--index-id", + indexId, + "--query", + "端到端检索测试", + "--top-k", + "3", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + const ok = data.Success === true || data.Code === "Success"; + expect(ok).toBe(true); + expect(Array.isArray(data.Data?.Nodes)).toBe(true); + }, 120_000); +}); diff --git a/packages/cli/tests/e2e/memory.e2e.test.ts b/packages/cli/tests/e2e/memory.e2e.test.ts new file mode 100644 index 0000000..ae01870 --- /dev/null +++ b/packages/cli/tests/e2e/memory.e2e.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; + +interface MemoryAddBody { + memory_ids?: string[]; +} + +interface MemoryListBody { + memory_nodes?: Array<{ memory_node_id: string; content: string }>; +} + +interface MemorySearchBody { + memory_nodes?: Array<{ memory_node_id: string; content: string }>; +} + +const DEFAULT_E2E_MEMORY_USER_ID = "e2e-vp-test"; +// bailian-cli-test +const DEFAULT_E2E_MEMORY_LIBRARY_ID = "92e8626561c4472e8805d2328030d642"; + +/** + * 优先使用环境变量,若未设置则使用默认值。 + */ +function memoryLibraryCliArgs(): string[] { + const id = process.env.BAILIAN_E2E_MEMORY_LIBRARY_ID?.trim() || DEFAULT_E2E_MEMORY_LIBRARY_ID; + return id ? ["--memory-library-id", id] : []; +} + +/** + * Memory:先做 help / 分组等常规检测(不依赖密钥、不调记忆 API)。 + * 需 E2E + DashScope 的缺参、dry-run 与 CRUD 放在 skip 块内。 + */ + +describe("e2e: memory", () => { + test("memory 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["memory"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/memory|add|list|search|update|delete|profile/i); + }); + + test("memory add --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["memory", "add", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/add|--user-id|--content|messages/i); + }); + + test("memory list --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["memory", "list", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/list|--user-id|memory-library/i); + }); + + test("memory search --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["memory", "search", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/search|--query|user-id/i); + }); + + test("memory profile create --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["memory", "profile", "create", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/profile|create|user-id/i); + }); +}); + +/** + * 记忆库增 → 列 → 搜 → 改 → 删 + */ +describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( + "e2e: memory CRUD + search", + () => { + test("memory add 缺少 --user-id 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "memory", + "add", + ...memoryLibraryCliArgs(), + "--content", + "仅内容无用户", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--user-id|Usage:/i); + }); + + test("memory add 缺少 --messages 与 --content 时报错正常退出", async () => { + const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID; + const { stderr, exitCode } = await runCli([ + "memory", + "add", + ...memoryLibraryCliArgs(), + "--user-id", + userId, + "--non-interactive", + ]); + expect(exitCode).toBe(1); + expect(stderr).toMatch(/messages|content|required/i); + }); + + test("memory add --dry-run 仅输出计划且不入网", async () => { + const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID; + const { stdout, stderr, exitCode } = await runCli([ + "memory", + "add", + "--dry-run", + ...memoryLibraryCliArgs(), + "--user-id", + userId, + "--content", + "dry-run 不入网", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { user_id?: string; custom_content?: string } }>( + stdout, + ); + expect(data.request?.user_id).toBe(userId); + expect(data.request?.custom_content).toContain("dry-run"); + }); + + test("记忆库增删改查", async () => { + const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID; + const contentA = "CLI vp test:记忆写入(可删)"; + const contentB = "CLI vp test:记忆已更新"; + + const addRes = await runCli([ + "memory", + "add", + ...memoryLibraryCliArgs(), + "--user-id", + userId, + "--content", + contentA, + "--non-interactive", + "--output", + "json", + ]); + expect(addRes.exitCode, addRes.stderr).toBe(0); + const added = parseStdoutJson(addRes.stdout); + expect(added.request_id?.length ?? 0, addRes.stdout + addRes.stderr).toBeGreaterThan(0); + + const listRes = await runCli([ + "memory", + "list", + ...memoryLibraryCliArgs(), + "--user-id", + userId, + "--non-interactive", + "--output", + "json", + ]); + expect(listRes.exitCode, listRes.stderr).toBe(0); + const listed = parseStdoutJson(listRes.stdout); + if ((listed.memory_nodes?.length ?? 0) === 0) { + throw new Error( + "memory list 为空:add 已成功但列表无节点。请设置 BAILIAN_E2E_MEMORY_LIBRARY_ID 与阿里云百炼控制台当前「记忆库」ID 一致;" + + "并设置 BAILIAN_E2E_MEMORY_USER_ID 与控制台筛选的「用户 ID」一致。stdout=" + + listRes.stdout, + ); + } + const nodeId = listed.memory_nodes![0]!.memory_node_id.trim(); + expect(nodeId.length).toBeGreaterThan(0); + + const searchRes = await runCli([ + "memory", + "search", + ...memoryLibraryCliArgs(), + "--user-id", + userId, + "--query", + "vp test", + "--top-k", + "5", + "--non-interactive", + "--output", + "json", + ]); + expect(searchRes.exitCode, searchRes.stderr).toBe(0); + const searched = parseStdoutJson(searchRes.stdout); + expect(searched.memory_nodes?.length ?? 0).toBeGreaterThan(0); + + const updRes = await runCli([ + "memory", + "update", + ...memoryLibraryCliArgs(), + "--node-id", + nodeId!, + "--user-id", + userId, + "--content", + contentB, + "--non-interactive", + "--output", + "json", + ]); + expect(updRes.exitCode, updRes.stderr).toBe(0); + + const delRes = await runCli([ + "memory", + "delete", + ...memoryLibraryCliArgs(), + "--node-id", + nodeId!, + "--user-id", + userId, + "--non-interactive", + "--output", + "json", + ]); + expect(delRes.exitCode, delRes.stderr).toBe(0); + }, 180_000); + }, +); diff --git a/packages/cli/tests/e2e/pipeline.e2e.test.ts b/packages/cli/tests/e2e/pipeline.e2e.test.ts new file mode 100644 index 0000000..ff49caf --- /dev/null +++ b/packages/cli/tests/e2e/pipeline.e2e.test.ts @@ -0,0 +1,253 @@ +import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cliPackageRoot, parseStdoutJson, runCli } from "./helpers.ts"; + +const DEMO_WORKFLOWS = [ + "chat-basic.json", + "chained-text.json", + "chained-text.yaml", + "image-generate.json", + "image-gen/image-gen-workflow.json", + "image-to-video.json", + "image2video/lego/lego-build-sequence.json", + "image2video/nine-grid-storyboard.json", + "image2video/virtual-tryon/virtual-tryon-workflow.json", + "logic-nodes.json", + "commerce/amazon-listing/amazon-listing-workflow.json", + "commerce/audio-meeting-summary/workflow.json", + "commerce/cool-background/cool-background-workflow.json", + "commerce/dress-on-model/dress-on-model-workflow.json", + "commerce/flatlay/flatlay-workflow.json", + "commerce/poster-i18n/poster-i18n-workflow.json", + "commerce/scatter-flatlay/scatter-flatlay-workflow.json", + "commerce/six-view-product/six-view-workflow.json", + "commerce/valentine-marketing/valentine-marketing-workflow.json", +]; + +describe("e2e: pipeline", () => { + let tempDir: string; + let invalidPipelinePath: string; + + const sceneRoot = join(cliPackageRoot, "scene"); + const scenePipelinePath = join(sceneRoot, "chat-basic.json"); + + beforeAll(async () => { + tempDir = await mkdtemp(join(tmpdir(), "bailian-cli-pipeline-")); + invalidPipelinePath = join(tempDir, "invalid-dependency-pipeline.json"); + await writeFile( + invalidPipelinePath, + JSON.stringify({ + version: "workflow/v1", + steps: [ + { + id: "chat", + type: "text/chat", + input: { message: { $from: "later", path: "/data/value" } }, + }, + { + id: "later", + type: "text/chat", + dependsOn: ["chat"], + input: { message: "hello" }, + }, + ], + }), + ); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("pipeline 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["pipeline"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/pipeline|run|validate/i); + expect(out).toMatch(/Minimal workflow\.yaml|text\/chat|bl pipeline run workflow\.yaml/i); + expect(out).toMatch(/Say hello in one short sentence|--dry-run --output json/i); + }); + + test("pipeline run --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["pipeline", "run", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/pipeline run|--input|--input-file|--events|--concurrency/i); + expect(stderr).not.toMatch(/--session-(?:dir|id)/i); + }); + + test("pipeline validate --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["pipeline", "validate", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/pipeline validate|workflow\.json|output json/i); + }); + + test("pipeline validate --output json 校验 scene workflow", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "pipeline", + "validate", + scenePipelinePath, + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ valid?: boolean; issues?: string[] }>(stdout); + expect(data.valid).toBe(true); + expect(data.issues).toEqual([]); + }); + + test("pipeline validate 使用 config 输出格式", async () => { + const { stdout, stderr, exitCode } = await runCli(["pipeline", "validate", scenePipelinePath], { + DASHSCOPE_OUTPUT: "text", + }); + expect(exitCode, stderr).toBe(0); + expect(stdout).toBe("Pipeline definition is valid.\n"); + }); + + test("pipeline validate --output json 校验迁移后的全部 scene workflows", async () => { + for (const workflow of DEMO_WORKFLOWS) { + const { stdout, stderr, exitCode } = await runCli([ + "pipeline", + "validate", + join(sceneRoot, workflow), + "--output", + "json", + ]); + expect(exitCode, `${workflow}\n${stderr}`).toBe(0); + const data = parseStdoutJson<{ valid?: boolean; issues?: string[] }>(stdout); + expect(data, workflow).toEqual({ valid: true, issues: [] }); + } + }); + + test("pipeline validate 拒绝非法依赖 workflow", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "pipeline", + "validate", + invalidPipelinePath, + "--output", + "json", + ]); + expect(exitCode).toBe(1); + expect(stderr).toBe(""); + const data = parseStdoutJson<{ valid?: boolean; issues?: string[] }>(stdout); + expect(data.valid).toBe(false); + expect(data.issues?.join("\n")).toMatch(/pipeline graph contains cycle/i); + }); + + test("pipeline run 缺少 file 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli(["pipeline", "run", "--non-interactive"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/pipeline file is required|Usage: bl pipeline run /i); + }); + + test("pipeline run --dry-run --output json 仅输出计划", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "pipeline", + "run", + scenePipelinePath, + "--input", + '{"message":"hello"}', + "--dry-run", + "--output", + "json", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + const report = parseStdoutJson<{ + status?: string; + version?: string; + steps?: Array<{ + id?: string; + type?: string; + status?: string; + input?: Record; + }>; + }>(stdout); + expect(report.status).toBe("planned"); + expect(report.version).toBe("workflow/v1"); + expect(report.steps?.[0]).toMatchObject({ + id: "chat", + type: "text/chat", + status: "planned", + input: { message: "hello" }, + }); + }); + + test("pipeline run 使用 config 输出格式", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "pipeline", + "run", + scenePipelinePath, + "--input", + '{"message":"hello"}', + "--dry-run", + "--non-interactive", + ], + { DASHSCOPE_OUTPUT: "text" }, + ); + expect(exitCode, stderr).toBe(0); + expect(stdout).toMatch(/Pipeline planned/); + expect(stdout).toMatch(/\[~\] chat \(text\/chat\) — planned/); + }); + + test("pipeline run --verbose 打印总步数和当前步骤序号", async () => { + const { stderr, exitCode } = await runCli([ + "pipeline", + "run", + scenePipelinePath, + "--input", + '{"message":"hello"}', + "--dry-run", + "--verbose", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/\[pipeline\.started\] 1 step/); + expect(stderr).toMatch(/\[step\.planned\] 1\/1 chat \(text\/chat\)/); + }); + + test("pipeline run --events jsonl 在 dry-run 下输出生命周期事件", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "pipeline", + "run", + scenePipelinePath, + "--input", + '{"message":"hello"}', + "--dry-run", + "--events", + "jsonl", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + const events = stdout + .trim() + .split("\n") + .map((line) => + parseStdoutJson<{ type?: string; step?: { id?: string; type?: string } }>(line), + ); + expect(events.map((event) => event.type)).toEqual([ + "pipeline.started", + "step.input.resolved", + "step.planned", + "pipeline.planned", + ]); + expect(events[1]?.step).toMatchObject({ id: "chat", type: "text/chat" }); + }); + + test("pipeline run 拒绝未知 events format", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "pipeline", + "run", + scenePipelinePath, + "--dry-run", + "--events", + "bogus", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toMatch(/unsupported --events format: bogus/i); + }); +}); diff --git a/packages/cli/tests/e2e/search-web.e2e.test.ts b/packages/cli/tests/e2e/search-web.e2e.test.ts new file mode 100644 index 0000000..9b0c353 --- /dev/null +++ b/packages/cli/tests/e2e/search-web.e2e.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; + +function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?: string }> { + const envelope = parseStdoutJson<{ content?: Array<{ type?: string; text?: string }> }>(stdout); + const block = envelope.content?.find((c) => c.type === "text" && c.text); + expect(block?.text, "MCP content[].text 缺失").toBeDefined(); + const text = block!.text as string; + const inner = JSON.parse(text) as { pages?: Array<{ title?: string; url?: string }> }; + return inner.pages ?? []; +} + +/** + * Search web E2E + */ + +describe("e2e: search web", () => { + test("search 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["search"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/search|web/i); + }); + + test("search web --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["search", "web", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/web|--query|list-tools|count/i); + }); +}); + +describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => { + test("search web 缺少 --query 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli(["search", "web", "--non-interactive"]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--query|Usage:/i); + }); + + test("search web --dry-run 仅输出计划且不调 MCP", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "search", + "web", + "--dry-run", + "--non-interactive", + "--output", + "json", + "--query", + "干跑校验", + "--count", + "5", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + action?: string; + tool?: string; + arguments?: { query?: string; count?: number }; + }>(stdout); + expect(data.action).toBe("tools/call"); + expect(data.tool).toBe("bailian_web_search"); + expect(data.arguments?.query).toBe("干跑校验"); + expect(data.arguments?.count).toBe(5); + }); + + test("search web --dry-run --list-tools 仅描述 tools/list", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "search", + "web", + "--dry-run", + "--list-tools", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ action?: string }>(stdout); + expect(data.action).toBe("tools/list"); + }); + + test("联网搜索返回 JSON 且含搜索结果", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "search", + "web", + "--query", + "阿里云百炼", + "--count", + "3", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const pages = pagesFromSearchWebStdout(stdout); + expect(pages.length).toBeGreaterThan(0); + expect(pages[0]?.url).toBeDefined(); + }, 120_000); +}); diff --git a/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts b/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts new file mode 100644 index 0000000..70fd0c6 --- /dev/null +++ b/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isDashScopeE2EReady, runCli } from "./helpers.ts"; + +/** + * Speech list-voices E2E + */ + +describe("e2e: speech list-voices", () => { + test("speech 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["speech"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/speech|synthesize|recognize/i); + }); + + test("speech synthesize --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["speech", "synthesize", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/synthesize|--text|--voice|list-voices|model/i); + }); + + test("speech recognize --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["speech", "recognize", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/recognize|--url|audio|model/i); + }); +}); + +describe.skipIf(!isDashScopeE2EReady())("e2e: speech list-voices", () => { + test("speech synthesize 缺少 --text 且非 --list-voices 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli(["speech", "synthesize", "--non-interactive"]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--text|Usage:/i); + }); + + test("【cosyvoice-v3-flash】获取音色列表", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "speech", + "synthesize", + "--list-voices", + "--model", + "cosyvoice-v3-flash", + "--non-interactive", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("longxiaochun_v3"); + }, 60_000); +}); diff --git a/packages/cli/tests/e2e/speech-recognize.e2e.test.ts b/packages/cli/tests/e2e/speech-recognize.e2e.test.ts new file mode 100644 index 0000000..35396ad --- /dev/null +++ b/packages/cli/tests/e2e/speech-recognize.e2e.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + e2eLabelFromMetaUrl, + isBailianE2EMediaEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Speech recognize:help / 分组不依赖密钥;识别流程需媒体 E2E + DashScope。 + */ + +describe("e2e: speech recognize", () => { + test("speech 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["speech"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i); + }); + + test("speech recognize --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["speech", "recognize", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/recognize|--url|model|audio/i); + }); +}); + +describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( + "e2e: speech recognize(DashScope 媒体)", + () => { + test("speech recognize 缺少 --url 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli(["speech", "recognize", "--non-interactive"]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--url|Usage:/i); + }); + + test("【fun-asr】语音识别", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const outMp3 = join(outDir, "e2e-tts.mp3"); + const syn = await runCli([ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longxiaochun_v3", + "--text", + "端到端语音识别", + "--out", + outMp3, + "--non-interactive", + "--output", + "json", + ]); + expect(syn.exitCode, syn.stderr).toBe(0); + const synBody = parseStdoutJson<{ audio_url?: string }>(syn.stdout); + const audioUrl = synBody.audio_url; + expect(audioUrl?.startsWith("http")).toBe(true); + + const asrJson = join(outDir, "e2e-asr.json"); + const rec = await runCli([ + "speech", + "recognize", + "--model", + "fun-asr", + "--url", + audioUrl!, + "--language", + "zh", + "--out", + asrJson, + "--non-interactive", + "--output", + "json", + ]); + expect(rec.exitCode, rec.stderr).toBe(0); + const raw = readFileSync(asrJson, "utf8"); + expect(raw.length).toBeGreaterThan(2); + }, 300_000); + }, +); diff --git a/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts b/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts new file mode 100644 index 0000000..9627e9e --- /dev/null +++ b/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vite-plus/test"; +import { join } from "node:path"; +import { + e2eLabelFromMetaUrl, + isBailianE2EMediaEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Speech synthesize:help / 分组不依赖密钥;合成本地需媒体 E2E + DashScope。 + */ + +describe("e2e: speech synthesize", () => { + test("speech 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["speech"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i); + }); + + test("speech synthesize --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["speech", "synthesize", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/synthesize|--text|--voice|model/i); + }); +}); + +describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( + "e2e: speech synthesize(DashScope 媒体)", + () => { + test("speech synthesize 缺少 --text 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longxiaochun_v3", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--text|Usage:/i); + }); + + test("speech synthesize --dry-run 仅输出 request 且不调 TTS", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "speech", + "synthesize", + "--dry-run", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longxiaochun_v3", + "--text", + "干跑", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { model?: string; input?: { text?: string } } }>( + stdout, + ); + expect(data.request?.model).toBe("cosyvoice-v3-flash"); + expect(data.request?.input?.text).toBe("干跑"); + }); + + test("【cosyvoice-v3-flash】语音合成", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const outMp3 = join(outDir, "e2e-tts.mp3"); + const { stdout, stderr, exitCode } = await runCli([ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longxiaochun_v3", + "--text", + "端到端语音测试", + "--out", + outMp3, + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ saved?: string; audio_url?: string }>(stdout); + expect(data.saved).toBe(outMp3); + expect(data.audio_url?.length ?? 0).toBeGreaterThan(0); + }, 180_000); + }, +); diff --git a/packages/cli/tests/e2e/text-chat.e2e.test.ts b/packages/cli/tests/e2e/text-chat.e2e.test.ts new file mode 100644 index 0000000..768a1c5 --- /dev/null +++ b/packages/cli/tests/e2e/text-chat.e2e.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; + +/** + * Text chat:help / 分组不依赖密钥;对话需 DashScope。 + */ + +describe("e2e: text chat", () => { + test("text 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["text"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/text|chat/i); + }); + + test("text chat --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["text", "chat", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/chat|--message|model|stream/i); + }); +}); + +describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => { + test("text chat 缺少 --message 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "text", + "chat", + "--model", + "qwen3.7-max", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--message|Usage:/i); + }); + + test("text chat --dry-run 仅输出 request 且不调对话接口", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "text", + "chat", + "--dry-run", + "--model", + "qwen3.7-max", + "--message", + "干跑", + "--max-tokens", + "8", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { model?: string; messages?: Array<{ content?: string }> }; + }>(stdout); + expect(data.request?.model).toBe("qwen3.7-max"); + expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true); + }); + + test("【qwen3.7-max】文本对话", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "text", + "chat", + "--model", + "qwen3.7-max", + "--message", + "只回复一个字:好", + "--max-tokens", + "32", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ choices?: Array<{ message?: { content?: string } }> }>(stdout); + const text = data.choices?.[0]?.message?.content ?? ""; + expect(text.length).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/packages/cli/tests/e2e/video-download.e2e.test.ts b/packages/cli/tests/e2e/video-download.e2e.test.ts new file mode 100644 index 0000000..8e4dcf8 --- /dev/null +++ b/packages/cli/tests/e2e/video-download.e2e.test.ts @@ -0,0 +1,136 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + cliTimeoutPrefix, + e2eLabelFromMetaUrl, + isBailianE2EVideoEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** dry-run 占位 UUID */ +const PLACEHOLDER_TASK_ID = "00000000-0000-4000-8000-000000000001"; + +/** + * Video download:help / 分组不依赖密钥。 + * 真实下载:先 `video generate` 等待成功并取 stdout 中的 task_id,再 `video download`。 + */ + +describe("e2e: video download", () => { + test("video 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["video"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); + }); + + test("video download --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["video", "download", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/download|--task-id|--out/i); + }); +}); + +describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( + "e2e: video download(DashScope 视频)", + () => { + test("video download 缺少 --task-id 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "video", + "download", + "--out", + "/tmp/will-not-be-used.mp4", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--task-id|Usage:/i); + }); + + test("video download 缺少 --out 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "video", + "download", + "--task-id", + PLACEHOLDER_TASK_ID, + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--out|Usage:/i); + }); + + test("video download --dry-run 仅输出计划且不下载", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const fakeOut = join(outDir, "e2e-dry-not-written.mp4"); + const { stdout, stderr, exitCode } = await runCli([ + "video", + "download", + "--dry-run", + "--task-id", + PLACEHOLDER_TASK_ID, + "--out", + fakeOut, + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ task_id?: string; action?: string; out?: string }>(stdout); + expect(data.task_id).toBe(PLACEHOLDER_TASK_ID); + expect(data.action).toBe("download"); + expect(data.out).toBe(fakeOut); + }); + + test("先生成视频再按 task_id 下载", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const genMp4 = join(outDir, "e2e-gen-for-download.mp4"); + + const gen = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--model", + "happyhorse-1.0-t2v", + "--duration", + "3", + "--prompt", + "极简几何色块,静态镜头,用于下载测试", + "--download", + genMp4, + "--non-interactive", + "--output", + "json", + ]); + expect(gen.exitCode, gen.stderr).toBe(0); + const genData = parseStdoutJson<{ + status?: string; + task_id?: string; + video_url?: string; + saved?: string; + }>(gen.stdout); + expect(genData.status).toBe("SUCCEEDED"); + expect(genData.task_id?.length ?? 0, gen.stdout + gen.stderr).toBeGreaterThan(8); + expect(genData.video_url?.startsWith("https://")).toBe(true); + expect(existsSync(genMp4)).toBe(true); + + const downloadMp4 = join(outDir, "e2e-download.mp4"); + const dl = await runCli([ + ...cliTimeoutPrefix(), + "video", + "download", + "--task-id", + genData.task_id!, + "--out", + downloadMp4, + "--non-interactive", + "--output", + "json", + ]); + expect(dl.exitCode, dl.stderr).toBe(0); + const dlData = parseStdoutJson<{ saved?: string }>(dl.stdout); + expect(dlData.saved).toBe(downloadMp4); + expect(existsSync(downloadMp4)).toBe(true); + }, 3_600_000); + }, +); diff --git a/packages/cli/tests/e2e/video-edit.e2e.test.ts b/packages/cli/tests/e2e/video-edit.e2e.test.ts new file mode 100644 index 0000000..44fb32a --- /dev/null +++ b/packages/cli/tests/e2e/video-edit.e2e.test.ts @@ -0,0 +1,93 @@ +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + cliTimeoutPrefix, + e2eLabelFromMetaUrl, + isBailianE2EVideoEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Video edit E2E + */ + +describe("e2e: video edit", () => { + test("video 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["video"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); + }); + + test("video edit --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["video", "edit", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/edit|--video|--prompt|model/i); + }); +}); + +describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( + "e2e: video edit(DashScope 视频)", + () => { + test("video edit 缺少 --video 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "edit", + "--model", + "happyhorse-1.0-video-edit", + "--prompt", + "仅提示词", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--video|Usage:/i); + }); + + test("【happyhorse-1.0-video-edit】视频编辑", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const t2vPath = join(outDir, "e2e-video-t2v.mp4"); + + const t2v = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--model", + "happyhorse-1.0-t2v", + "--prompt", + "夕阳下海面波光,海边有两个小朋友在玩耍", + "--download", + t2vPath, + "--non-interactive", + "--output", + "json", + ]); + expect(t2v.exitCode, t2v.stderr).toBe(0); + const t2vData = parseStdoutJson<{ status?: string; video_url?: string }>(t2v.stdout); + expect(t2vData.status).toBe("SUCCEEDED"); + + const { stdout, stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "edit", + "--model", + "happyhorse-1.0-video-edit", + "--video", + t2vData.video_url!, + "--prompt", + "整体色调偏暖", + "--download", + join(outDir, "e2e-video-edit.mp4"), + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ status?: string; video_url?: string }>(stdout); + expect(data.status).toBe("SUCCEEDED"); + expect(data.video_url?.startsWith("https://")).toBe(true); + }, 7_200_000); + }, +); diff --git a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts new file mode 100644 index 0000000..df61a63 --- /dev/null +++ b/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts @@ -0,0 +1,115 @@ +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + cliTimeoutPrefix, + e2eLabelFromMetaUrl, + isBailianE2EVideoEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Video generate (i2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。 + */ + +describe("e2e: video generate (i2v)", () => { + test("video 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["video"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); + }); + + test("video generate --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["video", "generate", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/generate|--prompt|--image|model/i); + }); +}); + +describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( + "e2e: video generate (i2v)(DashScope 视频)", + () => { + test("video generate 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--model", + "happyhorse-1.0-i2v", + "--image", + "https://example.com/placeholder.png", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--prompt|Usage:/i); + }); + + test("video generate --dry-run(无 --image)仅输出 request(t2v 路径不调上传)", async () => { + const { stdout, stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--dry-run", + "--model", + "happyhorse-1.0-t2v", + "--prompt", + "干跑无图", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { input?: { prompt?: string; media?: unknown } } }>( + stdout, + ); + expect(data.request?.input?.prompt).toBe("干跑无图"); + expect(data.request?.input?.media).toBeUndefined(); + }); + + test("【happyhorse-1.0-i2v】图片生成视频", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const png = join(outDir, "e2e-gen.png"); + const gen = await runCli([ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--prompt", + "一只简笔画小猫,白底", + "--out-dir", + outDir, + "--out-prefix", + "e2e-gen", + "--non-interactive", + "--output", + "json", + ]); + expect(gen.exitCode, gen.stderr).toBe(0); + const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout); + const imagePath = genData.saved?.[0] ?? png; + + const { stdout, stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--model", + "happyhorse-1.0-i2v", + "--image", + imagePath, + "--prompt", + "镜头缓慢推进,小猫微微动一下", + "--download", + join(outDir, "e2e-video-i2v.mp4"), + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ status?: string; video_url?: string; saved?: string }>(stdout); + expect(data.status).toBe("SUCCEEDED"); + expect(data.video_url?.startsWith("https://")).toBe(true); + }, 3_600_000); + }, +); diff --git a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts b/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts new file mode 100644 index 0000000..56af3e1 --- /dev/null +++ b/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts @@ -0,0 +1,91 @@ +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + cliTimeoutPrefix, + e2eLabelFromMetaUrl, + isBailianE2EVideoEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Video generate (t2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。 + */ + +describe("e2e: video generate (t2v)", () => { + test("video 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["video"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); + }); + + test("video generate --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["video", "generate", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/generate|--prompt|--model|download|image/i); + }); +}); + +describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( + "e2e: video generate (t2v)(DashScope 视频)", + () => { + test("video generate 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--model", + "happyhorse-1.0-t2v", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--prompt|Usage:/i); + }); + + test("video generate --dry-run(无 --image)仅输出 request 且不调生成接口", async () => { + const { stdout, stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--dry-run", + "--model", + "happyhorse-1.0-t2v", + "--prompt", + "干跑校验", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { model?: string; input?: { prompt?: string } } }>( + stdout, + ); + expect(data.request?.model).toBe("happyhorse-1.0-t2v"); + expect(data.request?.input?.prompt).toBe("干跑校验"); + }); + + test("【happyhorse-1.0-t2v】文本生成视频", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const { stdout, stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "generate", + "--model", + "happyhorse-1.0-t2v", + "--prompt", + "夕阳下海面波光,远景静态镜头", + "--download", + join(outDir, "e2e-video-t2v.mp4"), + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ status?: string; video_url?: string }>(stdout); + expect(data.status).toBe("SUCCEEDED"); + expect(data.video_url?.startsWith("https://")).toBe(true); + }, 3_600_000); + }, +); diff --git a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts new file mode 100644 index 0000000..f859470 --- /dev/null +++ b/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + cliTimeoutPrefix, + e2eLabelFromMetaUrl, + isBailianE2EVideoEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCli, +} from "./helpers.ts"; + +/** + * Video ref (r2v):help / 分组不依赖密钥;参考生成需视频 E2E + DashScope。 + */ + +describe("e2e: video ref (r2v)", () => { + test("video 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["video"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); + }); + + test("video ref --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["video", "ref", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/ref|--prompt|--image|model/i); + }); +}); + +describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( + "e2e: video ref (r2v)(DashScope 视频)", + () => { + test("video ref 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "ref", + "--model", + "happyhorse-1.0-r2v", + "--image", + "https://example.com/x.png", + "--non-interactive", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--prompt|Usage:/i); + }); + + test("video ref 缺少 --image 与 --ref-video 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "ref", + "--model", + "happyhorse-1.0-r2v", + "--prompt", + "仅有描述无素材", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--image|ref-video|At least one|required/i); + }); + + test("【happyhorse-1.0-r2v】视频参考生成", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const gen = await runCli([ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--prompt", + "一只简笔画小猫,白底", + "--out-dir", + outDir, + "--out-prefix", + "e2e-gen", + "--non-interactive", + "--output", + "json", + ]); + expect(gen.exitCode, gen.stderr).toBe(0); + const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout); + const imagePath = genData.saved?.[0]; + expect(imagePath).toBeTruthy(); + + const { stdout, stderr, exitCode } = await runCli([ + ...cliTimeoutPrefix(), + "video", + "ref", + "--model", + "happyhorse-1.0-r2v", + "--prompt", + "图1在画面中心轻微晃动", + "--image", + imagePath!, + "--download", + join(outDir, "e2e-video-r2v.mp4"), + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ status?: string; video_url?: string }>(stdout); + expect(data.status).toBe("SUCCEEDED"); + expect(data.video_url?.startsWith("https://")).toBe(true); + }, 3_600_000); + }, +); diff --git a/packages/cli/tests/e2e/video-task-get.e2e.test.ts b/packages/cli/tests/e2e/video-task-get.e2e.test.ts new file mode 100644 index 0000000..a6029f7 --- /dev/null +++ b/packages/cli/tests/e2e/video-task-get.e2e.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; + +const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim(); + +/** + * Video task get:help / 分组不依赖密钥;查询需 E2E + task_id + DashScope。 + */ + +describe("e2e: video task get", () => { + test("video 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["video"]); + expect(exitCode, stderr).toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); + }); + + test("video task get --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["video", "task", "get", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/task|get|--task-id/i); + }); +}); + +describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( + "e2e: video task get(DashScope)", + () => { + test("video task get 缺少 --task-id 时打印子命令帮助并退出 (0)", async () => { + const { stderr, exitCode } = await runCli([ + "video", + "task", + "get", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode).toBe(0); + expect(stderr).toMatch(/--task-id|Usage:/i); + }); + + test("video task get --dry-run 仅回显 task_id 且不调任务接口", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "video", + "task", + "get", + "--dry-run", + "--task-id", + taskId!, + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ task_id?: string }>(stdout); + expect(data.task_id).toBe(taskId); + }); + + test("根据 task_id 查询任务状态", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "video", + "task", + "get", + "--task-id", + taskId!, + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ task_status?: string; task_id?: string }>(stdout); + expect(data.task_id).toBe(taskId); + expect(data.task_status?.length ?? 0).toBeGreaterThan(0); + }, 60_000); + }, +); diff --git a/packages/cli/tests/index.test.ts b/packages/cli/tests/index.test.ts new file mode 100644 index 0000000..507a100 --- /dev/null +++ b/packages/cli/tests/index.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "vite-plus/test"; +import { createStepDispatcher } from "../src/pipeline/dispatcher.ts"; +import { executePipeline } from "../src/pipeline/executor.ts"; +import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts"; + +test("cli package skeleton", () => { + expect(true).toBe(true); +}); + +test("pipeline execution can use an isolated step dispatcher", async () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input, ctx) => ({ + data: { input, hasSignal: !!ctx.signal }, + })); + + const controller = new AbortController(); + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [{ id: "echo", type: "test/echo", input: { message: "hello" } }], + }; + + const report = await executePipeline( + pipeline, + {}, + { + stepDispatcher: dispatcher, + signal: controller.signal, + }, + ); + + expect(report.status).toBe("succeeded"); + expect(report.steps[0]?.output?.data).toEqual({ + input: { message: "hello" }, + hasSignal: true, + }); +}); diff --git a/packages/cli/tests/stress/lib/argv-parse.mjs b/packages/cli/tests/stress/lib/argv-parse.mjs new file mode 100644 index 0000000..faa8d69 --- /dev/null +++ b/packages/cli/tests/stress/lib/argv-parse.mjs @@ -0,0 +1,98 @@ +/** + * 通用压测脚本 argv 解析:--help、-c/-n/-m及等号形式。 + * @param {string[]} argv 通常为 process.argv + * @returns {Record} CONCURRENCY、COUNT、MODEL 等与环境变量对齐的键 + */ +export function parseStressArgv(argv) { + const overrides = {}; + for (let i = 2; i < argv.length; i++) { + const arg = argv[i]; + const next = argv[i + 1]; + + /** @param {string} key @param {string | undefined} value */ + const assign = (key, value) => { + if (value != null && String(value).trim() !== "") { + overrides[key] = String(value).trim(); + } + }; + + if (arg === "--help" || arg === "-h") { + overrides.__help = "1"; + continue; + } + if (arg === "--concurrency" || arg === "-c") { + assign("CONCURRENCY", next); + i++; + continue; + } + if (arg === "--count" || arg === "-n") { + assign("COUNT", next); + i++; + continue; + } + if (arg === "--model" || arg === "-m") { + assign("MODEL", next); + i++; + continue; + } + if (arg.startsWith("--concurrency=")) { + assign("CONCURRENCY", arg.slice("--concurrency=".length)); + continue; + } + if (arg.startsWith("--count=")) { + assign("COUNT", arg.slice("--count=".length)); + continue; + } + if (arg.startsWith("--model=")) { + assign("MODEL", arg.slice("--model=".length)); + continue; + } + if (arg === "--voice") { + assign("VOICE", next); + i++; + continue; + } + if (arg.startsWith("--voice=")) { + assign("VOICE", arg.slice("--voice=".length)); + continue; + } + if (arg === "--report-dir") { + assign("REPORT_DIR", next); + i++; + continue; + } + if (arg.startsWith("--report-dir=")) { + assign("REPORT_DIR", arg.slice("--report-dir=".length)); + continue; + } + if (arg === "--reuse-fixtures") { + overrides.__reuseFixtures = "1"; + continue; + } + if (arg === "--setup-only") { + overrides.__setupOnly = "1"; + continue; + } + if (arg === "--fixtures-dir") { + assign("__fixturesDir", next); + i++; + continue; + } + if (arg.startsWith("--fixtures-dir=")) { + assign("__fixturesDir", arg.slice("--fixtures-dir=".length)); + continue; + } + } + return overrides; +} + +/** + * 读取配置:parseStressArgv 结果优先于环境变量。 + * @param {Record} argvOverrides parseStressArgv 返回值 + */ +export function optFrom(argvOverrides, name) { + const raw = argvOverrides[name] ?? process.env[name]; + if (raw == null) return undefined; + const t = String(raw).trim(); + return t === "" ? undefined : t; +} diff --git a/packages/cli/tests/stress/lib/cli-runner.mjs b/packages/cli/tests/stress/lib/cli-runner.mjs new file mode 100644 index 0000000..831b16d --- /dev/null +++ b/packages/cli/tests/stress/lib/cli-runner.mjs @@ -0,0 +1,280 @@ +/** + * 子进程执行 CLI:spawn node main.ts,解析 stdout。 + */ +import { spawn } from "node:child_process"; +import { truncateLog, extractError, isRateLimitFailure } from "./parsers.mjs"; +import { captureTraceIdsFromText, enrichTraceIdsAsync } from "./trace-ids.mjs"; + +/** 压测默认开启 --verbose,便于从 stderr 捕获 request_id(设 STRESS_VERBOSE_REQUEST_ID=0 可关) */ +export function stressCliArgs(cliArgs) { + if (process.env.STRESS_VERBOSE_REQUEST_ID === "0") return cliArgs; + if (cliArgs.includes("--verbose")) return cliArgs; + return ["--verbose", ...cliArgs]; +} + +/** 对 shell 参数做引号包裹。 */ +export function shellQuote(s) { + if (/^[A-Za-z0-9_./:=+-]+$/.test(s)) return s; + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** + * 构建可复制的 pnpm 展示命令。 + * @param {string[]} cliArgs image generate 之后的参数 + */ +export function buildDisplayCommand(cliArgs) { + return ["pnpm", "run", "dev", ...cliArgs].map(shellQuote).join(" "); +} + +/** 追加捕获输出并限制缓冲区。 */ +export function appendCapturedLog(current, chunk, maxCapture) { + const next = current + chunk.toString(); + if (next.length <= maxCapture) return next; + return next.slice(-maxCapture); +} + +/** + * 执行一次 CLI 调用。 + * @param {object} ctx + * @param {(stdout: string, context: object) => { ok: boolean, data?: object, error?: string }} parseStdout 成功解析;可对 asr 读取 context.asrOutPath + */ +export function executeSingleCli(ctx) { + const { + MAIN_TS, + CLI_PACKAGE, + TIMEOUT_MS, + MAX_LOG_CAPTURE, + index, + displayCommand, + cliArgs, + baseRecord, + parseStdout, + readFileOptional, + asrOutPath, + } = ctx; + + const startedAt = Date.now(); + + return new Promise((resolve) => { + const child = spawn("node", [MAIN_TS, ...stressCliArgs(cliArgs)], { + cwd: CLI_PACKAGE, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + /** 子进程运行期间累积的 trace id(不受日志尾部截断影响) */ + let streamTrace = {}; + + const onLogChunk = (which, chunk) => { + streamTrace = captureTraceIdsFromText(chunk.toString(), streamTrace); + if (which === "stdout") { + stdout = appendCapturedLog(stdout, chunk, MAX_LOG_CAPTURE); + } else { + stderr = appendCapturedLog(stderr, chunk, MAX_LOG_CAPTURE); + } + }; + + child.stdout?.on("data", (chunk) => onLogChunk("stdout", chunk)); + child.stderr?.on("data", (chunk) => onLogChunk("stderr", chunk)); + + const timer = setTimeout(() => { + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5000); + }, TIMEOUT_MS); + + const pack = async (payload) => { + const enriched = await enrichTraceIdsAsync({ + ...payload, + streamRequestId: streamTrace.requestId, + streamTaskId: streamTrace.taskId, + stdout: payload.stdout, + stderr: payload.stderr, + }); + return { + ...enriched, + stdout: enriched.stdout != null ? truncateLog(String(enriched.stdout), 2048) : undefined, + stderr: enriched.stderr != null ? truncateLog(String(enriched.stderr), 2048) : undefined, + }; + }; + + child.on("close", async (code, signal) => { + clearTimeout(timer); + const durationMs = Date.now() - startedAt; + /** @type {Record} */ + const base = { + ...baseRecord, + index: index + 1, + command: displayCommand, + cwd: CLI_PACKAGE, + durationMs, + durationSec: (durationMs / 1000).toFixed(2), + }; + + if (signal === "SIGTERM" || signal === "SIGKILL") { + resolve( + await pack({ + ...base, + status: "failed", + exitCode: code ?? 1, + error: `超时(>${TIMEOUT_MS}ms)或被信号 ${signal ?? "终止"}`, + stdout: stdout.trim(), + stderr: stderr.trim(), + }), + ); + return; + } + + const trimmedOut = stdout.trim(); + const trimmedErr = stderr.trim(); + + if (code !== 0) { + resolve( + await pack({ + ...base, + status: "failed", + exitCode: code ?? 1, + error: extractError(trimmedErr, trimmedOut, code ?? 1), + stdout: trimmedOut, + stderr: trimmedErr, + }), + ); + return; + } + + let parsed = + typeof parseStdout === "function" + ? await parseStdout(trimmedOut, { + stderr: trimmedErr, + asrOutPath, + readFileOptional, + index, + }) + : { ok: false, error: "parseStdout 未配置" }; + + if (!parsed.ok) { + resolve( + await pack({ + ...base, + status: "failed", + exitCode: code ?? 1, + error: + parsed.error ?? + extractError(trimmedErr, trimmedOut, code ?? 1) ?? + "退出码为 0 但未解析到结果", + stdout: trimmedOut, + stderr: trimmedErr, + }), + ); + return; + } + + resolve( + await pack({ + ...base, + status: "success", + exitCode: 0, + ...parsed.data, + stdout: trimmedOut, + stderr: trimmedErr, + }), + ); + }); + + child.on("error", async (err) => { + clearTimeout(timer); + resolve( + await pack({ + ...baseRecord, + index: index + 1, + command: displayCommand, + cwd: CLI_PACKAGE, + durationMs: Date.now() - startedAt, + durationSec: ((Date.now() - startedAt) / 1000).toFixed(2), + status: "failed", + exitCode: 1, + error: /** @type {Error} */ (err).message, + stdout: stdout.trim(), + stderr: String(err), + }), + ); + }); + }); +} + +/** + * 固定并发池。 + * @param {number} total + * @param {number} concurrency + * @param {(index: number) => Promise} worker + */ +export async function runPool(total, concurrency, worker, opts = {}) { + const results = Array.from({ length: total }); + let next = 0; + let doneCount = 0; + + async function runner() { + while (true) { + const i = next++; + if (i >= total) break; + results[i] = await worker(i); + doneCount++; + if (opts.onTaskDone) opts.onTaskDone(results[i], i); + process.stderr.write(`\r[进度] ${doneCount}/${total} 已完成`); + } + } + + const workers = Array.from({ length: Math.min(concurrency, total) }, () => runner()); + await Promise.all(workers); + process.stderr.write("\n"); + return results; +} + +/** + * 单任务带限流重试。 + * @param {object} opts + */ +export async function runOneWithRetries(opts) { + const { index, rateLimiter, maxRetries, retryBaseMs, sleepFn, runAttempt } = opts; + const { sleep } = await import("./rate-limit.mjs"); + const wait = sleepFn ?? sleep; + + const overallStarted = Date.now(); + let attempt = 0; + /** @type {object | undefined} */ + let lastResult; + + while (attempt <= maxRetries) { + attempt++; + if (rateLimiter) { + await rateLimiter.acquire(); + } + + lastResult = await runAttempt(index); + + if (lastResult.status === "success") { + if (attempt > 1) { + lastResult.retries = attempt - 1; + } + break; + } + + if (!isRateLimitFailure(lastResult) || attempt > maxRetries) { + break; + } + + const waitMs = retryBaseMs * attempt; + process.stderr.write( + `\n[#${index + 1}] 触发限流,${(waitMs / 1000).toFixed(1)}s 后重试 (${attempt}/${maxRetries})\n`, + ); + await wait(waitMs); + } + + const durationMs = Date.now() - overallStarted; + return { + ...lastResult, + durationMs, + durationSec: (durationMs / 1000).toFixed(2), + }; +} diff --git a/packages/cli/tests/stress/lib/define-stress-target.mjs b/packages/cli/tests/stress/lib/define-stress-target.mjs new file mode 100644 index 0000000..345a343 --- /dev/null +++ b/packages/cli/tests/stress/lib/define-stress-target.mjs @@ -0,0 +1,280 @@ +/** + * 压测 target 工厂函数:封装共享逻辑,每个 target 只需声明式配置。 + */ +import { mkdirSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { DEFAULT_CLI_PACKAGE, MONOREPO_ROOT, resolveMainTs } from "./paths.mjs"; +import { parseStressArgv, optFrom } from "./argv-parse.mjs"; +import { resolveStressCountAndConcurrency } from "./stress-config.mjs"; +import { SubmissionRateLimiter } from "./rate-limit.mjs"; +import { ensurePrerequisites } from "./fixtures.mjs"; +import { + buildDisplayCommand, + executeSingleCli, + runPool, + runOneWithRetries, +} from "./cli-runner.mjs"; +import { logReportStep } from "./report.mjs"; +import { finishStressRun, stressExit, createIncrementalWriter } from "./finish-run.mjs"; + +const execFileAsync = promisify(execFile); + +/** + * @param {object} config + * @returns {(forwardedArgv: string[], ctx?: object) => Promise} + */ +export function defineStressTarget(config) { + return async function runStress(forwardedArgv, ctx = {}) { + const ARGV = parseStressArgv(["node", "stress", ...forwardedArgv]); + + if (ARGV.__help) { + console.error(config.helpText ?? `pnpm run test:stress -- ${config.canonical}`); + return stressExit(ctx, 0, { skipped: true }); + } + + const globals = ctx?.globals ?? {}; + const CLI_PACKAGE = optFrom(ARGV, "CLI_PACKAGE") || DEFAULT_CLI_PACKAGE; + const MAIN_TS = resolveMainTs(CLI_PACKAGE); + + const canonical = ctx?.canonicalTarget ?? config.canonical; + const { + count: COUNT, + concurrency: CONCURRENCY, + concurrencyExplicit: CONCURRENCY_EXPLICIT, + } = resolveStressCountAndConcurrency({ + canonical, + argv: ARGV, + configPath: globals.stressConfigPath, + }); + + const MODEL = optFrom(ARGV, "MODEL") || config.defaultModel; + const TIMEOUT_MS = Math.max( + config.minTimeoutMs ?? 10_000, + parseInt(optFrom(ARGV, "TIMEOUT_MS") ?? String(config.defaultTimeoutMs ?? 120_000), 10) || + (config.defaultTimeoutMs ?? 120_000), + ); + const CLI_TIMEOUT_SEC = Math.ceil(TIMEOUT_MS / 1000); + const MAX_RETRIES = Math.max( + 0, + parseInt(optFrom(ARGV, "MAX_RETRIES") ?? String(config.defaultMaxRetries ?? 3), 10) || + (config.defaultMaxRetries ?? 3), + ); + const RETRY_BASE_MS = Math.max( + 500, + parseInt(optFrom(ARGV, "RETRY_BASE_MS") ?? String(config.defaultRetryBaseMs ?? 3000), 10) || + (config.defaultRetryBaseMs ?? 3000), + ); + const DISABLE_RATE_LIMIT = optFrom(ARGV, "DISABLE_RATE_LIMIT") === "1"; + const MAX_LOG_CAPTURE = Math.max( + 4096, + parseInt(optFrom(ARGV, "MAX_LOG_CAPTURE") ?? "65536", 10) || 65536, + ); + + const RATE_LIMIT_MAX = Math.max( + 1, + parseInt(optFrom(ARGV, "RATE_LIMIT_MAX") ?? String(config.defaultRateLimitMax ?? 10), 10) || + (config.defaultRateLimitMax ?? 10), + ); + const RATE_LIMIT_WINDOW_MS = Math.max( + 100, + parseInt( + optFrom(ARGV, "RATE_LIMIT_WINDOW_MS") ?? String(config.defaultRateLimitWindowMs ?? 1000), + 10, + ) || + (config.defaultRateLimitWindowMs ?? 1000), + ); + + const POLL_INTERVAL = config.hasPollInterval + ? Math.max( + 1, + parseInt( + optFrom(ARGV, "POLL_INTERVAL") ?? String(config.defaultPollInterval ?? 10), + 10, + ) || + (config.defaultPollInterval ?? 10), + ) + : null; + + const extraParams = config.extraParams ? config.extraParams(ARGV) : {}; + + const runId = new Date().toISOString().replace(/[:.]/g, "-"); + const BATCH_ROOT = + ctx.reportDirOverride || + optFrom(ARGV, "REPORT_DIR") || + join(MONOREPO_ROOT, "test", "output", `${config.batchDirPrefix}-${runId}`); + + mkdirSync(BATCH_ROOT, { recursive: true }); + + // Fixture handling + let fixtureRef = null; + if (config.fixtureKind) { + const prerequisites = await ensurePrerequisites({ + canonicalTarget: canonical, + batchRoot: BATCH_ROOT, + mainTs: MAIN_TS, + cliPackage: CLI_PACKAGE, + globals: { + reuseFixtures: globals.reuseFixtures === true || ARGV.__reuseFixtures === "1", + fixturesDir: globals.fixturesDir, + }, + setupTimeoutMs: config.fixtureSetupTimeoutMs ?? 600_000, + videoSetupTimeoutMs: config.videoSetupTimeoutMs, + }); + + if (globals.setupOnly === true || ARGV.__setupOnly === "1") { + console.error("--setup-only:仅生成前置资源,已结束"); + return stressExit(ctx, 0, { skipped: true }); + } + + fixtureRef = config.resolveFixtureRef(prerequisites); + if (!fixtureRef) { + console.error(config.fixtureRefErrorMessage ?? "前置 manifest 缺少所需资源"); + return stressExit(ctx, 1); + } + } + + // Environment validation + if (!existsSync(MAIN_TS)) { + console.error(`未找到 CLI 入口: ${MAIN_TS}`); + return stressExit(ctx, 1); + } + try { + await execFileAsync("node", ["--version"], { encoding: "utf8" }); + } catch { + console.error("未找到 node。"); + return stressExit(ctx, 1); + } + + const rateLimiter = DISABLE_RATE_LIMIT + ? null + : new SubmissionRateLimiter(RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS); + + const buildCtx = { + MODEL, + CLI_TIMEOUT_SEC, + POLL_INTERVAL, + BATCH_ROOT, + fixtureRef, + extraParams, + }; + + /** @param {number} index */ + const runAttempt = async (index) => { + const prompt = config.generatePrompt(index, buildCtx); + const runDir = join(BATCH_ROOT, `run-${String(index + 1).padStart(3, "0")}`); + mkdirSync(runDir, { recursive: true }); + + const cliArgs = config.buildCliArgs({ ...buildCtx, prompt, runDir, index }); + const displayCommand = buildDisplayCommand(cliArgs); + const baseRecord = { prompt, runDir }; + const extraBase = config.buildBaseRecord + ? config.buildBaseRecord({ ...buildCtx, prompt, runDir, index }) + : {}; + + return executeSingleCli({ + MAIN_TS, + CLI_PACKAGE, + TIMEOUT_MS, + MAX_LOG_CAPTURE, + index, + displayCommand, + cliArgs, + baseRecord: { ...baseRecord, ...extraBase }, + ...(extraBase.asrOutPath ? { asrOutPath: extraBase.asrOutPath } : {}), + ...(extraBase.readFileOptional ? { readFileOptional: extraBase.readFileOptional } : {}), + parseStdout: config.parseStdout, + }); + }; + + // Banner + if (config.printBanner) { + config.printBanner({ + COUNT, + CONCURRENCY, + CONCURRENCY_EXPLICIT, + MODEL, + TIMEOUT_MS, + CLI_TIMEOUT_SEC, + POLL_INTERVAL, + MAX_RETRIES, + RETRY_BASE_MS, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_MS, + DISABLE_RATE_LIMIT, + CLI_PACKAGE, + BATCH_ROOT, + fixtureRef, + extraParams, + }); + } else { + const startedAtIso = new Date().toISOString(); + console.error(`批量压测(${canonical})`); + console.error(` 任务数: ${COUNT}`); + console.error(` 并发数: ${CONCURRENCY}${CONCURRENCY_EXPLICIT ? "" : "(配置文件/默认)"}`); + console.error(` 模型: ${MODEL}`); + if (POLL_INTERVAL != null) console.error(` 轮询间隔: ${POLL_INTERVAL}s`); + console.error( + ` 任务下发限流: ${DISABLE_RATE_LIMIT ? "已关闭" : `${RATE_LIMIT_MAX} 次 / ${RATE_LIMIT_WINDOW_MS}ms`}`, + ); + console.error(` 限流重试: 最多 ${MAX_RETRIES} 次,基数 ${RETRY_BASE_MS}ms`); + console.error(` CLI 超时: ${CLI_TIMEOUT_SEC}s`); + console.error(` 输出: ${BATCH_ROOT}`); + console.error(` 开始: ${startedAtIso}`); + console.error(""); + } + + // Pool execution + const startedAt = Date.now(); + const onTaskDone = createIncrementalWriter(BATCH_ROOT); + const results = await runPool( + COUNT, + CONCURRENCY, + (i) => + runOneWithRetries({ + index: i, + rateLimiter, + maxRetries: MAX_RETRIES, + retryBaseMs: RETRY_BASE_MS, + runAttempt, + }), + { onTaskDone }, + ); + + process.stderr.write("\n"); + logReportStep("所有任务已结束,开始生成报告…"); + + const finishedAt = Date.now(); + const reportMeta = { + startedAt, + finishedAt, + finishedAtIso: new Date(finishedAt).toISOString(), + concurrency: CONCURRENCY, + model: MODEL, + pollInterval: POLL_INTERVAL, + timeoutMs: TIMEOUT_MS, + rateLimitLabel: `${RATE_LIMIT_MAX} 次 / ${RATE_LIMIT_WINDOW_MS}ms`, + rateLimitDisabled: DISABLE_RATE_LIMIT, + maxRetries: MAX_RETRIES, + retryBaseMs: RETRY_BASE_MS, + cliPackage: CLI_PACKAGE, + batchRoot: BATCH_ROOT, + }; + + if (config.extraReportMeta) { + const extra = config.extraReportMeta({ ...buildCtx, fixtureRef }); + if (extra.extraMdLines) reportMeta.extraMdLines = extra.extraMdLines; + if (extra.extraHtmlMeta) reportMeta.extraHtmlMeta = extra.extraHtmlMeta; + } + + return finishStressRun({ + batchRoot: BATCH_ROOT, + results, + reportMeta, + reportSpec: config.reportSpec, + ctx: { ...ctx, canonicalTarget: canonical }, + }); + }; +} diff --git a/packages/cli/tests/stress/lib/fetch-request-id.mjs b/packages/cli/tests/stress/lib/fetch-request-id.mjs new file mode 100644 index 0000000..ad04ec5 --- /dev/null +++ b/packages/cli/tests/stress/lib/fetch-request-id.mjs @@ -0,0 +1,64 @@ +/** + * 压测专用:按 task_id 查询 DashScope 任务详情,补齐 stdout 中缺失的 request_id。 + * 不修改 CLI 业务代码;仅在 mergeTraceIds 仍无 requestId 时调用。 + */ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/api/v1"; + +/** + * @returns {string | undefined} + */ +function readApiKeyFromEnvOrConfig() { + const fromEnv = process.env.DASHSCOPE_API_KEY?.trim(); + if (fromEnv) return fromEnv; + + try { + const configPath = join(homedir(), ".bailian", "config.json"); + const raw = JSON.parse(readFileSync(configPath, "utf8")); + const key = raw.api_key ?? raw.apiKey ?? raw.dashscope_api_key ?? raw.dashscopeApiKey; + return typeof key === "string" && key.trim() ? key.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * @returns {string} + */ +function resolveDashScopeBaseUrl() { + const raw = process.env.DASHSCOPE_BASE_URL?.trim(); + if (!raw) return DEFAULT_BASE_URL; + return raw.replace(/\/$/, ""); +} + +/** + * @param {string} taskId + * @returns {Promise} + */ +export async function fetchRequestIdByTaskId(taskId) { + const id = String(taskId ?? "").trim(); + if (!id) return undefined; + + const apiKey = readApiKeyFromEnvOrConfig(); + if (!apiKey) return undefined; + + const url = `${resolveDashScopeBaseUrl()}/tasks/${encodeURIComponent(id)}`; + + try { + const res = await fetch(url, { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) return undefined; + + const data = await res.json(); + const rid = data?.request_id ?? data?.requestId; + return rid != null && String(rid).trim() ? String(rid).trim() : undefined; + } catch { + return undefined; + } +} diff --git a/packages/cli/tests/stress/lib/finish-run.mjs b/packages/cli/tests/stress/lib/finish-run.mjs new file mode 100644 index 0000000..a7ff591 --- /dev/null +++ b/packages/cli/tests/stress/lib/finish-run.mjs @@ -0,0 +1,79 @@ +/** + * 压测收尾:写报告、打印路径;套件模式下返回摘要而不 process.exit。 + */ +import { writeStressReports } from "./report.mjs"; +import { TARGET_DISPLAY_NAMES } from "./suite-catalog.mjs"; +import { IncrementalWriter } from "./incremental-writer.mjs"; + +/** + * 创建增量写入器,返回可直接传给 runPool opts.onTaskDone 的回调。 + * @param {string} batchRoot + */ +export function createIncrementalWriter(batchRoot) { + const writer = new IncrementalWriter(batchRoot); + return (result, index) => writer.append(result, index); +} + +/** + * @param {object | undefined} ctx + * @param {number} exitCode + * @param {Record} [extra] + */ +export function stressExit(ctx, exitCode, extra = {}) { + const payload = { exitCode, ...extra }; + if (ctx?.noExit) return payload; + process.exit(exitCode); +} + +/** + * @param {object} params + * @param {string} params.batchRoot + * @param {object[]} params.results + * @param {object} params.reportMeta 需含 startedAt / finishedAt / finishedAtIso + * @param {object} params.reportSpec + * @param {object} [params.ctx] + */ +export function finishStressRun({ batchRoot, results, reportMeta, reportSpec, ctx }) { + const { reportPath, reportHtmlPath, jsonPath } = writeStressReports( + batchRoot, + results, + reportMeta, + reportSpec, + ); + + const successCount = results.filter((r) => r.status === "success").length; + const failCount = results.length - successCount; + const successRate = results.length > 0 ? successCount / results.length : 0; + /** 报告用:是否存在失败任务(不用于进程退出码) */ + const hadFailures = failCount > 0; + + const canonical = ctx?.canonicalTarget ?? "unknown"; + const displayName = ctx?.displayName ?? TARGET_DISPLAY_NAMES[canonical] ?? canonical; + + console.error(""); + console.error( + `完成 [${displayName}]: 成功 ${successCount} / 失败 ${failCount} / 共 ${results.length}`, + ); + console.error(`报告 Markdown: ${reportPath}`); + console.error(`报告 HTML: ${reportHtmlPath}`); + console.error(`原始数据: ${jsonPath}`); + + return stressExit(ctx, 0, { + canonicalTarget: canonical, + displayName, + batchRoot, + startedAt: reportMeta.startedAt, + finishedAt: reportMeta.finishedAt, + count: results.length, + concurrency: reportMeta.concurrency, + model: reportMeta.model, + successCount, + failCount, + successRate, + hadFailures, + exitCode: hadFailures ? 1 : 0, + reportPath, + reportHtmlPath, + jsonPath, + }); +} diff --git a/packages/cli/tests/stress/lib/fixtures.mjs b/packages/cli/tests/stress/lib/fixtures.mjs new file mode 100644 index 0000000..8b8f9a4 --- /dev/null +++ b/packages/cli/tests/stress/lib/fixtures.mjs @@ -0,0 +1,277 @@ +/** + * 压测前置资源:音频 / 图片 / 短视频,写入 fixtures/prerequisites.json。 + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildDisplayCommand, executeSingleCli } from "./cli-runner.mjs"; +import { parseImageResult, parseSpeechSynthesizeResult, parseVideoResult } from "./parsers.mjs"; + +/** 按压测 target 决定需要哪些前置类型 */ +export function fixtureKindsForCanonicalTarget(name) { + const map = { + "speech-asr": ["audio"], + "image-edit": ["image"], + "video-i2v": ["image"], + "video-ref": ["image"], + "video-edit": ["video"], + }; + return map[name] ?? []; +} + +/** + * @param {object} ctx + * @param {string} ctx.canonicalTarget + * @param {string} ctx.batchRoot + * @param {string} ctx.mainTs + * @param {string} ctx.cliPackage + * @param {{ reuseFixtures?: boolean, fixturesDir?: string }} ctx.globals + */ +export async function ensurePrerequisites(ctx) { + const kinds = new Set(fixtureKindsForCanonicalTarget(ctx.canonicalTarget)); + if (kinds.size === 0) { + return { createdAt: new Date().toISOString(), prerequisites: {} }; + } + + /** 从外部目录拷贝已有 manifest(不改文件,只读) */ + if (ctx.globals?.fixturesDir) { + const external = ctx.globals.fixturesDir; + const manifestPath = join(external, "prerequisites.json"); + if (!existsSync(manifestPath)) { + console.error(`未找到外部 fixtures: ${manifestPath}`); + process.exit(1); + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + validateManifestHasKinds(manifest, kinds); + return manifest; + } + + const fixturesDir = join(ctx.batchRoot, "fixtures"); + mkdirSync(fixturesDir, { recursive: true }); + const manifestPath = join(fixturesDir, "prerequisites.json"); + + if (ctx.globals?.reuseFixtures && existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + validateManifestHasKinds(manifest, kinds); + return manifest; + } + + const MAX_LOG = Math.max(4096, parseInt(process.env.MAX_LOG_CAPTURE ?? "65536", 10) || 65536); + + /** @type {{ createdAt: string, audio?: object, image?: object, video?: object }} */ + const manifest = { createdAt: new Date().toISOString() }; + + const voice = process.env.STRESS_TTS_VOICE?.trim() || "longanyang"; + + /** 执行一次 setup CLI,失败则 exit */ + const runSetup = async (label, cliArgs, parseStdout, timeoutOverrideMs) => { + const displayCommand = buildDisplayCommand(cliArgs); + const res = await executeSingleCli({ + MAIN_TS: ctx.mainTs, + CLI_PACKAGE: ctx.cliPackage, + TIMEOUT_MS: timeoutOverrideMs ?? ctx.setupTimeoutMs ?? 600_000, + MAX_LOG_CAPTURE: MAX_LOG, + index: 0, + displayCommand, + cliArgs, + baseRecord: { prompt: `(前置资源:${label})`, runDir: fixturesDir }, + parseStdout: (out) => Promise.resolve(parseStdout(out)), + }); + + if (res.status !== "success") { + console.error(`前置资源「${label}」失败: ${res.error ?? res.stderr}`); + process.exit(1); + } + return { result: res, displayCommand }; + }; + + if (kinds.has("audio")) { + const outAudio = join(fixturesDir, "setup-audio.mp3"); + const args = [ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + voice, + "--text", + "压测前置语音样本,用于语音识别链路。", + "--out", + outAudio, + "--non-interactive", + "--output", + "json", + ]; + const { result, displayCommand } = await runSetup("tts-audio", args, (out) => { + const p = parseSpeechSynthesizeResult(out); + return p.ok + ? { + ok: true, + data: { + setupAudioUrls: p.data.audioUrls, + setupAudioSaved: p.data.saved, + }, + } + : p; + }); + manifest.audio = { + url: result.setupAudioUrls?.[0], + urls: result.setupAudioUrls, + saved: + typeof result.setupAudioSaved === "string" + ? result.setupAudioSaved + : result.setupAudioSaved?.[0], + command: displayCommand, + }; + } + + if (kinds.has("image")) { + const cliTimeoutSec = Math.ceil((ctx.setupTimeoutMs ?? 600_000) / 1000); + const args = [ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--prompt", + "压测前置图片:一只橘猫坐在窗台,柔和日光。", + "--out-dir", + fixturesDir, + "--out-prefix", + "stress-setup-image", + "--non-interactive", + "--output", + "json", + "--timeout", + String(cliTimeoutSec), + "--poll-interval", + "10", + ]; + const { result, displayCommand } = await runSetup("image", args, (out) => { + const p = parseImageResult(out); + return p.ok + ? { + ok: true, + data: { + setupUrls: p.data.urls, + setupSaved: p.data.saved, + }, + } + : p; + }); + manifest.image = { + urls: result.setupUrls ?? result.urls, + saved: result.setupSaved ?? result.saved, + command: displayCommand, + }; + const firstUrl = manifest.image.urls?.[0]; + const firstSaved = manifest.image.saved?.[0]; + manifest.image.primaryUrl = + typeof firstUrl === "string" + ? firstUrl + : typeof firstSaved === "string" + ? firstSaved + : undefined; + } + + if (kinds.has("video")) { + const videoTimeout = ctx.videoSetupTimeoutMs ?? 3_600_000; + const cliTimeoutSec = Math.ceil(videoTimeout / 1000); + const downloadPath = join(fixturesDir, "setup-video.mp4"); + const args = [ + "video", + "generate", + "--model", + "happyhorse-1.0-t2v", + "--prompt", + "压测前置短视频:海浪与静态远景,无明显人物。", + "--duration", + "5", + "--download", + downloadPath, + "--non-interactive", + "--output", + "json", + "--timeout", + String(cliTimeoutSec), + "--poll-interval", + "5", + ]; + const { result, displayCommand } = await runSetup( + "video-t2v", + args, + (out) => { + const p = parseVideoResult(out); + return p.ok + ? { + ok: true, + data: { + setupVideoUrls: p.data.videoUrls, + setupVideoSaved: p.data.saved, + }, + } + : p; + }, + videoTimeout, + ); + manifest.video = { + video_url: result.setupVideoUrls?.[0] ?? result.videoUrls?.[0], + saved: result.setupVideoSaved?.[0] ?? result.saved?.[0], + command: displayCommand, + }; + } + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + process.stderr.write(`[前置资源] 已写入 ${manifestPath}\n`); + + validateManifestHasKinds(manifest, kinds); + return manifest; +} + +/** @param {object} manifest @param {Set} kinds */ +function validateManifestHasKinds(manifest, kinds) { + for (const k of kinds) { + if (k === "audio") { + const a = manifest.audio; + const savedPath = typeof a?.saved === "string" ? a.saved : undefined; + const ok = + a && + ((typeof a.url === "string" && a.url.startsWith("http")) || + (savedPath && existsSync(savedPath))); + if (!ok) { + console.error("manifest 缺少有效 audio(http url 或可读的本地 saved)"); + process.exit(1); + } + } + if (k === "image") { + const im = manifest.image; + const urls = im?.urls; + const savedRaw = im?.saved; + const saved0 = Array.isArray(savedRaw) ? savedRaw[0] : savedRaw; + const u = + typeof im?.primaryUrl === "string" + ? im.primaryUrl + : typeof urls?.[0] === "string" + ? urls[0] + : saved0; + if (!u) { + console.error("manifest 缺少有效 image 资源"); + process.exit(1); + } + if (typeof saved0 === "string" && !saved0.startsWith("http") && !existsSync(saved0)) { + console.error(`manifest 指向的本地图片不存在: ${saved0}`); + process.exit(1); + } + } + if (k === "video") { + const v = manifest.video; + const p = v?.saved || v?.video_url; + if (!p) { + console.error("manifest 缺少有效 video 资源"); + process.exit(1); + } + if (typeof v?.saved === "string" && !existsSync(v.saved)) { + console.error(`manifest 指向的本地视频不存在: ${v.saved}`); + process.exit(1); + } + } + } +} diff --git a/packages/cli/tests/stress/lib/incremental-writer.mjs b/packages/cli/tests/stress/lib/incremental-writer.mjs new file mode 100644 index 0000000..522f639 --- /dev/null +++ b/packages/cli/tests/stress/lib/incremental-writer.mjs @@ -0,0 +1,20 @@ +/** + * 增量写入 results.jsonl —— 每完成一个任务追加一行,防崩溃丢数据。 + */ +import { appendFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { sanitizeResultForExport } from "./parsers.mjs"; + +export class IncrementalWriter { + /** @param {string} batchRoot */ + constructor(batchRoot) { + this.filePath = join(batchRoot, "results.jsonl"); + writeFileSync(this.filePath, "", "utf8"); + } + + /** @param {object} result @param {number} _index */ + append(result, _index) { + const line = JSON.stringify(sanitizeResultForExport(result)); + appendFileSync(this.filePath, line + "\n", "utf8"); + } +} diff --git a/packages/cli/tests/stress/lib/parsers.mjs b/packages/cli/tests/stress/lib/parsers.mjs new file mode 100644 index 0000000..12671fc --- /dev/null +++ b/packages/cli/tests/stress/lib/parsers.mjs @@ -0,0 +1,403 @@ +/** 截取日志尾部,防止撑爆缓冲与报告。 */ + +export function truncateLog(text, maxLen) { + const t = String(text ?? ""); + if (t.length <= maxLen) return t; + return `…(已截断,原长 ${t.length} 字符)\n${t.slice(-maxLen)}`; +} + +/** JSON 截取用于 extractError 合并输出 */ +const COMBINED_MAX = 32_768; + +/** + * 从 stdout 末尾行尝试解析单行 JSON。 + * @param {string} stdout + */ +export function extractJsonFromStdout(stdout) { + const lines = stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith(">")); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line.startsWith("{")) continue; + try { + return JSON.parse(line); + } catch { + // 继续 + } + } + + const start = stdout.indexOf("{"); + const end = stdout.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + return JSON.parse(stdout.slice(start, end + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * 解析 image generate / image edit JSON:urls / saved。 + * @param {string} stdout + */ +export function parseImageResult(stdout) { + const text = stdout.trim(); + if (!text) { + return { ok: false, error: "stdout 为空(命令可能未等到生成完成)" }; + } + + const data = extractJsonFromStdout(text); + if (!data) { + const paths = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith(">") && (l.startsWith("/") || l.startsWith("./"))); + if (paths.length > 0) { + return { + ok: true, + data: { urls: [], saved: paths, total: paths.length }, + }; + } + return { ok: false, error: "无法从 stdout 解析 JSON 结果" }; + } + + let saved = []; + let urls = []; + if (Array.isArray(data.saved)) saved = data.saved; + else if (typeof data.saved === "string" && data.saved) saved = [data.saved]; + urls = Array.isArray(data.urls) ? data.urls : []; + const total = typeof data.total === "number" ? data.total : saved.length || urls.length; + + if (urls.length === 0 && saved.length === 0) { + if (data.task_ids || data.task_id) { + const ids = data.task_ids ?? [data.task_id]; + return { + ok: false, + error: `仅返回 task_id,未等待生成完成: ${ids.join(", ")}。请勿使用 --no-wait,或检查 ~/.bailian/config.json 是否开启 async`, + }; + } + return { ok: false, error: "JSON 中无 urls / saved 字段(可能生成未完成)" }; + } + + const requestId = + data.request_id != null && String(data.request_id).trim() + ? String(data.request_id).trim() + : undefined; + + return { + ok: true, + data: { + saved, + urls, + total, + requestId, + taskId: data.task_id != null ? String(data.task_id) : undefined, + taskIds: data.task_ids, + }, + }; +} + +/** 从 video 类 JSON 提取 video_url / saved */ +export function extractVideoFields(data) { + const videoUrls = []; + const saved = []; + + if (typeof data.video_url === "string" && data.video_url) { + videoUrls.push(data.video_url); + } + if (typeof data.saved === "string" && data.saved) { + saved.push(data.saved); + } + + if (Array.isArray(data.videos)) { + for (const item of data.videos) { + if (item?.video_url) videoUrls.push(item.video_url); + if (item?.saved) saved.push(item.saved); + } + } + + return { + videoUrls, + saved, + taskId: data.task_id, + taskIds: data.task_ids, + size: data.size, + total: typeof data.total === "number" ? data.total : videoUrls.length || saved.length, + }; +} + +/** 解析 video generate / edit / ref 等 JSON */ +export function parseVideoResult(stdout) { + const text = stdout.trim(); + if (!text) { + return { ok: false, error: "stdout 为空(命令可能未等到生成完成)" }; + } + + const data = extractJsonFromStdout(text); + if (!data) { + const paths = text + .split("\n") + .map((l) => l.trim()) + .filter( + (l) => + l && + !l.startsWith(">") && + (l.endsWith(".mp4") || l.startsWith("/") || l.startsWith("./")), + ); + if (paths.length > 0) { + return { + ok: true, + data: { + videoUrls: [], + saved: paths, + total: paths.length, + }, + }; + } + return { ok: false, error: "无法从 stdout 解析 JSON 结果" }; + } + + const { videoUrls, saved, taskId, taskIds, size, total } = extractVideoFields(data); + + if (videoUrls.length === 0 && saved.length === 0) { + if (taskIds || taskId) { + const ids = taskIds ?? [taskId]; + return { + ok: false, + error: `仅返回 task_id,未等待生成完成: ${ids.join(", ")}。请勿使用 --no-wait,或检查 ~/.bailian/config.json 是否开启 async`, + }; + } + return { ok: false, error: "JSON 中无 video_url / saved 字段(可能生成未完成)" }; + } + + const requestId = + data.request_id != null && String(data.request_id).trim() + ? String(data.request_id).trim() + : undefined; + + return { + ok: true, + data: { + videoUrls, + saved, + total, + requestId, + taskId: taskId != null ? String(taskId) : undefined, + taskIds, + size, + }, + }; +} + +/** 解析 text chat JSON */ +export function parseTextChatResult(stdout) { + const text = stdout.trim(); + if (!text) { + return { ok: false, error: "stdout 为空" }; + } + + const data = extractJsonFromStdout(text); + if (!data) { + if (text.length > 0 && !text.startsWith("{")) { + return { + ok: true, + data: { replyTextPreview: truncateLog(text, 500), plainText: text }, + }; + } + return { ok: false, error: "无法解析 text chat JSON" }; + } + + const msg = data?.choices?.[0]?.message?.content; + const fallback = typeof data.content === "string" ? data.content : ""; + const content = + typeof msg === "string" ? msg : Array.isArray(msg) ? JSON.stringify(msg) : fallback || ""; + + if (!content.trim()) { + return { ok: false, error: "JSON 中无有效正文 content" }; + } + + const requestId = + data.request_id != null && String(data.request_id).trim() + ? String(data.request_id).trim() + : undefined; + + return { + ok: true, + data: { + replyTextPreview: truncateLog(content.trim(), 2000), + requestId, + }, + }; +} + +/** TTS synthesize JSON */ +export function parseSpeechSynthesizeResult(stdout) { + const text = stdout.trim(); + if (!text) { + return { ok: false, error: "stdout 为空" }; + } + + const data = extractJsonFromStdout(text); + if (!data) { + return { ok: false, error: "无法解析 speech synthesize JSON" }; + } + + const urls = []; + if (typeof data.audio_url === "string" && data.audio_url) urls.push(data.audio_url); + if (Array.isArray(data.audio_urls)) { + urls.push(...data.audio_urls.filter((u) => typeof u === "string")); + } + + let saved = []; + if (typeof data.saved === "string" && data.saved) saved = [data.saved]; + if (Array.isArray(data.saved)) saved = data.saved; + + if (urls.length === 0 && saved.length === 0) { + return { ok: false, error: "JSON 中无 audio_url / saved" }; + } + + const requestId = + data.request_id != null && String(data.request_id).trim() + ? String(data.request_id).trim() + : undefined; + + return { ok: true, data: { audioUrls: urls, saved, audioUrl: urls[0], requestId } }; +} + +/** 判断 ASR `--out` 文件是否含有效识别结果 */ + +export function parseAsrOutFile(readText) { + try { + const data = JSON.parse(readText); + + /** @param {*} d */ + const hasTranscripts = (d) => { + const t = d?.transcripts; + if (!Array.isArray(t) || t.length === 0) return false; + for (const tr of t) { + if (typeof tr?.text === "string" && tr.text.trim()) return true; + if (Array.isArray(tr?.sentences) && tr.sentences.some((s) => s?.text?.trim())) return true; + } + return false; + }; + + if (Array.isArray(data)) { + for (const item of data) { + if (hasTranscripts(item)) return { ok: true }; + } + } else if (hasTranscripts(data)) { + return { ok: true }; + } + + return { ok: false, error: "--out JSON 未包含 transcripts 文本" }; + } catch (e) { + return { + ok: false, + error: `--out JSON 解析失败: ${/** @type {Error} */ (e).message}`, + }; + } +} + +/** 汇总 stdout:非空且无 Bailian Error 前缀 */ +export function parseAsrStdoutOnly(stdout) { + const trimmed = stdout.replace(/^\[=+.*$/gm, "").trim(); + return trimmed.length > 0 ? { ok: true, data: { preview: truncateLog(trimmed, 500) } } : null; +} + +/** + * @param {string} text + */ +export function tryParseJsonObject(text) { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } +} + +/** @param {object | null} obj */ +export function formatApiErrorPayload(obj) { + if (!obj || typeof obj !== "object") return null; + const e = obj.error ?? obj; + if (!e || typeof e !== "object") return null; + const parts = []; + if (e.code != null) parts.push(`[code ${e.code}]`); + if (e.message) parts.push(String(e.message)); + if (e.hint) parts.push(`hint: ${e.hint}`); + return parts.length > 0 ? parts.join(" ") : null; +} + +/** + * @param {string} stderr + * @param {string} stdout + * @param {number | undefined} exitCode + */ +export function extractError(stderr, stdout, exitCode) { + const combined = truncateLog(`${stderr}\n${stdout}`.trim(), COMBINED_MAX); + if (!combined) { + return exitCode != null && exitCode !== 0 + ? `进程退出码 ${exitCode},无 stderr/stdout 输出` + : "未知错误(无 stderr/stdout)"; + } + + const parsed = tryParseJsonObject(combined); + const apiMsg = formatApiErrorPayload(parsed); + if (apiMsg) return apiMsg.slice(0, 2000); + + const jsonBlocks = combined.match(/\{[\s\S]*?\}/g); + if (jsonBlocks) { + const start = Math.max(0, jsonBlocks.length - 8); + for (let i = jsonBlocks.length - 1; i >= start; i--) { + try { + const o = JSON.parse(jsonBlocks[i]); + const msg = formatApiErrorPayload(o); + if (msg) return msg.slice(0, 2000); + } catch { + // ignore + } + } + } + + const lines = combined + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("[Model:")); + + const bailianLine = lines.find((l) => /BailianError|^\s*Error:/i.test(l)); + if (bailianLine) return bailianLine.slice(0, 2000); + + const meaningful = lines.filter( + (l) => l !== '"error": {' && l !== "{" && l !== "}" && !/^"[^"]+": \{?$/.test(l), + ); + if (meaningful.length > 0) { + return meaningful.slice(-4).join(" | ").slice(0, 2000); + } + + return exitCode != null && exitCode !== 0 ? `进程退出码 ${exitCode}` : combined.slice(0, 2000); +} + +/** 限流类失败判定 */ +export function isRateLimitFailure(result, extractFn) { + if (result.exitCode === 4) return true; + const extract = extractFn ?? extractError; + const msg = result.error ?? extract(result.stderr ?? "", result.stdout ?? "", result.exitCode); + return /rate limit|quota exceeded|限流|频率|too many requests/i.test(msg); +} + +/** 写入 results.json 前精简单条结果。 */ +export function sanitizeResultForExport(r) { + const { raw: _raw, ...rest } = r; + return { + ...rest, + stdout: r.stdout ? truncateLog(r.stdout, 2048) : undefined, + stderr: r.stderr ? truncateLog(r.stderr, 2048) : undefined, + }; +} diff --git a/packages/cli/tests/stress/lib/paths.mjs b/packages/cli/tests/stress/lib/paths.mjs new file mode 100644 index 0000000..d568e44 --- /dev/null +++ b/packages/cli/tests/stress/lib/paths.mjs @@ -0,0 +1,22 @@ +/** + * 压测脚本根目录及 CLI/monorepo 路径解析。 + */ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** 当前文件所在 lib 目录 */ +const LIB_DIR = dirname(fileURLToPath(import.meta.url)); + +/** `packages/cli/tests/stress` */ +export const STRESS_ROOT = join(LIB_DIR, ".."); + +/** `packages/cli` */ +export const DEFAULT_CLI_PACKAGE = join(STRESS_ROOT, "..", ".."); + +/** monorepo 根目录 */ +export const MONOREPO_ROOT = join(DEFAULT_CLI_PACKAGE, "..", ".."); + +/** CLI 入口 main.ts(ts-node 或直接 node ts 由项目脚本决定) */ +export function resolveMainTs(cliPackage = DEFAULT_CLI_PACKAGE) { + return join(cliPackage, "src", "main.ts"); +} diff --git a/packages/cli/tests/stress/lib/rate-limit.mjs b/packages/cli/tests/stress/lib/rate-limit.mjs new file mode 100644 index 0000000..e897ede --- /dev/null +++ b/packages/cli/tests/stress/lib/rate-limit.mjs @@ -0,0 +1,49 @@ +/** 睡眠指定毫秒 */ + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * 滑动窗口限流器:控制任务下发频率。 + */ +export class SubmissionRateLimiter { + /** + * @param {number} maxRequests 窗口内允许的最大请求数 + * @param {number} windowMs 滑动窗口长度(毫秒) + */ + constructor(maxRequests, windowMs) { + this.maxRequests = maxRequests; + this.windowMs = windowMs; + this.timestamps = []; + this.waitQueue = []; + this.draining = false; + } + + /** 获取一次提交许可;超限则排队等待。 */ + async acquire() { + return new Promise((resolve) => { + this.waitQueue.push(resolve); + void this.drain(); + }); + } + + /** 依次处理等待队列。 */ + async drain() { + if (this.draining) return; + this.draining = true; + while (this.waitQueue.length > 0) { + const now = Date.now(); + this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs); + if (this.timestamps.length >= this.maxRequests) { + const waitMs = this.windowMs - (now - this.timestamps[0]) + 20; + await sleep(Math.max(waitMs, 50)); + continue; + } + this.timestamps.push(Date.now()); + const next = this.waitQueue.shift(); + next?.(); + } + this.draining = false; + } +} diff --git a/packages/cli/tests/stress/lib/report.mjs b/packages/cli/tests/stress/lib/report.mjs new file mode 100644 index 0000000..0765686 --- /dev/null +++ b/packages/cli/tests/stress/lib/report.mjs @@ -0,0 +1,268 @@ +/** + * 压测 Markdown / HTML 报告生成。 + */ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { sanitizeResultForExport } from "./parsers.mjs"; + +/** 步骤日志 */ +export function logReportStep(message) { + process.stderr.write(`[报告] ${message}\n`); +} + +export function formatDuration(ms) { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +export function escapeTableCell(value) { + return String(value ?? "") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, "
"); +} + +export function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** @param {{ cwd: string, command: string }} r */ +export function formatFullCommand(r) { + return `cd ${r.cwd} && ${r.command}`; +} + +/** @param {object} r @param {(a: string,b: string,n?: number)=>string} extractError */ +/** @param {object} r */ +export function formatRequestIdCell(r) { + return escapeTableCell(r.requestId ?? "—"); +} + +/** @param {object} r */ +export function formatTaskIdCell(r) { + const raw = + r.taskId ?? + (Array.isArray(r.taskIds) && r.taskIds.length > 0 ? r.taskIds.join(", ") : undefined); + return escapeTableCell(raw ?? "—"); +} + +export function getErrorMessage(r, extractError) { + const err = + r.error && r.error !== '"error": {' + ? r.error + : extractError(r.stderr ?? "", r.stdout ?? "", r.exitCode); + const parts = []; + if (r.exitCode != null && r.exitCode !== 0) { + parts.push(`exit=${r.exitCode}`); + } + parts.push(err || "(未能解析错误信息)"); + return parts.join(" | "); +} + +/** @param {object[]} results */ +export function computeReportStats(results, meta) { + const successes = results.filter((r) => r.status === "success"); + const failures = results.filter((r) => r.status === "failed"); + const durations = results.map((r) => r.durationMs); + const totalMs = meta.finishedAt - meta.startedAt; + let min = 0; + let max = 0; + let avg = 0; + if (durations.length > 0) { + min = Math.min(...durations); + max = Math.max(...durations); + avg = durations.reduce((a, b) => a + b, 0) / durations.length; + } + return { successes, failures, durations, totalMs, min, max, avg }; +} + +/** + * @param {object} spec + * @param {object[]} results 已 sanitize + * @param {object} meta + */ +export function buildMarkdownReport(spec, results, meta) { + const { successes, failures, totalMs, min, max, avg } = computeReportStats(results, meta); + const lines = []; + lines.push(`# ${spec.titleMd}`); + lines.push(""); + lines.push(`- **生成时间**: ${meta.finishedAtIso}`); + lines.push(`- **任务总数**: ${results.length}`); + lines.push(`- **成功**: ${successes.length}`); + lines.push(`- **失败**: ${failures.length}`); + lines.push(`- **并发数**: ${meta.concurrency}`); + lines.push(`- **模型**: ${meta.model}`); + if (Array.isArray(meta.extraMdLines)) { + for (const l of meta.extraMdLines) lines.push(l); + } + lines.push( + `- **任务下发限流**: ${meta.rateLimitLabel}${meta.rateLimitDisabled ? "(已关闭)" : ""}`, + ); + if (!meta.rateLimitDisabled) { + lines.push(`- **限流重试**: 最多 ${meta.maxRetries} 次,间隔基数 ${meta.retryBaseMs}ms`); + } + if (meta.pollInterval != null) { + lines.push(`- **轮询间隔**: ${meta.pollInterval}s`); + } + if (meta.timeoutMs != null) { + lines.push(`- **CLI 超时**: ${formatDuration(meta.timeoutMs)}`); + } + lines.push(`- **工作目录**: \`${meta.cliPackage}\``); + lines.push(`- **输出根目录**: \`${meta.batchRoot}\``); + lines.push(`- **墙钟总耗时**: ${formatDuration(totalMs)}`); + if (results.length > 0) { + lines.push( + `- **单任务耗时**: 最短 ${formatDuration(min)} / 最长 ${formatDuration(max)} / 平均 ${formatDuration(avg)}`, + ); + } + lines.push(""); + lines.push("## 明细"); + lines.push(""); + lines.push( + `| # | 状态 | 耗时 | Request ID | Task ID | ${spec.promptColumnMd} | 完整命令 | ${spec.outcomeColumnMd} |`, + ); + lines.push("|---|------|------|------------|---------|---------|----------|-----------|"); + for (const r of results) { + const status = r.status === "success" ? "✅ 成功" : "❌ 失败"; + lines.push( + `| ${r.index} | ${status} | ${r.durationSec}s | ${formatRequestIdCell(r)} | ${formatTaskIdCell(r)} | ${escapeTableCell(r.prompt)} | ${escapeTableCell(formatFullCommand(r))} | ${spec.formatOutcomeMd(r)} |`, + ); + } + return lines.join("\n"); +} + +/** + * @param {object} spec + */ +export function buildHtmlReport(spec, results, meta) { + const { successes, failures, totalMs, min, max, avg } = computeReportStats(results, meta); + + const rows = results + .map((r) => { + const statusClass = r.status === "success" ? "status-ok" : "status-fail"; + const statusLabel = r.status === "success" ? "成功" : "失败"; + return ` + ${r.index} + ${statusLabel} + ${escapeHtml(r.durationSec)}s + ${escapeHtml(r.requestId ?? "—")} + ${escapeHtml( + r.taskId ?? + (Array.isArray(r.taskIds) && r.taskIds.length > 0 ? r.taskIds.join(", ") : "—") ?? + "—", + )} + ${escapeHtml(r.prompt)} + ${escapeHtml(formatFullCommand(r))} + ${spec.formatOutcomeHtml(r)} +`; + }) + .join("\n"); + + const extraMeta = meta.extraHtmlMeta ? escapeHtml(meta.extraHtmlMeta) : ""; + + return ` + + + + + ${escapeHtml(spec.titleHtml)} + + + +
+

${escapeHtml(spec.titleHtml)}

+

生成时间:${escapeHtml(meta.finishedAtIso)} · 模型:${escapeHtml(meta.model)} · 并发:${meta.concurrency}${extraMeta}

+
+
${results.length}
+
${successes.length}
+
${failures.length}
+
${escapeHtml(formatDuration(totalMs))}
+
${escapeHtml(formatDuration(min))}
+
${escapeHtml(formatDuration(max))}
+
${escapeHtml(formatDuration(avg))}
+
+
+ + + + + + + +${rows} + +
#状态耗时Request IDTask ID${escapeHtml(spec.promptColumnHtml)}完整命令${escapeHtml(spec.outcomeColumnHtml)}
+
+

输出目录:${escapeHtml(meta.batchRoot)} · 轮询间隔 ${meta.pollInterval != null ? escapeHtml(String(meta.pollInterval)) : "—"}s · CLI 超时 ${meta.timeoutMs != null ? escapeHtml(formatDuration(meta.timeoutMs)) : "—"}

+
+ +`; +} + +/** + * @param {string} batchRoot + * @param {object[]} results 原始任务结果(会先 sanitize) + * @param {object} reportMeta + * @param {object} reportSpec outcome / title 定义 + */ +export function writeStressReports(batchRoot, results, reportMeta, reportSpec) { + logReportStep(`共 ${results.length} 条结果,正在精简并生成报告…`); + + const exportResults = results.map(sanitizeResultForExport); + + logReportStep("生成 Markdown…"); + const reportMd = buildMarkdownReport(reportSpec, exportResults, reportMeta); + + logReportStep("生成 HTML…"); + const reportHtml = buildHtmlReport(reportSpec, exportResults, reportMeta); + + const reportPath = join(batchRoot, "REPORT.md"); + const reportHtmlPath = join(batchRoot, "REPORT.html"); + const jsonPath = join(batchRoot, "results.json"); + + writeFileSync(reportPath, reportMd, "utf8"); + writeFileSync(reportHtmlPath, reportHtml, "utf8"); + writeFileSync(jsonPath, JSON.stringify(exportResults, null, 2), "utf8"); + + logReportStep("报告已全部写入"); + + return { reportPath, reportHtmlPath, jsonPath }; +} diff --git a/packages/cli/tests/stress/lib/run-suite.mjs b/packages/cli/tests/stress/lib/run-suite.mjs new file mode 100644 index 0000000..2fa11fd --- /dev/null +++ b/packages/cli/tests/stress/lib/run-suite.mjs @@ -0,0 +1,256 @@ +/** + * 全量压测套件:支持分阶段并行(默认)和顺序执行(--sequential)。 + */ +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { MONOREPO_ROOT } from "./paths.mjs"; +import { STRESS_SUITE_ORDER, SUITE_PHASES, TARGET_DISPLAY_NAMES } from "./suite-catalog.mjs"; +import { writeSuiteReports } from "./suite-report.mjs"; +import { generateCombinedFixtures } from "./suite-fixtures.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TARGETS_DIR = join(__dirname, "..", "targets"); + +function formatSuiteTimestamp(d = new Date()) { + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} + +/** + * 运行单个 target 并返回摘要(不 throw)。 + */ +async function runSingleTarget(item, { globals, forwarded, suiteRoot }) { + const displayName = TARGET_DISPLAY_NAMES[item.canonical] ?? item.canonical; + const modHref = pathToFileURL(join(TARGETS_DIR, item.file)).href; + const mod = await import(modHref); + const caseStarted = Date.now(); + + try { + const result = await mod.runStress(forwarded, { + canonicalTarget: item.canonical, + displayName, + globals, + noExit: true, + reportDirOverride: join(suiteRoot, item.canonical), + }); + + const wallClockMs = Date.now() - caseStarted; + + if (result?.skipped) { + return { displayName, canonicalTarget: item.canonical, wallClockMs, skipped: true }; + } + + if (result == null || typeof result.exitCode !== "number") { + return { + displayName, + canonicalTarget: item.canonical, + wallClockMs, + exitCode: 1, + failCount: 1, + successCount: 0, + count: 0, + error: "runStress 未返回 exitCode", + }; + } + + return { ...result, displayName, canonicalTarget: item.canonical, wallClockMs }; + } catch (err) { + return { + displayName, + canonicalTarget: item.canonical, + wallClockMs: Date.now() - caseStarted, + exitCode: 1, + failCount: 1, + successCount: 0, + count: 0, + error: String(/** @type {Error} */ (err).message ?? err), + }; + } +} + +/** + * 并行模式(默认):Phase 0 生成共享 fixtures → Phase 1 并行 → Phase 2 并行。 + */ +export async function runStressSuite({ globals, forwarded }) { + if (globals.sequential) { + return runStressSuiteSequential({ globals, forwarded }); + } + + if (globals.setupOnly) { + console.error("全量压测模式不支持 --setup-only,请对单个 target 运行,例如:"); + console.error(" pnpm run test:stress -- image-edit --setup-only"); + process.exit(1); + } + + const suiteStarted = Date.now(); + const ts = formatSuiteTimestamp(); + const suiteRoot = join(MONOREPO_ROOT, "test", "output", `stress-suite-${ts}`); + mkdirSync(suiteRoot, { recursive: true }); + + console.error(`[全量压测] 模式: 并行`); + console.error(`[全量压测] 输出根目录: ${suiteRoot}`); + + // Phase 0: 生成共享前置资源 + console.error(""); + console.error("========== [Phase 0] 生成共享前置资源 =========="); + let sharedFixturesDir; + try { + sharedFixturesDir = await generateCombinedFixtures({ suiteRoot, cliPackage: undefined }); + } catch (err) { + console.error(`[全量压测] 共享前置资源生成失败: ${err.message}`); + process.exit(1); + } + + const summaries = []; + let passedCases = 0; + let failedCases = 0; + let skippedCases = 0; + + for (const phase of SUITE_PHASES) { + console.error(""); + console.error(`========== ${phase.label} (${phase.targets.length} targets, 并行) ==========`); + + const phasePromises = phase.targets.map((item) => { + const phaseGlobals = { ...globals, fixturesDir: sharedFixturesDir }; + return runSingleTarget(item, { + globals: phaseGlobals, + forwarded, + suiteRoot, + }); + }); + + const phaseResults = await Promise.allSettled(phasePromises); + + for (const settled of phaseResults) { + const summary = + settled.status === "fulfilled" + ? settled.value + : { + displayName: "unknown", + canonicalTarget: "unknown", + wallClockMs: 0, + exitCode: 1, + failCount: 1, + successCount: 0, + count: 0, + error: String(settled.reason), + }; + + if (summary.skipped) { + skippedCases++; + console.error(` [跳过] ${summary.displayName}`); + continue; + } + + summaries.push(summary); + + if (summary.exitCode !== 0 || summary.hadFailures) { + failedCases++; + console.error(` [失败] ${summary.displayName}`); + } else { + passedCases++; + console.error(` [通过] ${summary.displayName}`); + } + } + } + + const suiteFinished = Date.now(); + const meta = { + suiteRoot, + startedAt: suiteStarted, + finishedAt: suiteFinished, + finishedAtIso: new Date(suiteFinished).toISOString(), + wallClockMs: suiteFinished - suiteStarted, + passedCases, + failedCases, + skippedCases, + }; + + const paths = writeSuiteReports(suiteRoot, summaries, meta); + + console.error(""); + console.error("========== 全量压测完成 =========="); + console.error( + `用例: 成功 ${passedCases} / 失败 ${failedCases} / 跳过 ${skippedCases} / 共 ${SUITE_PHASES.reduce((n, p) => n + p.targets.length, 0)}`, + ); + console.error(`墙钟总耗时: ${((suiteFinished - suiteStarted) / 1000).toFixed(1)}s`); + console.error(`全量压测报告 Markdown: ${paths.reportPath}`); + console.error(`全量压测报告 HTML: ${paths.reportHtmlPath}`); + console.error(`全量压测报告 JSON: ${paths.jsonPath}`); + + process.exit(0); +} + +/** + * 顺序模式(--sequential):逐个执行全部用例。 + */ +async function runStressSuiteSequential({ globals, forwarded }) { + if (globals.setupOnly) { + console.error("全量压测模式不支持 --setup-only,请对单个 target 运行,例如:"); + console.error(" pnpm run test:stress -- image-edit --setup-only"); + process.exit(1); + } + + const suiteStarted = Date.now(); + const ts = formatSuiteTimestamp(); + const suiteRoot = join(MONOREPO_ROOT, "test", "output", `stress-suite-${ts}`); + mkdirSync(suiteRoot, { recursive: true }); + + console.error(`[全量压测] 模式: 顺序`); + console.error(`[全量压测] 输出根目录: ${suiteRoot}`); + console.error(`[全量压测] 将顺序执行 ${STRESS_SUITE_ORDER.length} 个用例…`); + + const summaries = []; + let passedCases = 0; + let failedCases = 0; + let skippedCases = 0; + + for (const item of STRESS_SUITE_ORDER) { + const displayName = TARGET_DISPLAY_NAMES[item.canonical] ?? item.canonical; + console.error(""); + console.error(`========== [${displayName}] (${item.canonical}) [全量压测] ==========`); + + const summary = await runSingleTarget(item, { globals, forwarded, suiteRoot }); + + if (summary.skipped) { + skippedCases++; + console.error(`[全量压测] 跳过: ${displayName}`); + continue; + } + + summaries.push(summary); + + if (summary.exitCode !== 0 || summary.hadFailures) { + failedCases++; + } else { + passedCases++; + } + } + + const suiteFinished = Date.now(); + const meta = { + suiteRoot, + startedAt: suiteStarted, + finishedAt: suiteFinished, + finishedAtIso: new Date(suiteFinished).toISOString(), + wallClockMs: suiteFinished - suiteStarted, + passedCases, + failedCases, + skippedCases, + }; + + const paths = writeSuiteReports(suiteRoot, summaries, meta); + + console.error(""); + console.error("========== 全量压测完成 =========="); + console.error( + `用例: 成功 ${passedCases} / 失败 ${failedCases} / 跳过 ${skippedCases} / 共 ${STRESS_SUITE_ORDER.length}`, + ); + console.error(`墙钟总耗时: ${((suiteFinished - suiteStarted) / 1000).toFixed(1)}s`); + console.error(`全量压测报告 Markdown: ${paths.reportPath}`); + console.error(`全量压测报告 HTML: ${paths.reportHtmlPath}`); + console.error(`全量压测报告 JSON: ${paths.jsonPath}`); + + process.exit(0); +} diff --git a/packages/cli/tests/stress/lib/stress-config.mjs b/packages/cli/tests/stress/lib/stress-config.mjs new file mode 100644 index 0000000..cafd4da --- /dev/null +++ b/packages/cli/tests/stress/lib/stress-config.mjs @@ -0,0 +1,118 @@ +/** + * 压测 count / concurrency 配置:命令行 > stress.defaults.json > 代码内置默认。 + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { STRESS_ROOT } from "./paths.mjs"; + +/** @type {string | null} */ +let cachedConfigPath = null; +/** @type {{ targets?: Record } | null} */ +let cachedConfig = null; + +/** 代码内置兜底(配置文件缺失某 target 时使用) */ +export const CODE_DEFAULTS = { + text: { count: 100, concurrency: 50 }, + "speech-tts": { count: 50, concurrency: 50 }, + "speech-asr": { count: 50, concurrency: 50 }, + "image-generate": { count: 100, concurrency: 20 }, + "image-edit": { count: 50, concurrency: 20 }, + "video-t2v": { count: 20, concurrency: 10 }, + "video-i2v": { count: 20, concurrency: 20 }, + "video-ref": { count: 20, concurrency: 20 }, + "video-edit": { count: 20, concurrency: 20 }, +}; + +const DEFAULT_CONFIG_PATH = join(STRESS_ROOT, "stress.defaults.json"); + +/** + * @param {string} [configPath] + */ +export function loadStressConfig(configPath) { + const path = configPath?.trim() || process.env.STRESS_CONFIG?.trim() || DEFAULT_CONFIG_PATH; + if (cachedConfig && cachedConfigPath === path) { + return cachedConfig; + } + + if (!existsSync(path)) { + cachedConfigPath = path; + cachedConfig = { targets: {} }; + return cachedConfig; + } + + try { + const raw = JSON.parse(readFileSync(path, "utf8")); + cachedConfigPath = path; + cachedConfig = + raw && typeof raw === "object" ? /** @type {typeof cachedConfig} */ (raw) : { targets: {} }; + return cachedConfig; + } catch (e) { + console.error(`[压测配置] 无法解析 ${path}: ${/** @type {Error} */ (e).message}`); + cachedConfigPath = path; + cachedConfig = { targets: {} }; + return cachedConfig; + } +} + +/** + * @param {string} canonical + * @param {string} [configPath] + */ +export function getTargetStressDefaults(canonical, configPath) { + const config = loadStressConfig(configPath); + const fromFile = config?.targets?.[canonical]; + const fromCode = CODE_DEFAULTS[canonical] ?? { count: 10, concurrency: 5 }; + return { + count: fromFile?.count ?? fromCode.count, + concurrency: fromFile?.concurrency ?? fromCode.concurrency, + }; +} + +/** + * @param {string | number | undefined} raw + * @param {number} fallback + */ +function parsePositiveInt(raw, fallback) { + const n = parseInt(String(raw ?? ""), 10); + if (!Number.isFinite(n) || n < 1) return fallback; + return n; +} + +/** + * @param {Record} argv parseStressArgv 结果 + * @param {string | undefined} key + */ +function argvHas(argv, key) { + const v = argv[key]; + return v != null && String(v).trim() !== ""; +} + +/** + * 解析 count、concurrency。未传 -c 时默认 concurrency = min(count, 配置/代码默认并发)。 + * + * @param {object} params + * @param {string} params.canonical 如 text、video-t2v + * @param {Record} params.argv + * @param {string} [params.configPath] + */ +export function resolveStressCountAndConcurrency({ canonical, argv, configPath }) { + const defaults = getTargetStressDefaults(canonical, configPath); + + const count = (() => { + if (argvHas(argv, "COUNT")) { + return parsePositiveInt(argv.COUNT, defaults.count); + } + return parsePositiveInt(defaults.count, 10); + })(); + + const concurrencyExplicit = argvHas(argv, "CONCURRENCY"); + const concurrency = (() => { + if (concurrencyExplicit) { + return parsePositiveInt(argv.CONCURRENCY, 1); + } + const cap = parsePositiveInt(defaults.concurrency, 1); + return Math.min(count, cap); + })(); + + return { count, concurrency, concurrencyExplicit }; +} diff --git a/packages/cli/tests/stress/lib/suite-catalog.mjs b/packages/cli/tests/stress/lib/suite-catalog.mjs new file mode 100644 index 0000000..49a9bf1 --- /dev/null +++ b/packages/cli/tests/stress/lib/suite-catalog.mjs @@ -0,0 +1,56 @@ +/** + * 全量压测套件:目标顺序与中文展示名。 + */ + +/** @type {Record} */ +export const TARGET_DISPLAY_NAMES = { + text: "文本对话", + "speech-tts": "语音合成", + "speech-asr": "语音识别", + "image-generate": "图片生成", + "image-edit": "图片编辑", + "video-t2v": "文生视频", + "video-i2v": "图生视频", + "video-ref": "视频参考生成", + "video-edit": "视频编辑", +}; + +/** 套件执行顺序(顺序模式使用,视频类靠后,耗时更长) */ +export const STRESS_SUITE_ORDER = [ + { canonical: "text", file: "text-chat.mjs" }, + { canonical: "speech-tts", file: "speech-synthesize.mjs" }, + { canonical: "speech-asr", file: "speech-recognize.mjs" }, + { canonical: "image-generate", file: "image-generate.mjs" }, + { canonical: "image-edit", file: "image-edit.mjs" }, + { canonical: "video-t2v", file: "video-t2v.mjs" }, + { canonical: "video-i2v", file: "video-i2v.mjs" }, + { canonical: "video-ref", file: "video-ref.mjs" }, + { canonical: "video-edit", file: "video-edit.mjs" }, +]; + +/** + * 并行模式分阶段执行计划。 + * Phase 1: 无外部依赖(可全部并行) + * Phase 2: 依赖共享前置资源(在 Phase 0 统一生成后全部并行) + */ +export const SUITE_PHASES = [ + { + label: "Phase 1(无依赖)", + targets: [ + { canonical: "text", file: "text-chat.mjs" }, + { canonical: "speech-tts", file: "speech-synthesize.mjs" }, + { canonical: "image-generate", file: "image-generate.mjs" }, + { canonical: "video-t2v", file: "video-t2v.mjs" }, + ], + }, + { + label: "Phase 2(使用共享前置资源)", + targets: [ + { canonical: "speech-asr", file: "speech-recognize.mjs" }, + { canonical: "image-edit", file: "image-edit.mjs" }, + { canonical: "video-i2v", file: "video-i2v.mjs" }, + { canonical: "video-ref", file: "video-ref.mjs" }, + { canonical: "video-edit", file: "video-edit.mjs" }, + ], + }, +]; diff --git a/packages/cli/tests/stress/lib/suite-fixtures.mjs b/packages/cli/tests/stress/lib/suite-fixtures.mjs new file mode 100644 index 0000000..e879bd5 --- /dev/null +++ b/packages/cli/tests/stress/lib/suite-fixtures.mjs @@ -0,0 +1,169 @@ +/** + * 全量压测共享前置资源生成:一次性生成 audio + image + video 到共享目录。 + */ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { DEFAULT_CLI_PACKAGE, resolveMainTs } from "./paths.mjs"; +import { buildDisplayCommand, executeSingleCli } from "./cli-runner.mjs"; +import { parseImageResult, parseSpeechSynthesizeResult, parseVideoResult } from "./parsers.mjs"; +import { writeFileSync } from "node:fs"; + +/** + * 生成全部前置资源(audio / image / video),写入统一 manifest。 + * @param {{ suiteRoot: string, cliPackage?: string }} opts + * @returns {Promise} fixturesDir — 包含 prerequisites.json 的目录路径 + */ +export async function generateCombinedFixtures({ suiteRoot, cliPackage }) { + const CLI_PACKAGE = cliPackage || DEFAULT_CLI_PACKAGE; + const MAIN_TS = resolveMainTs(CLI_PACKAGE); + const fixturesDir = join(suiteRoot, "shared-fixtures"); + mkdirSync(fixturesDir, { recursive: true }); + + const MAX_LOG = 65536; + const manifest = { createdAt: new Date().toISOString() }; + const voice = process.env.STRESS_TTS_VOICE?.trim() || "longanyang"; + + const runSetup = async (label, cliArgs, parseStdout, timeoutMs = 600_000) => { + const displayCommand = buildDisplayCommand(cliArgs); + console.error(`[前置资源] 生成 ${label}…`); + const res = await executeSingleCli({ + MAIN_TS, + CLI_PACKAGE, + TIMEOUT_MS: timeoutMs, + MAX_LOG_CAPTURE: MAX_LOG, + index: 0, + displayCommand, + cliArgs, + baseRecord: { prompt: `(前置资源:${label})`, runDir: fixturesDir }, + parseStdout: (out) => Promise.resolve(parseStdout(out)), + }); + if (res.status !== "success") { + console.error(`[前置资源] 「${label}」失败: ${res.error ?? res.stderr}`); + throw new Error(`前置资源「${label}」生成失败`); + } + return { result: res, displayCommand }; + }; + + // Audio + const outAudio = join(fixturesDir, "setup-audio.mp3"); + const { result: audioRes, displayCommand: audioCmd } = await runSetup( + "audio", + [ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + voice, + "--text", + "压测前置语音样本,用于语音识别链路。", + "--out", + outAudio, + "--non-interactive", + "--output", + "json", + ], + (out) => { + const p = parseSpeechSynthesizeResult(out); + return p.ok + ? { ok: true, data: { setupAudioUrls: p.data.audioUrls, setupAudioSaved: p.data.saved } } + : p; + }, + ); + manifest.audio = { + url: audioRes.setupAudioUrls?.[0], + urls: audioRes.setupAudioUrls, + saved: + typeof audioRes.setupAudioSaved === "string" + ? audioRes.setupAudioSaved + : audioRes.setupAudioSaved?.[0], + command: audioCmd, + }; + + // Image + const imgTimeoutMs = 600_000; + const { result: imgRes, displayCommand: imgCmd } = await runSetup( + "image", + [ + "image", + "generate", + "--model", + "qwen-image-2.0", + "--prompt", + "压测前置图片:一只橘猫坐在窗台,柔和日光。", + "--out-dir", + fixturesDir, + "--out-prefix", + "stress-setup-image", + "--non-interactive", + "--output", + "json", + "--timeout", + String(Math.ceil(imgTimeoutMs / 1000)), + "--poll-interval", + "10", + ], + (out) => { + const p = parseImageResult(out); + return p.ok ? { ok: true, data: { setupUrls: p.data.urls, setupSaved: p.data.saved } } : p; + }, + imgTimeoutMs, + ); + manifest.image = { + urls: imgRes.setupUrls ?? imgRes.urls, + saved: imgRes.setupSaved ?? imgRes.saved, + command: imgCmd, + }; + const firstUrl = manifest.image.urls?.[0]; + const firstSaved = manifest.image.saved?.[0]; + manifest.image.primaryUrl = + typeof firstUrl === "string" + ? firstUrl + : typeof firstSaved === "string" + ? firstSaved + : undefined; + + // Video + const videoTimeoutMs = 3_600_000; + const downloadPath = join(fixturesDir, "setup-video.mp4"); + const { result: vidRes, displayCommand: vidCmd } = await runSetup( + "video", + [ + "video", + "generate", + "--model", + "happyhorse-1.0-t2v", + "--prompt", + "压测前置短视频:海浪与静态远景,无明显人物。", + "--duration", + "5", + "--download", + downloadPath, + "--non-interactive", + "--output", + "json", + "--timeout", + String(Math.ceil(videoTimeoutMs / 1000)), + "--poll-interval", + "5", + ], + (out) => { + const p = parseVideoResult(out); + return p.ok + ? { ok: true, data: { setupVideoUrls: p.data.videoUrls, setupVideoSaved: p.data.saved } } + : p; + }, + videoTimeoutMs, + ); + manifest.video = { + video_url: vidRes.setupVideoUrls?.[0] ?? vidRes.videoUrls?.[0], + saved: vidRes.setupVideoSaved?.[0] ?? vidRes.saved?.[0], + command: vidCmd, + }; + + const manifestPath = join(fixturesDir, "prerequisites.json"); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + console.error(`[前置资源] 全部就绪: ${manifestPath}`); + + return fixturesDir; +} diff --git a/packages/cli/tests/stress/lib/suite-report.mjs b/packages/cli/tests/stress/lib/suite-report.mjs new file mode 100644 index 0000000..1ef8de3 --- /dev/null +++ b/packages/cli/tests/stress/lib/suite-report.mjs @@ -0,0 +1,126 @@ +/** + * 全量压测套件总报告。 + */ +import { writeFileSync } from "fs"; +import { join } from "path"; +import { formatDuration, escapeHtml, escapeTableCell } from "./report.mjs"; + +/** + * @param {number} rate 0–1 + */ +export function formatSuccessRate(rate) { + if (!Number.isFinite(rate)) return "—"; + return `${(rate * 100).toFixed(1)}%`; +} + +/** + * @param {string} suiteRoot + * @param {object[]} rows 各用例 finishStressRun 返回的摘要 + * @param {object} meta + */ +export function writeSuiteReports(suiteRoot, rows, meta) { + const md = buildSuiteMarkdown(rows, meta); + const html = buildSuiteHtml(rows, meta); + const json = JSON.stringify({ meta, cases: rows }, null, 2); + + const reportPath = join(suiteRoot, "SUITE_REPORT.md"); + const reportHtmlPath = join(suiteRoot, "SUITE_REPORT.html"); + const jsonPath = join(suiteRoot, "suite-results.json"); + + writeFileSync(reportPath, md, "utf8"); + writeFileSync(reportHtmlPath, html, "utf8"); + writeFileSync(jsonPath, json, "utf8"); + + return { reportPath, reportHtmlPath, jsonPath }; +} + +/** + * @param {object[]} rows + * @param {object} meta + */ +function buildSuiteMarkdown(rows, meta) { + const lines = []; + lines.push("# 全量压测报告"); + lines.push(""); + lines.push(`- **生成时间**: ${meta.finishedAtIso}`); + lines.push(`- **用例数**: ${rows.length}`); + lines.push(`- **套件墙钟耗时**: ${formatDuration(meta.wallClockMs)}`); + lines.push(`- **输出根目录**: \`${meta.suiteRoot}\``); + lines.push( + `- **汇总**: 成功用例 ${meta.passedCases} / 失败用例 ${meta.failedCases} / 跳过 ${meta.skippedCases}`, + ); + lines.push(""); + lines.push("## 用例汇总"); + lines.push(""); + lines.push("| 用例 | 执行时间 | 任务数 | 并发 | 成功 | 失败 | 成功率 | 详细报告 |"); + lines.push("|------|----------|--------|------|------|------|--------|--------|"); + for (const r of rows) { + const dur = formatDuration(r.wallClockMs ?? r.finishedAt - r.startedAt ?? 0); + const sub = r.reportPath ? `\`${r.reportPath}\`` : "—"; + lines.push( + `| ${escapeTableCell(r.displayName)} | ${dur} | ${r.count ?? "—"} | ${r.concurrency ?? "—"} | ${r.successCount ?? "—"} | ${r.failCount ?? "—"} | ${formatSuccessRate(r.successRate)} | ${sub} |`, + ); + } + return lines.join("\n"); +} + +/** + * @param {object[]} rows + * @param {object} meta + */ +function buildSuiteHtml(rows, meta) { + const tableRows = rows + .map((r) => { + const dur = formatDuration(r.wallClockMs ?? r.finishedAt - r.startedAt ?? 0); + const sub = r.reportPath + ? `${escapeHtml(r.reportPath)}` + : "—"; + const failClass = (r.failCount ?? 0) > 0 ? "status-fail" : ""; + return ` + ${escapeHtml(r.displayName)} + ${escapeHtml(dur)} + ${r.count ?? "—"} + ${r.concurrency ?? "—"} + ${r.successCount ?? "—"} + ${r.failCount ?? "—"} + ${escapeHtml(formatSuccessRate(r.successRate))} + ${sub} +`; + }) + .join("\n"); + + return ` + + + + 全量压测报告 + + + +

全量压测报告

+

生成时间:${escapeHtml(meta.finishedAtIso)} · 墙钟 ${escapeHtml(formatDuration(meta.wallClockMs))} · 目录 ${escapeHtml(meta.suiteRoot)}

+

成功用例 ${meta.passedCases} · 失败用例 ${meta.failedCases} · 跳过 ${meta.skippedCases}

+ + + + + + + + +${tableRows} + +
用例执行时间任务数并发成功失败成功率详细报告
+ +`; +} diff --git a/packages/cli/tests/stress/lib/trace-ids.mjs b/packages/cli/tests/stress/lib/trace-ids.mjs new file mode 100644 index 0000000..52691a9 --- /dev/null +++ b/packages/cli/tests/stress/lib/trace-ids.mjs @@ -0,0 +1,244 @@ +/** + * 从 API JSON / CLI 日志提取 requestId、taskId,供压测报告排查。 + */ +import { extractJsonFromStdout } from "./parsers.mjs"; +import { fetchRequestIdByTaskId } from "./fetch-request-id.mjs"; + +/** + * @param {unknown} data + */ +export function extractTraceIdsFromJson(data) { + if (!data || typeof data !== "object") return {}; + + /** @type {Record} */ + const obj = /** @type {Record} */ (data); + /** @type {Record | undefined} */ + const err = + obj.error && typeof obj.error === "object" + ? /** @type {Record} */ (obj.error) + : undefined; + + const requestIdRaw = obj.request_id ?? obj.requestId ?? err?.request_id ?? err?.requestId; + const requestId = + requestIdRaw != null && String(requestIdRaw).trim() ? String(requestIdRaw).trim() : undefined; + + const taskId = formatTaskIdFromJson(obj); + return { requestId, taskId }; +} + +/** + * @param {Record} data + */ +function formatTaskIdFromJson(data) { + if (data.task_id != null && String(data.task_id).trim()) { + return String(data.task_id).trim(); + } + if (data.task_ids != null) { + return formatTaskIdsValue(data.task_ids); + } + if (Array.isArray(data.videos)) { + const ids = data.videos + .map((v) => + v && typeof v === "object" ? /** @type {{ task_id?: unknown }} */ (v).task_id : null, + ) + .filter((id) => id != null && String(id).trim()) + .map((id) => String(id).trim()); + if (ids.length > 0) return [...new Set(ids)].join(", "); + } + if (Array.isArray(data.images)) { + const ids = data.images + .map((v) => + v && typeof v === "object" ? /** @type {{ task_id?: unknown }} */ (v).task_id : null, + ) + .filter((id) => id != null && String(id).trim()) + .map((id) => String(id).trim()); + if (ids.length > 0) return [...new Set(ids)].join(", "); + } + return undefined; +} + +/** + * @param {unknown} value + */ +function formatTaskIdsValue(value) { + if (Array.isArray(value)) { + const ids = value.map((v) => String(v).trim()).filter(Boolean); + return ids.length > 0 ? ids.join(", ") : undefined; + } + if (value != null && String(value).trim()) return String(value).trim(); + return undefined; +} + +/** + * 从文本中收集所有 request_id(stderr verbose、错误块、嵌入 JSON)。 + * @param {string} text + */ +export function collectRequestIdsFromText(text) { + const combined = String(text ?? ""); + /** @type {string[]} */ + const ids = []; + + const patterns = [ + /(?:Request\s+ID|request_id)\s*:\s*([^\s\n\r]+)/gi, + /"request_id"\s*:\s*"([^"]+)"/gi, + ]; + + for (const re of patterns) { + let m; + while ((m = re.exec(combined)) !== null) { + const id = m[1].trim(); + if (id && !ids.includes(id)) ids.push(id); + } + } + + return ids; +} + +/** + * 从文本中收集 task_id。 + * @param {string} text + */ +export function collectTaskIdsFromText(text) { + const combined = String(text ?? ""); + /** @type {string[]} */ + const ids = []; + + const patterns = [/"task_id"\s*:\s*"([^"]+)"/gi, /task_id["\s:]+([a-zA-Z0-9_-]+)/gi]; + + for (const re of patterns) { + let m; + while ((m = re.exec(combined)) !== null) { + const id = m[1].trim(); + if (id && !ids.includes(id)) ids.push(id); + } + } + + return ids; +} + +/** + * 增量合并日志片段中的 trace id(避免 stderr 尾部截断丢掉早期的 request_id)。 + * @param {string} chunk + * @param {{ requestId?: string, taskId?: string }} [prev] + */ +export function captureTraceIdsFromText(chunk, prev = {}) { + const text = String(chunk ?? ""); + const reqIds = collectRequestIdsFromText(text); + const taskIds = collectTaskIdsFromText(text); + + return { + requestId: reqIds.length > 0 ? reqIds[reqIds.length - 1] : prev.requestId, + taskId: taskIds.length > 0 ? taskIds[taskIds.length - 1] : prev.taskId, + }; +} + +/** + * 扫描文本中所有 JSON 块,取最后一个 request_id(更接近最终请求)。 + * @param {string} text + */ +export function extractLastRequestIdFromAllJson(text) { + const combined = String(text ?? ""); + let last; + + const re = /"request_id"\s*:\s*"([^"]+)"/gi; + let m; + while ((m = re.exec(combined)) !== null) { + const id = m[1].trim(); + if (id) last = id; + } + + const blocks = combined.match(/\{[\s\S]*?\}/g); + if (blocks) { + for (const block of blocks) { + try { + const o = JSON.parse(block); + const { requestId } = extractTraceIdsFromJson(o); + if (requestId) last = requestId; + } catch { + // ignore + } + } + } + + return last; +} + +/** + * @param {string} stdout + * @param {string} stderr + */ +export function extractTraceIdsFromLogs(stdout, stderr) { + const combined = `${stdout ?? ""}\n${stderr ?? ""}`; + const reqIds = collectRequestIdsFromText(combined); + const taskIds = collectTaskIdsFromText(combined); + + const requestId = + (reqIds.length > 0 ? reqIds[reqIds.length - 1] : undefined) ?? + extractLastRequestIdFromAllJson(combined); + + const taskId = taskIds.length > 0 ? taskIds[taskIds.length - 1] : undefined; + + return { requestId, taskId }; +} + +/** + * 合并到单条压测结果(同步部分)。 + * @param {Record} result + */ +export function mergeTraceIds(result) { + const stdout = String(result.stdout ?? ""); + const stderr = String(result.stderr ?? ""); + const errorText = String(result.error ?? ""); + const combined = `${stdout}\n${stderr}\n${errorText}`; + + const data = extractJsonFromStdout(stdout); + const fromJson = extractTraceIdsFromJson(data); + const fromAllJson = extractLastRequestIdFromAllJson(combined); + const fromLogs = extractTraceIdsFromLogs(stdout, stderr); + const fromError = extractTraceIdsFromLogs(errorText, ""); + const fromStream = { + requestId: result.streamRequestId, + taskId: result.streamTaskId, + }; + + const taskIdsArr = Array.isArray(result.taskIds) ? result.taskIds : undefined; + const existingTask = + result.taskId ?? (taskIdsArr?.length ? taskIdsArr.map(String).join(", ") : undefined); + + const { streamRequestId: _s1, streamTaskId: _s2, ...rest } = result; + + return { + ...rest, + requestId: + result.requestId ?? + fromStream.requestId ?? + fromJson.requestId ?? + fromAllJson ?? + fromLogs.requestId ?? + fromError.requestId, + taskId: + existingTask ?? fromStream.taskId ?? fromJson.taskId ?? fromLogs.taskId ?? fromError.taskId, + }; +} + +/** + * 异步补齐 requestId(任务查询 API);不修改 CLI。 + * @param {Record} result + */ +export async function enrichTraceIdsAsync(result) { + let merged = mergeTraceIds(result); + + if (merged.requestId) return merged; + + const taskId = merged.taskId ? String(merged.taskId).split(",")[0].trim() : ""; + if (!taskId) return merged; + + if (process.env.STRESS_FETCH_REQUEST_ID === "0") return merged; + + const fromTask = await fetchRequestIdByTaskId(taskId); + if (fromTask) { + merged = { ...merged, requestId: fromTask }; + } + + return merged; +} diff --git a/packages/cli/tests/stress/run.mjs b/packages/cli/tests/stress/run.mjs new file mode 100644 index 0000000..4a2b481 --- /dev/null +++ b/packages/cli/tests/stress/run.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * 批量压测统一入口:按 target 路由到 `targets/*.mjs`。 + * + * 用法(在 monorepo 根): + * pnpm run test:stress # 执行全部用例 + 套件总报告 + * pnpm run test:stress -- list + * pnpm run test:stress -- text -- --count 10 -c 2 + * pnpm run test:stress -- all -- --count 5 -c 2 + * pnpm run test:stress -- video-edit --reuse-fixtures --setup-only + */ + +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { runStressSuite } from "./lib/run-suite.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * @param {string[]} argv + */ +function partitionStressArgv(argv) { + /** @type {{ reuseFixtures?: boolean, setupOnly?: boolean, fixturesDir?: string, stressConfigPath?: string }} */ + const globals = {}; + const filtered = []; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--reuse-fixtures") { + globals.reuseFixtures = true; + continue; + } + if (a === "--setup-only") { + globals.setupOnly = true; + continue; + } + if (a === "--fixtures-dir") { + globals.fixturesDir = argv[++i]; + continue; + } + if (a.startsWith("--fixtures-dir=")) { + globals.fixturesDir = a.slice("--fixtures-dir=".length); + continue; + } + if (a === "--stress-config") { + globals.stressConfigPath = argv[++i]; + continue; + } + if (a.startsWith("--stress-config=")) { + globals.stressConfigPath = a.slice("--stress-config=".length); + continue; + } + filtered.push(a); + } + + const cleaned = filtered.filter((a) => a !== "--"); + + const rawTarget = cleaned.shift(); + return { globals, target: rawTarget, forwarded: cleaned }; +} + +/** @type {Record} */ +const ROUTES = { + all: { file: "__suite__", canonical: "all" }, + "run-all": { file: "__suite__", canonical: "all" }, + // 文本 + text: { file: "text-chat.mjs", canonical: "text" }, + "text-chat": { file: "text-chat.mjs", canonical: "text" }, + // 语音 + "speech-tts": { file: "speech-synthesize.mjs", canonical: "speech-tts" }, + "speech-synthesize": { file: "speech-synthesize.mjs", canonical: "speech-tts" }, + "speech-asr": { file: "speech-recognize.mjs", canonical: "speech-asr" }, + "speech-recognize": { file: "speech-recognize.mjs", canonical: "speech-asr" }, + // 图像 + "image-generate": { file: "image-generate.mjs", canonical: "image-generate" }, + "image-edit": { file: "image-edit.mjs", canonical: "image-edit" }, + // 视频 + "video-t2v": { file: "video-t2v.mjs", canonical: "video-t2v" }, + "video-generate": { file: "video-t2v.mjs", canonical: "video-t2v" }, + "video-i2v": { file: "video-i2v.mjs", canonical: "video-i2v" }, + "video-ref": { file: "video-ref.mjs", canonical: "video-ref" }, + "video-edit": { file: "video-edit.mjs", canonical: "video-edit" }, +}; + +function printTargets() { + console.log(` +压测目标(第一参数,省略或 all 表示跑全部用例并生成套件总报告) +-------------------------- ---------------------------------------- +(无 / all) 顺序执行全部 9 个用例 → SUITE_REPORT.md +text bl text chat +speech-tts bl speech synthesize +speech-asr bl speech recognize(需前置音频) +image-generate bl image generate +image-edit bl image edit(需前置图) +video-t2v / video-generate bl video generate 文生视频 +video-i2v bl video generate --image(需前置图) +video-ref bl video ref(需前置图) +video-edit bl video edit(需前置短视频) + +全局选项(可出现在任意顺序,会与 target 参数分离): + --reuse-fixtures 若当前批次 fixtures/prerequisites.json 已存在则跳过生成 + --setup-only 仅生成前置资源(适用于带 fixtures 的目标;套件模式不可用) + --fixtures-dir 使用已有目录下的 prerequisites.json(不复制) + --stress-config 压测 count/concurrency 配置文件(默认 stress.defaults.json) + +示例: + pnpm run test:stress + pnpm run test:stress -- list + pnpm run test:stress -- all -- --count 5 -c 2 + pnpm run test:stress -- image-generate -- --count 5 -c 1 + pnpm run test:stress -- video-edit --setup-only +`); +} + +async function main() { + const { globals, target, forwarded } = partitionStressArgv(process.argv); + + const t = target?.trim(); + + if (t === "list" || t === "--help" || t === "-h") { + printTargets(); + process.exit(0); + } + + if (!t || t === "all" || t === "run-all") { + await runStressSuite({ globals, forwarded }); + return; + } + + const route = ROUTES[t]; + if (!route) { + console.error(`未知 target: "${t}". 运行 pnpm run test:stress -- list 查看列表。`); + process.exit(1); + } + + if (route.file === "__suite__") { + await runStressSuite({ globals, forwarded }); + return; + } + + const modHref = pathToFileURL(join(__dirname, "targets", route.file)).href; + const mod = await import(modHref); + + /** @type {(a: string[], c: object) => Promise} */ + const runStress = mod.runStress; + if (typeof runStress !== "function") { + console.error(`${route.file} 未导出 runStress`); + process.exit(1); + } + + await runStress(forwarded, { + canonicalTarget: route.canonical, + globals, + alias: t, + }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/cli/tests/stress/stress.defaults.json b/packages/cli/tests/stress/stress.defaults.json new file mode 100644 index 0000000..50c2d28 --- /dev/null +++ b/packages/cli/tests/stress/stress.defaults.json @@ -0,0 +1,14 @@ +{ + "$comment": "压测任务数/并发默认值。优先级:命令行 --count/-n、-c/--concurrency > 本文件 > lib/stress-config.mjs 中 CODE_DEFAULTS", + "targets": { + "text": { "count": 100, "concurrency": 50 }, + "speech-tts": { "count": 100, "concurrency": 50 }, + "speech-asr": { "count": 50, "concurrency": 50 }, + "image-generate": { "count": 100, "concurrency": 20 }, + "image-edit": { "count": 50, "concurrency": 20 }, + "video-t2v": { "count": 20, "concurrency": 10 }, + "video-i2v": { "count": 20, "concurrency": 20 }, + "video-ref": { "count": 20, "concurrency": 20 }, + "video-edit": { "count": 20, "concurrency": 20 } + } +} diff --git a/packages/cli/tests/stress/targets/image-edit.mjs b/packages/cli/tests/stress/targets/image-edit.mjs new file mode 100644 index 0000000..b9063b1 --- /dev/null +++ b/packages/cli/tests/stress/targets/image-edit.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * `image edit` 并发压测;依赖前置生成的图片 fixtures。 + */ +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseImageResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; + +const instructions = [ + "将整体色调改为暖色夕阳。", + "增强对比度与细节,保持主体不变。", + "为画面加入轻微胶片颗粒感。", + "把背景虚化成浅景深。", + "转换为黑白照片风格,保留主体清晰度。", + "将画面调为日系清新色调。", + "加入柔和的逆光效果。", + "将背景替换为星空。", +]; + +export const runStress = defineStressTarget({ + canonical: "image-edit", + defaultModel: "qwen-image-2.0", + batchDirPrefix: "image-edit-batch", + helpText: "pnpm run test:stress -- image-edit [--reuse-fixtures] -- --count 20", + + defaultTimeoutMs: 600_000, + minTimeoutMs: 30_000, + defaultRateLimitMax: 2, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 3000, + defaultMaxRetries: 3, + hasPollInterval: true, + defaultPollInterval: 10, + + fixtureKind: "image", + resolveFixtureRef: (prerequisites) => + prerequisites.image?.primaryUrl || + prerequisites.image?.urls?.[0] || + prerequisites.image?.saved?.[0], + fixtureRefErrorMessage: "前置 manifest 缺少图片 URL 或路径", + + generatePrompt: (idx) => `${instructions[idx % instructions.length]} [#${idx}]`, + + buildCliArgs: ({ MODEL, prompt, runDir, fixtureRef, CLI_TIMEOUT_SEC, POLL_INTERVAL }) => [ + "image", + "edit", + "--model", + MODEL, + "--image", + String(fixtureRef), + "--prompt", + prompt, + "--out-dir", + runDir, + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--poll-interval", + String(POLL_INTERVAL), + ], + + parseStdout: (stdout) => Promise.resolve(parseImageResult(stdout)), + + extraReportMeta: ({ fixtureRef }) => ({ + extraMdLines: [`- **源图**: \`${String(fixtureRef).slice(0, 120)}\``], + }), + + reportSpec: { + titleMd: "图像编辑批量压测报告(image edit)", + titleHtml: "图像编辑批量压测报告(image edit)", + promptColumnMd: "编辑指令", + promptColumnHtml: "编辑指令", + outcomeColumnMd: "图片地址 / 错误信息", + outcomeColumnHtml: "图片地址 / 错误信息", + formatOutcomeMd: (r) => { + if (r.status === "success") { + if (r.urls?.length) return escapeTableCell(r.urls.join("
")); + if (r.saved?.length) return escapeTableCell(`(本地) ${r.saved.join("
")}`); + return "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + if (r.urls?.length) { + return r.urls + .map((url) => { + const safe = escapeHtml(url); + return ``; + }) + .join(""); + } + if (r.saved?.length) + return r.saved.map((p) => `${escapeHtml(p)}`).join("
"); + return "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/image-generate.mjs b/packages/cli/tests/stress/targets/image-generate.mjs new file mode 100644 index 0000000..e4f9c9a --- /dev/null +++ b/packages/cli/tests/stress/targets/image-generate.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * `image generate` 并发压测。 + */ +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseImageResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; +import { optFrom } from "../lib/argv-parse.mjs"; + +const subjects = [ + "一只橘猫", + "一位宇航员", + "一座古堡", + "一片向日葵田", + "一条锦鲤", + "一杯拿铁", + "一辆复古自行车", + "一朵樱花", + "一只柴犬", + "一座雪山", + "一艘帆船", + "一只机械蝴蝶", + "一座中式亭台", + "一片极光", + "一只企鹅", +]; +const styles = [ + "水彩插画风格", + "赛博朋克霓虹", + "极简扁平设计", + "电影级写实光影", + "日系动漫", + "像素艺术", + "油画厚涂", + "黑白素描", + "3D 渲染", + "蒸汽波复古", +]; +const scenes = [ + "在雨夜街头", + "在火星表面", + "在海底珊瑚礁", + "在清晨薄雾中", + "在星空下", + "在图书馆里", + "在樱花飘落的公园", + "在云端之上", + "在沙漠绿洲", + "在冬日雪地里", +]; +const extras = [ + "细节丰富,构图居中", + "柔和光线,高对比", + "广角镜头,景深明显", + "暖色调,温馨氛围", + "冷色调,神秘气氛", + "留白充足,适合壁纸", +]; + +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; + +export const runStress = defineStressTarget({ + canonical: "image-generate", + defaultModel: "qwen-image-2.0", + batchDirPrefix: "image-generate-batch", + helpText: `用法(由 pnpm run test:stress -- image-generate 调用): + pnpm run test:stress -- image-generate -- --concurrency 5 --count 100 + +详见 docs/agents/stress-batch-tests.md`, + + defaultTimeoutMs: 600_000, + minTimeoutMs: 30_000, + defaultRateLimitMax: 2, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 3000, + defaultMaxRetries: 3, + hasPollInterval: true, + defaultPollInterval: 10, + + extraParams: (ARGV) => ({ + REPORT_THUMBNAILS: optFrom(ARGV, "REPORT_THUMBNAILS") === "1", + }), + + generatePrompt: (index) => { + const seed = `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`; + return `${pick(subjects)}${pick(scenes)},${pick(styles)},${pick(extras)} [#${seed.slice(-6)}]`; + }, + + buildCliArgs: ({ MODEL, prompt, runDir, CLI_TIMEOUT_SEC, POLL_INTERVAL }) => [ + "image", + "generate", + "--model", + MODEL, + "--prompt", + prompt, + "--out-dir", + runDir, + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--poll-interval", + String(POLL_INTERVAL), + ], + + parseStdout: (stdout) => Promise.resolve(parseImageResult(stdout)), + + reportSpec: { + titleMd: "图片生成批量压测报告(image-generate)", + titleHtml: "图片生成批量压测报告", + promptColumnMd: "Prompt", + promptColumnHtml: "Prompt", + outcomeColumnMd: "图片地址 / 错误信息", + outcomeColumnHtml: "图片地址 / 错误信息", + formatOutcomeMd: (r) => { + if (r.status === "success") { + if (r.urls?.length) return escapeTableCell(r.urls.join("
")); + if (r.saved?.length) return escapeTableCell(`(本地) ${r.saved.join("
")}`); + return "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + if (r.urls?.length) { + return r.urls + .map((url) => { + const safe = escapeHtml(url); + return ``; + }) + .join(""); + } + if (r.saved?.length) + return r.saved.map((p) => `${escapeHtml(p)}`).join("
"); + return "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/speech-recognize.mjs b/packages/cli/tests/stress/targets/speech-recognize.mjs new file mode 100644 index 0000000..926c9cf --- /dev/null +++ b/packages/cli/tests/stress/targets/speech-recognize.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * `speech recognize` 并发压测;依赖前置合成的音频 fixtures。 + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseAsrOutFile, parseAsrStdoutOnly, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; + +export const runStress = defineStressTarget({ + canonical: "speech-asr", + defaultModel: "fun-asr", + batchDirPrefix: "speech-asr-batch", + helpText: "pnpm run test:stress -- speech-asr [--reuse-fixtures] -- --count 10", + + defaultTimeoutMs: 600_000, + minTimeoutMs: 60_000, + defaultRateLimitMax: 6, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 4000, + defaultMaxRetries: 3, + hasPollInterval: true, + defaultPollInterval: 2, + + fixtureKind: "audio", + resolveFixtureRef: (prerequisites) => + prerequisites.audio?.url || prerequisites.audio?.saved || prerequisites.audio?.urls?.[0], + fixtureRefErrorMessage: "前置 manifest 缺少音频 URL 或路径", + + generatePrompt: (idx) => `[ASR-${idx}-${Date.now().toString(36)}]`, + + buildCliArgs: ({ MODEL, CLI_TIMEOUT_SEC, POLL_INTERVAL, fixtureRef, runDir, index }) => [ + "speech", + "recognize", + "--model", + MODEL, + "--url", + String(fixtureRef), + "--poll-interval", + String(POLL_INTERVAL), + "--out", + join(runDir, "asr-result.json"), + "--non-interactive", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--language", + "zh", + ], + + buildBaseRecord: ({ runDir }) => ({ + asrOutPath: join(runDir, "asr-result.json"), + }), + + parseStdout: async (stdout, psCtx) => { + const fp = psCtx.asrOutPath; + try { + const raw = readFileSync(fp, "utf8"); + const pr = parseAsrOutFile(raw); + if (pr.ok) { + return { ok: true, data: { transcriptNote: `(见 ${fp})`, asrOutPath: fp } }; + } + } catch { + // file not found or read failure + } + const so = parseAsrStdoutOnly(stdout); + if (so?.ok) { + return { ok: true, data: { transcriptNote: String(so.data.preview ?? ""), asrOutPath: fp } }; + } + return { ok: false, error: "无法从 --out 或 stdout 得到识别正文" }; + }, + + extraReportMeta: ({ fixtureRef }) => ({ + extraMdLines: [`- **音频输入**: \`${String(fixtureRef)}\``], + }), + + reportSpec: { + titleMd: "语音识别批量压测报告(speech recognize)", + titleHtml: "语音识别批量压测报告(speech recognize)", + promptColumnMd: "任务标记", + promptColumnHtml: "任务标记", + outcomeColumnMd: "结果", + outcomeColumnHtml: "结果", + formatOutcomeMd: (r) => { + if (r.status === "success") { + return escapeTableCell(String(r.transcriptNote ?? r.asrOutPath ?? "—")); + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + return `${escapeHtml(String(r.transcriptNote ?? r.asrOutPath ?? ""))}`; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/speech-synthesize.mjs b/packages/cli/tests/stress/targets/speech-synthesize.mjs new file mode 100644 index 0000000..354f7b1 --- /dev/null +++ b/packages/cli/tests/stress/targets/speech-synthesize.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * `speech synthesize` 并发压测。 + */ +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseSpeechSynthesizeResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; +import { optFrom } from "../lib/argv-parse.mjs"; + +const texts = [ + "今天天气晴朗,适合出门散步。", + "人工智能正在改变我们的生活。", + "春天来了,万物复苏,百花盛开。", + "学习新技能需要持续不断的练习。", + "海浪拍打着沙滩,远处传来海鸥的鸣叫。", +]; + +export const runStress = defineStressTarget({ + canonical: "speech-tts", + defaultModel: "cosyvoice-v3-flash", + batchDirPrefix: "speech-tts-batch", + helpText: "pnpm run test:stress -- speech-tts -- --count 20 --concurrency 5", + + defaultTimeoutMs: 180_000, + minTimeoutMs: 10_000, + defaultRateLimitMax: 8, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 4000, + defaultMaxRetries: 3, + + extraParams: (ARGV) => ({ + VOICE: optFrom(ARGV, "VOICE") || process.env.STRESS_TTS_VOICE?.trim() || "longanyang", + }), + + generatePrompt: (idx) => `${texts[idx % texts.length]} [run-${idx}-${Date.now().toString(36)}]`, + + buildCliArgs: ({ MODEL, prompt, runDir, CLI_TIMEOUT_SEC, extraParams, index }) => [ + "speech", + "synthesize", + "--model", + MODEL, + "--voice", + extraParams.VOICE, + "--text", + prompt, + "--out", + `${runDir}/audio_${String(index + 1).padStart(3, "0")}.mp3`, + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + ], + + parseStdout: (stdout) => Promise.resolve(parseSpeechSynthesizeResult(stdout)), + + extraReportMeta: ({ extraParams }) => ({ + extraMdLines: [`- **音色**: ${extraParams.VOICE}`], + extraHtmlMeta: ` · 音色: ${extraParams.VOICE}`, + }), + + reportSpec: { + titleMd: "语音合成批量压测报告(speech synthesize)", + titleHtml: "语音合成批量压测报告(speech synthesize)", + promptColumnMd: "文本", + promptColumnHtml: "文本", + outcomeColumnMd: "audio URL / 本地路径 / 错误信息", + outcomeColumnHtml: "audio URL / 本地路径 / 错误信息", + formatOutcomeMd: (r) => { + if (r.status === "success") { + const parts = []; + if (r.audioUrls?.length) parts.push(...r.audioUrls.map((u) => escapeTableCell(u))); + if (r.saved?.length) parts.push(...r.saved.map((p) => escapeTableCell(`(本地) ${p}`))); + return parts.length ? parts.join("
") : "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + const parts = []; + if (r.audioUrls?.length) { + for (const url of r.audioUrls) { + const safe = escapeHtml(url); + parts.push( + `${safe}`, + ); + } + } + if (r.saved?.length) { + for (const p of r.saved) { + parts.push(`${escapeHtml(p)}`); + } + } + return parts.length ? parts.join("
") : "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/text-chat.mjs b/packages/cli/tests/stress/targets/text-chat.mjs new file mode 100644 index 0000000..18f2b1c --- /dev/null +++ b/packages/cli/tests/stress/targets/text-chat.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * `text chat` 并发压测。 + */ +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseTextChatResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; + +const snippets = [ + "用两句话解释机器学习。", + "列举三个节能习惯。", + "写一首五言绝句,主题为春雨。", + "什么是 REST API?", + "简述 TypeScript 与 JavaScript 的区别。", +]; + +export const runStress = defineStressTarget({ + canonical: "text", + defaultModel: "qwen3.6-plus", + batchDirPrefix: "text-chat-batch", + helpText: "pnpm run test:stress -- text -- --count 20 --concurrency 5", + + defaultTimeoutMs: 120_000, + minTimeoutMs: 10_000, + defaultRateLimitMax: 10, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 3000, + defaultMaxRetries: 3, + + generatePrompt: (idx) => + `${snippets[idx % snippets.length]} [run-${idx}-${Date.now().toString(36)}]`, + + buildCliArgs: ({ MODEL, prompt, CLI_TIMEOUT_SEC }) => [ + "text", + "chat", + "--model", + MODEL, + "--message", + prompt, + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + ], + + parseStdout: (stdout) => Promise.resolve(parseTextChatResult(stdout)), + + reportSpec: { + titleMd: "文本对话批量压测报告(text chat)", + titleHtml: "文本对话批量压测报告(text chat)", + promptColumnMd: "Message", + promptColumnHtml: "Message", + outcomeColumnMd: "回复摘要 / 错误信息", + outcomeColumnHtml: "回复摘要 / 错误信息", + formatOutcomeMd: (r) => { + if (r.status === "success") { + return escapeTableCell(String(r.replyTextPreview ?? "").slice(0, 500)); + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + return `${escapeHtml(String(r.replyTextPreview ?? "").slice(0, 800))}`; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/video-edit.mjs b/packages/cli/tests/stress/targets/video-edit.mjs new file mode 100644 index 0000000..5ef7666 --- /dev/null +++ b/packages/cli/tests/stress/targets/video-edit.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * `video edit` 视频编辑并发压测;依赖前置短视频 fixtures。 + */ +import { join } from "node:path"; +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseVideoResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; +import { optFrom } from "../lib/argv-parse.mjs"; + +const edits = [ + "将整体画面转为柔和的水彩风格。", + "增强色彩饱和度,保持镜头稳定。", + "加入轻微电影宽银幕暗角效果。", +]; + +export const runStress = defineStressTarget({ + canonical: "video-edit", + defaultModel: "happyhorse-1.0-video-edit", + batchDirPrefix: "video-edit-batch", + helpText: "pnpm run test:stress -- video-edit [--reuse-fixtures] -- --count 5 -c 2", + + defaultTimeoutMs: 3_600_000, + minTimeoutMs: 60_000, + defaultRateLimitMax: 10, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 15000, + defaultMaxRetries: 2, + hasPollInterval: true, + defaultPollInterval: 15, + + fixtureKind: "video", + fixtureSetupTimeoutMs: 600_000, + videoSetupTimeoutMs: 3_600_000, + resolveFixtureRef: (prerequisites) => + prerequisites.video?.video_url || prerequisites.video?.saved, + fixtureRefErrorMessage: "前置 manifest 缺少输入视频", + + extraParams: (ARGV) => ({ + DURATION: Math.max(2, Math.min(10, parseInt(optFrom(ARGV, "DURATION") ?? "5", 10) || 5)), + }), + + generatePrompt: (idx) => `${edits[idx % edits.length]} [#edit-${idx}]`, + + buildCliArgs: ({ + MODEL, + prompt, + runDir, + fixtureRef, + CLI_TIMEOUT_SEC, + POLL_INTERVAL, + extraParams, + index, + }) => [ + "video", + "edit", + "--model", + MODEL, + "--video", + String(fixtureRef), + "--prompt", + prompt, + "--download", + join(runDir, `edited_${String(index + 1).padStart(3, "0")}.mp4`), + "--duration", + String(extraParams.DURATION), + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--poll-interval", + String(POLL_INTERVAL), + ], + + buildBaseRecord: ({ runDir, index }) => ({ + downloadPath: join(runDir, `edited_${String(index + 1).padStart(3, "0")}.mp4`), + }), + + parseStdout: (stdout) => Promise.resolve(parseVideoResult(stdout)), + + extraReportMeta: ({ fixtureRef, extraParams }) => ({ + extraMdLines: [ + `- **源视频**: ${String(fixtureRef)}`, + `- **输出时长**: ${extraParams.DURATION}s`, + ], + extraHtmlMeta: ` · edit · ${extraParams.DURATION}s`, + }), + + reportSpec: { + titleMd: "视频编辑批量压测报告(video edit)", + titleHtml: "视频编辑批量压测报告(video edit)", + promptColumnMd: "编辑指令", + promptColumnHtml: "编辑指令", + outcomeColumnMd: "输出视频 / 错误", + outcomeColumnHtml: "输出视频 / 错误", + formatOutcomeMd: (r) => { + if (r.status === "success") { + const lines = []; + if (r.videoUrls?.length) lines.push(...r.videoUrls.map((u) => escapeTableCell(u))); + if (r.saved?.length) lines.push(...r.saved.map((p) => escapeTableCell(`(本地) ${p}`))); + return lines.length ? lines.join("
") : "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + const parts = []; + if (r.videoUrls?.length) { + for (const url of r.videoUrls) { + const safe = escapeHtml(url); + parts.push( + ``, + ); + } + } + if (r.saved?.length) { + for (const p of r.saved) parts.push(`${escapeHtml(p)}`); + } + return parts.length ? parts.join("") : "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/video-i2v.mjs b/packages/cli/tests/stress/targets/video-i2v.mjs new file mode 100644 index 0000000..b0cecfc --- /dev/null +++ b/packages/cli/tests/stress/targets/video-i2v.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * `video generate` 图生视频(--image)并发压测。 + */ +import { join } from "node:path"; +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseVideoResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; +import { optFrom } from "../lib/argv-parse.mjs"; + +const motions = [ + "让画面主体微微晃动,云层缓慢流动。", + "镜头缓缓推进,保持画面稳定。", + "风轻拂树叶,光影缓慢变化。", +]; + +export const runStress = defineStressTarget({ + canonical: "video-i2v", + defaultModel: "happyhorse-1.0-i2v", + batchDirPrefix: "video-i2v-batch", + helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2", + + defaultTimeoutMs: 3_600_000, + minTimeoutMs: 60_000, + defaultRateLimitMax: 10, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 8000, + defaultMaxRetries: 3, + hasPollInterval: true, + defaultPollInterval: 5, + + fixtureKind: "image", + fixtureSetupTimeoutMs: 600_000, + videoSetupTimeoutMs: 3_600_000, + resolveFixtureRef: (prerequisites) => + prerequisites.image?.primaryUrl || + prerequisites.image?.urls?.[0] || + prerequisites.image?.saved?.[0], + fixtureRefErrorMessage: "前置 manifest 缺少图片", + + extraParams: (ARGV) => ({ + DURATION: Math.max(1, parseInt(optFrom(ARGV, "DURATION") ?? "5", 10) || 5), + }), + + generatePrompt: (idx) => `${motions[idx % motions.length]} [#i2v-${idx}]`, + + buildCliArgs: ({ + MODEL, + prompt, + runDir, + fixtureRef, + CLI_TIMEOUT_SEC, + POLL_INTERVAL, + extraParams, + index, + }) => [ + "video", + "generate", + "--model", + MODEL, + "--image", + String(fixtureRef), + "--prompt", + prompt, + "--download", + join(runDir, `video_${String(index + 1).padStart(3, "0")}.mp4`), + "--duration", + String(extraParams.DURATION), + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--poll-interval", + String(POLL_INTERVAL), + ], + + buildBaseRecord: ({ runDir, index }) => ({ + downloadPath: join(runDir, `video_${String(index + 1).padStart(3, "0")}.mp4`), + }), + + parseStdout: (stdout) => Promise.resolve(parseVideoResult(stdout)), + + extraReportMeta: ({ fixtureRef, extraParams }) => ({ + extraMdLines: [ + `- **首帧图**: ${String(fixtureRef)}`, + `- **单段时长**: ${extraParams.DURATION}s`, + ], + extraHtmlMeta: ` · i2v · ${extraParams.DURATION}s`, + }), + + reportSpec: { + titleMd: "图生视频批量压测报告(video generate i2v)", + titleHtml: "图生视频批量压测报告(video generate i2v)", + promptColumnMd: "Prompt", + promptColumnHtml: "Prompt", + outcomeColumnMd: "视频 / 错误", + outcomeColumnHtml: "视频 / 错误", + formatOutcomeMd: (r) => { + if (r.status === "success") { + const lines = []; + if (r.videoUrls?.length) lines.push(...r.videoUrls.map((u) => escapeTableCell(u))); + if (r.saved?.length) lines.push(...r.saved.map((p) => escapeTableCell(`(本地) ${p}`))); + return lines.length ? lines.join("
") : "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + const parts = []; + if (r.videoUrls?.length) { + for (const url of r.videoUrls) { + const safe = escapeHtml(url); + parts.push( + ``, + ); + } + } + if (r.saved?.length) { + for (const p of r.saved) parts.push(`${escapeHtml(p)}`); + } + return parts.length ? parts.join("") : "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/video-ref.mjs b/packages/cli/tests/stress/targets/video-ref.mjs new file mode 100644 index 0000000..480a1eb --- /dev/null +++ b/packages/cli/tests/stress/targets/video-ref.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * `video ref` 参考生视频并发压测;使用前置图片。 + */ +import { join } from "node:path"; +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseVideoResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; +import { optFrom } from "../lib/argv-parse.mjs"; + +const prompts = [ + "图1在草地上缓慢行走,远景静态镜头。", + "图1微微转头望向镜头,背景保持稳定。", + "图1在柔和光线下眨眼,微风拂动发丝。", +]; + +export const runStress = defineStressTarget({ + canonical: "video-ref", + defaultModel: "happyhorse-1.0-r2v", + batchDirPrefix: "video-ref-batch", + helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2", + + defaultTimeoutMs: 3_600_000, + minTimeoutMs: 60_000, + defaultRateLimitMax: 10, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 15000, + defaultMaxRetries: 2, + hasPollInterval: true, + defaultPollInterval: 15, + + fixtureKind: "image", + fixtureSetupTimeoutMs: 600_000, + videoSetupTimeoutMs: 3_600_000, + resolveFixtureRef: (prerequisites) => + prerequisites.image?.primaryUrl || + prerequisites.image?.urls?.[0] || + prerequisites.image?.saved?.[0], + fixtureRefErrorMessage: "前置 manifest 缺少参考图", + + extraParams: (ARGV) => ({ + DURATION: Math.max(2, Math.min(10, parseInt(optFrom(ARGV, "DURATION") ?? "5", 10) || 5)), + }), + + generatePrompt: (idx) => `${prompts[idx % prompts.length]} [#r2v-${idx}]`, + + buildCliArgs: ({ + MODEL, + prompt, + runDir, + fixtureRef, + CLI_TIMEOUT_SEC, + POLL_INTERVAL, + extraParams, + index, + }) => [ + "video", + "ref", + "--model", + MODEL, + "--prompt", + prompt, + "--image", + String(fixtureRef), + "--download", + join(runDir, `ref_${String(index + 1).padStart(3, "0")}.mp4`), + "--duration", + String(extraParams.DURATION), + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--poll-interval", + String(POLL_INTERVAL), + ], + + buildBaseRecord: ({ runDir, index }) => ({ + downloadPath: join(runDir, `ref_${String(index + 1).padStart(3, "0")}.mp4`), + }), + + parseStdout: (stdout) => Promise.resolve(parseVideoResult(stdout)), + + extraReportMeta: ({ fixtureRef, extraParams }) => ({ + extraMdLines: [`- **参考图**: ${String(fixtureRef)}`, `- **时长**: ${extraParams.DURATION}s`], + extraHtmlMeta: ` · ref · ${extraParams.DURATION}s`, + }), + + reportSpec: { + titleMd: "参考生视频批量压测报告(video ref)", + titleHtml: "参考生视频批量压测报告(video ref)", + promptColumnMd: "Prompt", + promptColumnHtml: "Prompt", + outcomeColumnMd: "视频 / 错误", + outcomeColumnHtml: "视频 / 错误", + formatOutcomeMd: (r) => { + if (r.status === "success") { + const lines = []; + if (r.videoUrls?.length) lines.push(...r.videoUrls.map((u) => escapeTableCell(u))); + if (r.saved?.length) lines.push(...r.saved.map((p) => escapeTableCell(`(本地) ${p}`))); + return lines.length ? lines.join("
") : "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + const parts = []; + if (r.videoUrls?.length) { + for (const url of r.videoUrls) { + const safe = escapeHtml(url); + parts.push( + ``, + ); + } + } + if (r.saved?.length) { + for (const p of r.saved) parts.push(`${escapeHtml(p)}`); + } + return parts.length ? parts.join("") : "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tests/stress/targets/video-t2v.mjs b/packages/cli/tests/stress/targets/video-t2v.mjs new file mode 100644 index 0000000..d29a161 --- /dev/null +++ b/packages/cli/tests/stress/targets/video-t2v.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * `video generate` 文本生视频并发压测。 + */ +import { join } from "node:path"; +import { defineStressTarget } from "../lib/define-stress-target.mjs"; +import { parseVideoResult, extractError } from "../lib/parsers.mjs"; +import { escapeHtml, escapeTableCell, getErrorMessage } from "../lib/report.mjs"; +import { optFrom } from "../lib/argv-parse.mjs"; + +const subjects = [ + "一只橘猫", + "一位宇航员", + "一片海浪", + "一座古城", + "一辆复古汽车", + "一位舞者在舞台上", + "一片樱花飘落", + "一座雪山", + "一条街道", + "一只飞鸟", +]; +const motions = [ + "缓缓转头看向镜头", + "在微风中轻轻摇摆", + "从远处向镜头走来", + "镜头缓慢推进", + "光影随日落变化", + "静态镜头,细微自然运动", + "镜头环绕半圈", + "雨滴落下,水面泛起涟漪", +]; +const styles = [ + "电影感写实", + "日系清新", + "赛博朋克霓虹", + "水墨意境", + "纪录片风格", + "慢动作", + "暖色调", + "冷色调悬疑", +]; + +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; + +export const runStress = defineStressTarget({ + canonical: "video-t2v", + defaultModel: "happyhorse-1.0-t2v", + batchDirPrefix: "video-t2v-batch", + helpText: `用法:pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3 +详见 docs/agents/stress-batch-tests.md`, + + defaultTimeoutMs: 3_600_000, + minTimeoutMs: 60_000, + defaultRateLimitMax: 10, + defaultRateLimitWindowMs: 1000, + defaultRetryBaseMs: 5000, + defaultMaxRetries: 3, + hasPollInterval: true, + defaultPollInterval: 5, + + extraParams: (ARGV) => ({ + DURATION: Math.max(1, parseInt(optFrom(ARGV, "DURATION") ?? "5", 10) || 5), + }), + + generatePrompt: (index) => { + const seed = `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`; + return `${pick(subjects)},${pick(motions)},${pick(styles)} [#${seed.slice(-6)}]`; + }, + + buildCliArgs: ({ MODEL, prompt, runDir, CLI_TIMEOUT_SEC, POLL_INTERVAL, extraParams, index }) => [ + "video", + "generate", + "--model", + MODEL, + "--prompt", + prompt, + "--download", + join(runDir, `video_${String(index + 1).padStart(3, "0")}.mp4`), + "--duration", + String(extraParams.DURATION), + "--non-interactive", + "--output", + "json", + "--timeout", + String(CLI_TIMEOUT_SEC), + "--poll-interval", + String(POLL_INTERVAL), + ], + + buildBaseRecord: ({ runDir, index, extraParams }) => ({ + downloadPath: join(runDir, `video_${String(index + 1).padStart(3, "0")}.mp4`), + }), + + parseStdout: (stdout) => Promise.resolve(parseVideoResult(stdout)), + + extraReportMeta: ({ extraParams }) => ({ + extraMdLines: [`- **单段时长**: ${extraParams.DURATION}s`], + extraHtmlMeta: ` · 单段时长 ${extraParams.DURATION}s`, + }), + + reportSpec: { + titleMd: "视频文本生批量压测报告(video-t2v)", + titleHtml: "视频文本生批量压测报告(video-t2v)", + promptColumnMd: "Prompt", + promptColumnHtml: "Prompt", + outcomeColumnMd: "视频地址 / 错误信息", + outcomeColumnHtml: "视频地址 / 错误信息", + formatOutcomeMd: (r) => { + if (r.status === "success") { + const lines = []; + if (r.videoUrls?.length) lines.push(...r.videoUrls.map((u) => escapeTableCell(u))); + if (r.saved?.length) lines.push(...r.saved.map((p) => escapeTableCell(`(本地) ${p}`))); + if (r.size) lines.push(escapeTableCell(`size: ${r.size}`)); + return lines.length ? lines.join("
") : "—"; + } + return escapeTableCell(getErrorMessage(r, extractError)); + }, + formatOutcomeHtml: (r) => { + if (r.status === "success") { + const parts = []; + if (r.videoUrls?.length) { + for (const url of r.videoUrls) { + const safe = escapeHtml(url); + parts.push( + ``, + ); + } + } + if (r.saved?.length) { + for (const p of r.saved) parts.push(`${escapeHtml(p)}`); + } + if (r.size) + parts.push(`size: ${escapeHtml(String(r.size))}`); + return parts.length ? parts.join("") : "—"; + } + return `${escapeHtml(getErrorMessage(r, extractError))}`; + }, + }, +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..ff4adab --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + } +} diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts new file mode 100644 index 0000000..769e51e --- /dev/null +++ b/packages/cli/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + globalSetup: "./tests/e2e/global-setup.ts", + testTimeout: 60_000, + hookTimeout: 60_000, + }, + pack: { + entry: { + bailian: "src/main.ts", + }, + hash: false, + minify: true, + platform: "node", + banner: "#!/usr/bin/env node\n", + outputOptions: { + codeSplitting: false, + }, + dts: { + tsgo: true, + }, + exports: true, + }, + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +}); diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 0000000..7535211 --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..9eb125c --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Aliyun Model Studio (DashScope) AI Platform + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..6d2bd60 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,14 @@ +# bailian-cli-core + +[English](./README.md) | [简体中文](./README_CN.md) + +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: + +## License + +Apache-2.0 diff --git a/packages/core/README_CN.md b/packages/core/README_CN.md new file mode 100644 index 0000000..bd80b57 --- /dev/null +++ b/packages/core/README_CN.md @@ -0,0 +1,14 @@ +# bailian-cli-core + +[English](./README.md) | [简体中文](./README_CN.md) + +[`bailian-cli`](https://www.npmjs.com/package/bailian-cli) 的内部 SDK。 + +不建议直接使用。安装、使用方式与文档请参考 CLI: + +- npm: +- 主页: + +## License + +Apache-2.0 diff --git a/packages/core/lib/remote-telemetry/event-plugin.d.ts b/packages/core/lib/remote-telemetry/event-plugin.d.ts new file mode 100644 index 0000000..d50e3cd --- /dev/null +++ b/packages/core/lib/remote-telemetry/event-plugin.d.ts @@ -0,0 +1,3 @@ +declare const RemoteEventPlugin: unknown; + +export default RemoteEventPlugin; diff --git a/packages/core/lib/remote-telemetry/event-plugin.js b/packages/core/lib/remote-telemetry/event-plugin.js new file mode 100644 index 0000000..638e10d --- /dev/null +++ b/packages/core/lib/remote-telemetry/event-plugin.js @@ -0,0 +1,120 @@ +/* oxlint-disable */ +module.exports = (function (e) { + var t = {}; + function n(r) { + if (t[r]) return t[r].exports; + var o = (t[r] = { i: r, l: !1, exports: {} }); + return (e[r].call(o.exports, o, o.exports, n), (o.l = !0), o.exports); + } + return ( + (n.m = e), + (n.c = t), + (n.d = function (e, t, r) { + n.o(e, t) || Object.defineProperty(e, t, { enumerable: !0, get: r }); + }), + (n.r = function (e) { + ("undefined" != typeof Symbol && + Symbol.toStringTag && + Object.defineProperty(e, Symbol.toStringTag, { value: "Module" }), + Object.defineProperty(e, "__esModule", { value: !0 })); + }), + (n.t = function (e, t) { + if ((1 & t && (e = n(e)), 8 & t)) return e; + if (4 & t && "object" == typeof e && e && e.__esModule) return e; + var r = Object.create(null); + if ( + (n.r(r), + Object.defineProperty(r, "default", { enumerable: !0, value: e }), + 2 & t && "string" != typeof e) + ) + for (var o in e) + n.d( + r, + o, + function (t) { + return e[t]; + }.bind(null, o), + ); + return r; + }), + (n.n = function (e) { + var t = + e && e.__esModule + ? function () { + return e.default; + } + : function () { + return e; + }; + return (n.d(t, "a", t), t); + }), + (n.o = function (e, t) { + return Object.prototype.hasOwnProperty.call(e, t); + }), + (n.p = ""), + n((n.s = 0)) + ); +})([ + function (e, t, n) { + "use strict"; + n.r(t); + var r = ["ec", "ea", "el", "et"]; + var o = function (e, t) { + var n = function (e) { + var n = e.ec, + r = e.ea, + o = e.el, + l = e.et, + u = void 0 === l ? "CLK" : l, + a = e.xpath; + (delete e.ec, + delete e.ea, + delete e.el, + delete e.et, + delete e.xpath, + (e.p1 = n), + (e.p2 = r), + (e.p3 = o), + (e.p4 = u), + (e.p5 = a)); + try { + t.log("event", e); + } catch (e) {} + }; + return function () { + var t = arguments, + o = {}; + if (0 !== t.length) { + for (var l = 0; l < t.length; l++) { + var u, + a, + i = t[l]; + if (0 !== l && "object" == typeof i && l !== t.length - 1) + return void ( + null == e || + null === (u = e.console) || + void 0 === u || + null === (a = u.warn) || + void 0 === a || + a.call(u, "Only the last argument can be object type") + ); + if ("string" == typeof i || "number" == typeof i) o[r[l]] = i; + else if ("object" == typeof i && l === t.length - 1) + for (var c in i) i.hasOwnProperty(c) && (o[c] = i[c]); + } + n(o); + } else { + var f, p; + null === (f = e.console) || + void 0 === f || + null === (p = f.warn) || + void 0 === p || + p.call(f, "At lease one augument"); + } + }; + }; + t.default = function (e, t) { + return o(global, e); + }; + }, +]).default; diff --git a/packages/core/lib/remote-telemetry/tracker.d.ts b/packages/core/lib/remote-telemetry/tracker.d.ts new file mode 100644 index 0000000..f87e25e --- /dev/null +++ b/packages/core/lib/remote-telemetry/tracker.d.ts @@ -0,0 +1,7 @@ +declare class RemoteTracker { + constructor(config: { pid: string; debug?: boolean; [key: string]: unknown }); + use(plugin: unknown): (...args: any[]) => void; + send(gokey: string): Promise; +} + +export default RemoteTracker; diff --git a/packages/core/lib/remote-telemetry/tracker.js b/packages/core/lib/remote-telemetry/tracker.js new file mode 100644 index 0000000..c2e363b --- /dev/null +++ b/packages/core/lib/remote-telemetry/tracker.js @@ -0,0 +1,1143 @@ +/* oxlint-disable */ +module.exports = (function (e) { + var t = {}; + function n(r) { + if (t[r]) return t[r].exports; + var o = (t[r] = { i: r, l: !1, exports: {} }); + return (e[r].call(o.exports, o, o.exports, n), (o.l = !0), o.exports); + } + return ( + (n.m = e), + (n.c = t), + (n.d = function (e, t, r) { + n.o(e, t) || Object.defineProperty(e, t, { enumerable: !0, get: r }); + }), + (n.r = function (e) { + ("undefined" != typeof Symbol && + Symbol.toStringTag && + Object.defineProperty(e, Symbol.toStringTag, { value: "Module" }), + Object.defineProperty(e, "__esModule", { value: !0 })); + }), + (n.t = function (e, t) { + if ((1 & t && (e = n(e)), 8 & t)) return e; + if (4 & t && "object" == typeof e && e && e.__esModule) return e; + var r = Object.create(null); + if ( + (n.r(r), + Object.defineProperty(r, "default", { enumerable: !0, value: e }), + 2 & t && "string" != typeof e) + ) + for (var o in e) + n.d( + r, + o, + function (t) { + return e[t]; + }.bind(null, o), + ); + return r; + }), + (n.n = function (e) { + var t = + e && e.__esModule + ? function () { + return e.default; + } + : function () { + return e; + }; + return (n.d(t, "a", t), t); + }), + (n.o = function (e, t) { + return Object.prototype.hasOwnProperty.call(e, t); + }), + (n.p = ""), + n((n.s = 8)) + ); +})([ + function (e, t) { + e.exports = require("os"); + }, + function (e, t) { + e.exports = globalThis.fetch; + }, + function (e, t, n) { + "use strict"; + e.exports = n(6); + }, + function (e, t) { + e.exports = require("dns"); + }, + function (e, t) { + e.exports = require("util"); + }, + function (e, t) { + e.exports = require("crypto"); + }, + function (e, t, n) { + Object.defineProperty(t, Symbol.toStringTag, { value: "Module" }); + const r = n(7), + o = (e, t) => { + ((t.appName = "BaiduSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "Baidu"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + i = (e, t) => { + ((t.appName = "360 Spider"), + (t.appVersion = e.value), + (t.deviceBrand = "360"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + a = (e, t) => { + ((t.appName = "BingBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Microsoft"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + u = (e, t) => { + ((t.appName = "Googlebot"), + (t.appVersion = e.value), + (t.deviceBrand = "Google"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + p = (e, t) => { + ((t.appName = "YandexBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Yandex"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + l = (e, t) => { + ("Sogou web spider" === e.getPreviousNTokens(3) && + ((t.deviceBrand = "Sogou.com"), (t.appName = "SogouSpider")), + (t.appVersion = e.value), + (t.deviceType = "bot")); + }, + s = (e, t) => { + ((t.appName = "DataproviderBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Dataprovider.com"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + c = (e, t) => { + ((t.appName = "AhrefsBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Ahrefs"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + d = (e, t) => { + ((t.appName = "BitSightBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Bitsight"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + f = (e, t) => { + ((t.appName = "oBot"), + (t.appVersion = e.value), + (t.deviceBrand = "IBM"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + v = (e, t) => { + ((t.appName = "Cincraw"), + (t.appVersion = e.value), + (t.deviceBrand = "CINC"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + g = (e, t) => { + ((t.appName = "DingTalkBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Alibaba"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + h = (e, t) => { + ((t.appName = "YisouSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "Alibaba"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + m = (e, t) => { + ((t.appName = "ByteSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "ByteDance"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + b = (e, t) => { + ((t.appName = "HeadlineCrawler"), + (t.appVersion = e.value), + (t.deviceBrand = "Headline.com"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + y = (e, t) => { + ((t.appName = "BitDiscoveryBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Tenable"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + _ = (e, t) => { + ("Screaming Frog SEO Spider" === e.getPreviousNTokens(4) && + ((t.deviceBrand = "Screaming Frog"), (t.appName = "Screaming Frog")), + (t.appVersion = e.value), + (t.deviceType = "bot")); + }, + B = (e, t) => { + ((t.appName = "Ai2Bot"), + (t.appVersion = e.value), + (t.deviceBrand = "Ai2"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + S = (e, t) => { + ((t.appName = "DianjingAdSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "Dianjing"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + T = (e, t) => { + ((t.appName = "BaiduSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "Baidu"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + j = (e, t) => { + ((t.appName = "360 Spider"), + (t.appVersion = e.value), + (t.deviceBrand = "360"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + N = (e, t) => { + ((t.appName = "BingBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Microsoft"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + w = (e, t) => { + ((t.appName = "Googlebot"), + (t.appVersion = e.value), + (t.deviceBrand = "Google"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + O = (e, t) => { + ((t.appName = "YandexBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Yandex"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + V = (e, t) => { + ("Sogou web spider" === e.getPreviousNTokens(3) && + ((t.deviceBrand = "Sogou.com"), (t.appName = "SogouSpider")), + (t.appVersion = e.value), + (t.deviceType = "bot")); + }, + A = (e, t) => { + ((t.appName = "DataproviderBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Dataprovider.com"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + D = (e, t) => { + ((t.appName = "AhrefsBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Ahrefs"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + k = (e, t) => { + ((t.appName = "BitSightBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Bitsight"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + P = (e, t) => { + ((t.appName = "oBot"), + (t.appVersion = e.value), + (t.deviceBrand = "IBM"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + C = (e, t) => { + ((t.appName = "Cincraw"), + (t.appVersion = e.value), + (t.deviceBrand = "CINC"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + E = (e, t) => { + ((t.appName = "DingTalkBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Alibaba"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + x = (e, t) => { + ((t.appName = "YisouSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "Alibaba"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + M = (e, t) => { + ((t.appName = "ByteSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "ByteDance"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + q = (e, t) => { + ((t.appName = "HeadlineCrawler"), + (t.appVersion = e.value), + (t.deviceBrand = "Headline.com"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + I = (e, t) => { + ((t.appName = "BitDiscoveryBot"), + (t.appVersion = e.value), + (t.deviceBrand = "Tenable"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + H = (e, t) => { + ("Screaming Frog SEO Spider" === e.getPreviousNTokens(4) && + ((t.deviceBrand = "Screaming Frog"), (t.appName = "Screaming Frog")), + (t.appVersion = e.value), + (t.deviceType = "bot")); + }, + U = (e, t) => { + ((t.appName = "Ai2Bot"), + (t.appVersion = e.value), + (t.deviceBrand = "Ai2"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + L = (e, t) => { + ((t.appName = "DianjingAdSpider"), + (t.appVersion = e.value), + (t.deviceBrand = "Dianjing"), + (t.deviceType = "bot"), + (t.platform = "other")); + }, + Q = new Map(), + R = new Map(); + (Q.set("Baiduspider-render", o), + Q.set("Baiduspider+", o), + Q.set("Baiduspider-image+", o), + Q.set("360Spider", i), + Q.set("360Spider-Image", i), + Q.set("bingbot", a), + Q.set("Googlebot", u), + Q.set("YandexRenderResourcesBot", p), + Q.set("spider", l), + Q.set("Dataprovider.com", s), + Q.set("AhrefsBot", c), + Q.set("BitSightBot", d), + Q.set("oBot", f), + Q.set("Cincraw", v), + Q.set("DingTalkBot-LinkService", g), + Q.set("YisouSpider", h), + Q.set("Bytespider", m), + Q.set("ev-crawler", b), + Q.set("bitdiscovery", y), + Q.set("Spider", _), + Q.set("Ai2Bot-Dolma", B), + Q.set("dianjing_ad_spider", S), + R.set("Baiduspider-render", T), + R.set("Baiduspider+", T), + R.set("Baiduspider-image+", T), + R.set("360Spider", j), + R.set("360Spider-Image", j), + R.set("bingbot", N), + R.set("Googlebot", w), + R.set("YandexRenderResourcesBot", O), + R.set("spider", V), + R.set("Dataprovider.com", A), + R.set("AhrefsBot", D), + R.set("BitSightBot", k), + R.set("oBot", P), + R.set("Cincraw", C), + R.set("DingTalkBot-LinkService", E), + R.set("YisouSpider", x), + R.set("Bytespider", M), + R.set("ev-crawler", q), + R.set("bitdiscovery", I), + R.set("Spider", H), + R.set("Ai2Bot-Dolma", U), + R.set("dianjing_ad_spider", L)); + const F = { + productHandlerMap: Q, + commentHandlerMap: R, + getSpecialProductHandler: () => null, + getSpecialCommentHandler: () => null, + getDefaultModelHandler: () => null, + }; + t.isBot = function (e) { + const t = r.createUAInfo(); + return (r.runTask(e, t, F), "bot" === t.deviceType); + }; + }, + function (e, t) { + function n(e) { + const t = [], + n = { + parent: e, + tokens: t, + get firstToken() { + return 0 === t.length ? null : t[0]; + }, + getNewToken(r) { + const o = (function () { + const e = [], + t = [], + n = []; + let r = null, + o = null, + i = null, + a = !0, + u = !0, + p = !0, + l = null; + const s = { + get key() { + return (a && ((r = e.join("")), (a = !1)), r); + }, + get value() { + return (u && ((o = t.join("")), (u = !1)), o); + }, + get originValue() { + return (p && ((i = n.join("")), (p = !1)), i); + }, + previousToken: null, + properties: null, + appendKey(t) { + (e.push(t), (a = !0)); + }, + appendValue(e) { + (t.push("_" === e ? "." : e), n.push(e), (u = !0), (p = !0), (l = null)); + }, + getSplitValue(e) { + if (null === l) { + const e = s.value; + l = "" === e ? [] : e.split("/"); + } + return e >= 0 && e < l.length ? l[e] : null; + }, + getPreviousNTokens(e) { + const t = []; + let n = s; + for (let r = 0; r < e; r++) { + if (null == n) return null; + (t.unshift(n.key), (n = n.previousToken)); + } + return t.join(" "); + }, + }; + return s; + })(); + return ( + t.push(o), + (o.previousToken = void 0 !== r ? r : t.length > 1 ? t[t.length - 2] : null), + e && (e.properties = n), + o + ); + }, + getLastToken: () => (0 === t.length ? null : t[t.length - 1]), + getFirstToken: () => (0 === t.length ? null : t[0]), + isEmpty: () => 0 === t.length, + }; + return n; + } + function r() { + return { + appName: null, + appVersion: null, + browserName: null, + browserVersion: null, + engineName: null, + engineVersion: null, + deviceBrand: null, + deviceModel: null, + deviceType: "mobile", + osName: null, + osVersion: null, + platform: "web", + tokenGroup: n(null), + }; + } + const o = new Set(" ;,\"'".split("")), + i = new Set("/=:".split("")), + a = new Set([ + "Mozilla", + "AppleWebKit", + "Safari", + "Opera", + "Dalvik", + "com.ss.android.ugc.aweme", + ]); + function u(e) { + return 1 === e.length && o.has(e); + } + function p(e) { + return 1 === e.length && i.has(e); + } + function l(e, t, n, r) { + if (null == e) return; + const o = t.parent, + i = e.key; + let u = null; + if (null != o) { + const e = o.key; + var p, l; + if (a.has(e)) + ((u = null !== (p = r.commentHandlerMap.get(i)) && void 0 !== p ? p : null), + null == u && (u = r.getSpecialCommentHandler(i)), + null == u && i.endsWith(" Build") && (u = r.getDefaultModelHandler())); + else + u = + null !== (l = r.productHandlerMap.get(i)) && void 0 !== l + ? l + : r.getSpecialProductHandler(i); + } else { + var s; + u = + null !== (s = r.productHandlerMap.get(i)) && void 0 !== s + ? s + : r.getSpecialProductHandler(i); + } + if (null != u) + try { + u(e, n); + } catch (e) {} + } + function s(e, t, r) { + if (null == e) throw new Error("input can not be null"); + return ( + (function e(t, r, o, i, a) { + let s, + c = null, + d = null, + f = !1; + const v = t.length; + let g = r > 0 ? t[r - 1] : "\0"; + for (s = r; s < v; s++) { + const h = t[s]; + if (u(h)) { + const e = "\0" !== g && u(g); + if (!f && r > 0 && " " === h && !e) { + const e = s + 1; + if (e < v) { + const n = t[e]; + /\d/.test(n) || "-" === n ? (f = !0) : null != d && d.appendKey(h); + } else null != d && d.appendKey(h); + } else null != d && ((c = d), (d = null)); + g = h; + } else if ("(" === h) { + if ("(" === g) { + g = h; + continue; + } + const r = s; + ((s = e(t, s + 1, n(o.getLastToken()), i, a)), + null != d && ((c = d), (d = null)), + (g = t[r])); + } else { + if (")" === h) { + if (0 === r) { + g = h; + continue; + } + break; + } + (null == d && (l(o.getLastToken(), o, i, a), (d = o.getNewToken(c)), (f = !1)), + p(h) ? (f && d.appendValue(h), (f = !0)) : f ? d.appendValue(h) : d.appendKey(h), + (g = h)); + } + } + return (l(o.getLastToken(), o, i, a), s); + })(e, 0, t.tokenGroup, t, r), + t + ); + } + (Object.defineProperty(t, "DEFAULT_MODEL_HANDLER_KEY", { + enumerable: !0, + get: function () { + return "DEFAULT_MODEL_HANDLER"; + }, + }), + Object.defineProperty(t, "createUAInfo", { + enumerable: !0, + get: function () { + return r; + }, + }), + Object.defineProperty(t, "runTask", { + enumerable: !0, + get: function () { + return s; + }, + })); + }, + function (e, t, n) { + "use strict"; + n.r(t); + var r = n(0), + o = n.n(r), + i = n(1), + a = n.n(i); + n(2); + function u() { + var e = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 20, + t = arguments.length > 1 ? arguments[1] : void 0; + return ( + (t = t || ""), + e + ? u( + --e, + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz".charAt( + Math.floor(60 * Math.random()), + ) + t, + ) + : t + ); + } + function p(e, t) { + for (var n in t) e[n] = t[n]; + return e; + } + function l(e) { + return "[object Object]" === Object.prototype.toString.call(e); + } + function s(e) { + return "undefined" != typeof Promise && e instanceof Promise; + } + var c = Object.freeze({ __aesBeforeSkip: 1 }), + d = function (e) { + var t = Object.prototype.toString.call(e); + if (("[object String]" === t && e) || "[object Number]" === t || "[object Boolean]" === t) + return e; + if ("[object Object]" === t || "[object Array]" === t) + try { + return JSON.stringify(e); + } catch (e) {} + }, + f = function (e) { + var t = {}; + for (var n in e) { + var r = e[n]; + void 0 !== r && (t[n] = d(r)); + } + return t; + }, + v = function (e) { + var t = []; + for (var n in e) { + var r = d(e[n]); + void 0 !== r && t.push("".concat(n, "=").concat(encodeURIComponent(r))); + } + return t.join("&"); + }; + function g(e) { + return (e.requiredFields || []).concat(["pid"]).some(function (t) { + return void 0 === e[t]; + }); + } + function h() { + var e = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "", + t = arguments.length > 1 ? arguments[1] : void 0; + "undefined" != typeof console && console.warn("日志解析报错,埋点将被丢弃 => ".concat(e), t); + } + var m = "AEM_TRACKER_UNIQUE_PVID", + b = + "undefined" != typeof globalThis && globalThis + ? globalThis + : "undefined" != typeof window && window + ? window + : "undefined" != typeof global && global + ? global + : "undefined" != typeof self && self + ? self + : (console.error("Unable to locate global object in current environment"), {}); + function y(e) { + ((this._queue = []), + (this._reqQueue = []), + (this._plugins = {}), + (this._subscribers = { onConfigUpdated: [] }), + (this._timeout = 0), + (this._config = { + sdk_version: "3.3.18", + set pv_id(e) { + b[m] = e; + }, + get pv_id() { + return (b[m] || (b[m] = u()), b[m]); + }, + timezone_offset: new Date().getTimezoneOffset(), + }), + e && (this._config = p(this._config, e))); + } + y.prototype = { + constructor: y, + _sendAll: function () { + if ( + (this._timeout && (clearTimeout(this._timeout), (this._timeout = 0)), this._queue.length) + ) { + var e, + t = this._config.maxUrlLength || 3e4, + n = this._getSendConfig(); + try { + e = this._processData(this._queue, n); + } catch (e) {} + if (e && e.length < t) return ((this._queue = []), void this.send(e)); + for (var r, o = []; this._queue.length; ) { + o.push(this._queue.shift()); + try { + r = this._processData(o, n); + } catch (e) { + var i = o.pop(); + h(e.message, i); + continue; + } + if (r.length > t) { + o.length > 1 && (this._queue.unshift(o.pop()), (r = this._processData(o, n))); + break; + } + } + (r && this.send(r), this._queue.length && this._sendAll()); + } + }, + _send: function (e, t) { + var n = this; + if (!1 === t) { + var r; + try { + r = this._processData([e]); + } catch (t) { + h(t.message, e); + } + r && this.send(r); + } else { + this._queue.push(e); + var o = this._config.mergeRequestInterval || 500; + this._timeout || + (this._timeout = setTimeout(function () { + n._sendAll(); + }, o)); + } + }, + _getSendConfig: function () { + var e = {}, + t = this._config; + for (var n in t) + "requiredFields" !== n && + "maxUrlLength" !== n && + "queueGlobalName" !== n && + "debug" !== n && + "excludeCrawlers" !== n && + "collectClientHints" !== n && + 0 !== n.indexOf("plugin") && + "" !== t[n] && + null !== t[n] && + void 0 !== t[n] && + (e[n] = d(t[n])); + return e; + }, + _processData: function (e, t) { + t = t || this._getSendConfig(); + var n = v(t); + return (n += + "&msg=" + + encodeURIComponent( + e + .map(function (e) { + return v(e); + }) + .join("|"), + )); + }, + setConfig: function (e, t) { + var n = this, + r = {}; + void 0 !== t ? (r[e] = t) : (r = e); + var o = !(function e(t, n) { + if (void 0 === t || void 0 === n) return !1; + if (!l(t) || !l(n)) return !1; + for (var r in t) + if (l(t[r])) { + if (!e(t[r], n[r])) return !1; + } else if (t[r] !== n[r]) return !1; + return !0; + })(r, this._config), + i = function () { + if (o) { + for (var e in r) + l(r[e]) ? (n._config[e] = p(n._config[e] || {}, r[e])) : (n._config[e] = r[e]); + n._execSubscribe("onConfigUpdated", [r, n._config]); + } + }; + this._reqQueue.length + ? (i(), + g(this._config) || + (this._reqQueue.forEach(function (e) { + n._send.apply(n, e); + }), + (this._reqQueue = []))) + : (o && this._sendAll(), i()); + }, + getConfig: function (e) { + return e ? this._config[e] : this._config; + }, + updatePVID: (function (e, t) { + if ("function" != typeof e) throw new TypeError("Expected a function"); + t = "number" == typeof t && t >= 0 ? t : 100; + var n = null; + return function () { + if (null === n) { + var r = this, + o = Array.prototype.slice.call(arguments); + ((n = setTimeout(function () { + n = null; + }, t)), + e.apply(r, o)); + } + }; + })(function () { + b[m] = u(); + }, 200), + log: function (e) { + var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, + n = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}; + e && + ((t.ts = t.ts || new Date().getTime()), + (t.type = e), + this._print("log", e, t), + (t = f(t)), + g(this._config) + ? this._reqQueue.length < 1e3 && this._reqQueue.push([t, n.combo]) + : this._send(t, n.combo)); + }, + before: function (e, t) { + var n = this; + return function () { + var r = arguments, + o = t.apply(n, r); + o !== c && + (s(o) + ? o.then(function (t) { + t !== c && e.apply(n, t || r); + }) + : e.apply(n, o || r)); + }; + }, + after: function (e, t) { + var n = this; + return function () { + var r = arguments; + (e.apply(n, r), t.apply(n, r)); + }; + }, + use: function (e, t) { + var n = this; + return "[object Array]" === Object.prototype.toString.call(e) + ? e.map(function (e) { + if ("[object Array]" === Object.prototype.toString.call(e)) { + var t = e[0], + r = e[1]; + return n._plugins[t] || (n._plugins[t] = new t(n, r)); + } + return n._plugins[e] || (n._plugins[e] = new e(n)); + }) + : this._plugins[e] || (this._plugins[e] = new e(this, t)); + }, + _print: function () { + this._config.debug && + "undefined" != typeof console && + console.log.apply(console, arguments); + }, + onConfigUpdated: function (e) { + this._subscribers.onConfigUpdated && this._subscribers.onConfigUpdated.push(e); + }, + _execSubscribe: function (e, t) { + this._subscribers[e] && + this._subscribers[e].forEach(function (e) { + e.apply(this, t); + }); + }, + }; + var _ = y, + B = n(3), + S = n.n(B), + T = n(4), + j = n(5), + N = n.n(j); + function w(e) { + return (w = + "function" == typeof Symbol && "symbol" == typeof Symbol.iterator + ? function (e) { + return typeof e; + } + : function (e) { + return e && + "function" == typeof Symbol && + e.constructor === Symbol && + e !== Symbol.prototype + ? "symbol" + : typeof e; + })(e); + } + function O(e, t) { + var n = Object.keys(e); + if (Object.getOwnPropertySymbols) { + var r = Object.getOwnPropertySymbols(e); + (t && + (r = r.filter(function (t) { + return Object.getOwnPropertyDescriptor(e, t).enumerable; + })), + n.push.apply(n, r)); + } + return n; + } + function V(e) { + for (var t = 1; t < arguments.length; t++) { + var n = null != arguments[t] ? arguments[t] : {}; + t % 2 + ? O(Object(n), !0).forEach(function (t) { + A(e, t, n[t]); + }) + : Object.getOwnPropertyDescriptors + ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) + : O(Object(n)).forEach(function (t) { + Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t)); + }); + } + return e; + } + function A(e, t, n) { + return ( + (t = (function (e) { + var t = (function (e, t) { + if ("object" != w(e) || !e) return e; + var n = e[Symbol.toPrimitive]; + if (void 0 !== n) { + var r = n.call(e, t || "default"); + if ("object" != w(r)) return r; + throw new TypeError("@@toPrimitive must return a primitive value."); + } + return ("string" === t ? String : Number)(e); + })(e, "string"); + return "symbol" == w(t) ? t : t + ""; + })(t)) in e + ? Object.defineProperty(e, t, { + value: n, + enumerable: !0, + configurable: !0, + writable: !0, + }) + : (e[t] = n), + e + ); + } + function D(e, t) { + var n = ("undefined" != typeof Symbol && e[Symbol.iterator]) || e["@@iterator"]; + if (!n) { + if (Array.isArray(e) || (n = P(e)) || (t && e && "number" == typeof e.length)) { + n && (e = n); + var r = 0, + o = function () {}; + return { + s: o, + n: function () { + return r >= e.length ? { done: !0 } : { done: !1, value: e[r++] }; + }, + e: function (e) { + throw e; + }, + f: o, + }; + } + throw new TypeError( + "Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.", + ); + } + var i, + a = !0, + u = !1; + return { + s: function () { + n = n.call(e); + }, + n: function () { + var e = n.next(); + return ((a = e.done), e); + }, + e: function (e) { + ((u = !0), (i = e)); + }, + f: function () { + try { + a || null == n.return || n.return(); + } finally { + if (u) throw i; + } + }, + }; + } + function k(e, t) { + return ( + (function (e) { + if (Array.isArray(e)) return e; + })(e) || + (function (e, t) { + var n = + null == e + ? null + : ("undefined" != typeof Symbol && e[Symbol.iterator]) || e["@@iterator"]; + if (null != n) { + var r, + o, + i, + a, + u = [], + p = !0, + l = !1; + try { + if (((i = (n = n.call(e)).next), 0 === t)) { + if (Object(n) !== n) return; + p = !1; + } else + for (; !(p = (r = i.call(n)).done) && (u.push(r.value), u.length !== t); p = !0); + } catch (e) { + ((l = !0), (o = e)); + } finally { + try { + if (!p && null != n.return && ((a = n.return()), Object(a) !== a)) return; + } finally { + if (l) throw o; + } + } + return u; + } + })(e, t) || + P(e, t) || + (function () { + throw new TypeError( + "Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.", + ); + })() + ); + } + function P(e, t) { + if (e) { + if ("string" == typeof e) return C(e, t); + var n = {}.toString.call(e).slice(8, -1); + return ( + "Object" === n && e.constructor && (n = e.constructor.name), + "Map" === n || "Set" === n + ? Array.from(e) + : "Arguments" === n || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) + ? C(e, t) + : void 0 + ); + } + } + function C(e, t) { + (null == t || t > e.length) && (t = e.length); + for (var n = 0, r = Array(t); n < t; n++) r[n] = e[n]; + return r; + } + function E() { + for ( + var e = /(?:[0]{1,2}[:-]){5}[0]{1,2}/, + t = o.a.networkInterfaces(), + n = 0, + r = Object.entries(t); + n < r.length; + n++ + ) { + var i = k(r[n], 2), + a = (i[0], i[1]); + if (a) { + var u, + p = D(a); + try { + for (p.s(); !(u = p.n()).done; ) { + var l = u.value; + if (!1 === e.test(l.mac)) return l.mac; + } + } catch (e) { + p.e(e); + } finally { + p.f(); + } + } + } + return "00:00:00:00:00:00"; + } + var x, + M, + q = + ((x = process.version), + { + os: o.a.type(), + os_version: o.a.release(), + app_name: "node", + app_version: x, + device_id: N.a.createHash("md5").update(E()).digest("hex"), + platform: "node", + }), + I = Object(T.promisify)(S.a.resolve); + function H(e) { + ((this._offlineQueue = []), + (e.endpoint = e.endpoint || "gm.mmstat.com"), + _.call(this, V(V({}, q), e)), + (this._config.endpoint_url = "https://".concat(this._config.endpoint).concat("/aes.1.1"))); + } + ((H.prototype = (((M = function () {}).prototype = _.prototype), new M())), + (H.prototype.constructor = H), + (H.prototype.send = function (e) { + var t, + n = this; + return ((t = this._config.endpoint), I(t)) + .then(function (t) { + return ( + n._offlineQueue.forEach(function (e) { + n.send(e); + }), + (n._offlineQueue = []), + n._print("send", e), + a()(n._config.endpoint_url, { + method: "POST", + keepalive: true, + body: JSON.stringify({ gokey: encodeURIComponent(e), gmkey: "EXP" }), + }).catch(function (e) { + console.warn("send fail", e); + }) + ); + }) + .catch(function (t) { + (n._offlineQueue.length > 500 && n._offlineQueue.shift(), n._offlineQueue.push(e)); + }); + })); + t.default = H; + }, +]).default; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..3067ca1 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,47 @@ +{ + "name": "bailian-cli-core", + "version": "1.1.0", + "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", + "homepage": "https://bailian.console.aliyun.com/cli", + "bugs": { + "url": "https://github.com/modelstudioai/cli/issues" + }, + "license": "Apache-2.0", + "author": "Aliyun Model Studio", + "repository": { + "type": "git", + "url": "git+https://github.com/modelstudioai/cli.git", + "directory": "packages/core" + }, + "files": [ + "dist" + ], + "type": "module", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "vp pack", + "dev": "vp pack --watch", + "test": "vp test", + "check": "vp check" + }, + "dependencies": { + "yaml": "^2.8.3" + }, + "devDependencies": { + "@types/node": "catalog:", + "@typescript/native-preview": "7.0.0-dev.20260328.1", + "typescript": "^6.0.2", + "vite-plus": "catalog:" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/core/src/auth/credentials.ts b/packages/core/src/auth/credentials.ts new file mode 100644 index 0000000..b7c57ad --- /dev/null +++ b/packages/core/src/auth/credentials.ts @@ -0,0 +1,48 @@ +import { readFileSync, writeFileSync, renameSync, existsSync } from "fs"; +import { getConfigPath, ensureConfigDir } from "../config/index.ts"; + +export function loadApiKeyFromConfig(): string | null { + const path = getConfigPath(); + if (!existsSync(path)) return null; + + try { + const raw = readFileSync(path, "utf-8"); + const data = JSON.parse(raw) as Record; + if (typeof data.api_key === "string" && data.api_key.length > 0) { + return data.api_key; + } + return null; + } catch { + return null; + } +} + +export async function saveApiKeyToConfig(apiKey: string): Promise { + await ensureConfigDir(); + const path = getConfigPath(); + let existing: Record = {}; + try { + existing = JSON.parse(readFileSync(path, "utf-8")); + } catch { + /* ignore */ + } + existing.api_key = apiKey; + const tmp = path + ".tmp"; + writeFileSync(tmp, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); +} + +export async function clearApiKey(): Promise { + const path = getConfigPath(); + if (!existsSync(path)) return; + try { + const existing = JSON.parse(readFileSync(path, "utf-8")); + delete existing.api_key; + delete existing.access_token; + const tmp = path + ".tmp"; + writeFileSync(tmp, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); + } catch { + /* ignore */ + } +} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts new file mode 100644 index 0000000..eacb188 --- /dev/null +++ b/packages/core/src/auth/index.ts @@ -0,0 +1,7 @@ +export { clearApiKey, loadApiKeyFromConfig, saveApiKeyToConfig } from "./credentials.ts"; +export { + resolveCredential, + resolveConsoleGatewayCredential, + CONSOLE_GATEWAY_NO_TOKEN_MESSAGE, +} from "./resolver.ts"; +export type { AuthMethod, ResolvedCredential } from "./types.ts"; diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts new file mode 100644 index 0000000..4d005f8 --- /dev/null +++ b/packages/core/src/auth/resolver.ts @@ -0,0 +1,77 @@ +import type { Config } from "../config/schema.ts"; +import type { ResolvedCredential } from "./types.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +export async function resolveCredential(config: Config): Promise { + // 1. --api-key flag (explicit API key for this invocation) + if (config.apiKey) { + return { token: config.apiKey, method: "api-key", source: "flag" }; + } + + // 2. API key in config (DashScope sk-…); preferred over console token when both exist + if (config.fileApiKey) { + return { token: config.fileApiKey, method: "api-key", source: "config.json" }; + } + + // 3. access_token from env (temporary override) + if (config.accessTokenEnv) { + return { + token: config.accessTokenEnv, + method: "access-token", + source: "DASHSCOPE_ACCESS_TOKEN", + }; + } + + // 4. access_token from config (console callback) + if (config.fileAccessToken) { + return { + token: config.fileAccessToken, + method: "access-token", + source: "config.json", + }; + } + + // 5. API key from environment + if (process.env.DASHSCOPE_API_KEY) { + return { token: process.env.DASHSCOPE_API_KEY, method: "api-key", source: "DASHSCOPE_API_KEY" }; + } + + throw new BailianError( + "No credentials found.", + ExitCode.AUTH, + "Set DASHSCOPE_API_KEY environment variable, pass --api-key, or configure a key.", + ); +} + +/** + * Credential for Bailian **console** CLI gateway only (`callConsoleGateway`). + * DashScope API keys are not valid Bearer tokens for this gateway — use env/file + * `access_token` even when `api_key` is also present in config. + */ +/** Thrown when `callConsoleGateway` has no usable console session token. */ +export const CONSOLE_GATEWAY_NO_TOKEN_MESSAGE = "No console access token found."; + +export async function resolveConsoleGatewayCredential(config: Config): Promise { + if (config.accessTokenEnv) { + return { + token: config.accessTokenEnv, + method: "access-token", + source: "DASHSCOPE_ACCESS_TOKEN", + }; + } + + if (config.fileAccessToken) { + return { + token: config.fileAccessToken, + method: "access-token", + source: "config.json", + }; + } + + throw new BailianError( + CONSOLE_GATEWAY_NO_TOKEN_MESSAGE, + ExitCode.AUTH, + "Run `bl auth login --console` or set DASHSCOPE_ACCESS_TOKEN.", + ); +} diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts new file mode 100644 index 0000000..124a284 --- /dev/null +++ b/packages/core/src/auth/types.ts @@ -0,0 +1,7 @@ +export type AuthMethod = "api-key" | "access-token"; + +export interface ResolvedCredential { + token: string; + method: AuthMethod; + source: string; +} diff --git a/packages/core/src/client/ak-sign.ts b/packages/core/src/client/ak-sign.ts new file mode 100644 index 0000000..e9ed7be --- /dev/null +++ b/packages/core/src/client/ak-sign.ts @@ -0,0 +1,80 @@ +/** + * Alibaba Cloud V3 Signature (ROA style) for Bailian Cloud API. + * + * Used by Knowledge Base Retrieve API which requires AK/SK authentication + * instead of Bearer token. + * + * Reference: https://help.aliyun.com/document_detail/2712195.html + */ + +import { createHmac, createHash, randomUUID } from "crypto"; + +export interface AkSignConfig { + accessKeyId: string; + accessKeySecret: string; + action: string; + version: string; + body: string; + host: string; + pathname: string; + method?: string; +} + +export function signRequest(cfg: AkSignConfig): Record { + const method = cfg.method ?? "POST"; + const now = new Date(); + const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z"); + const nonce = randomUUID(); + + const hashedBody = sha256Hex(cfg.body); + + const headers: Record = { + host: cfg.host, + "x-acs-action": cfg.action, + "x-acs-version": cfg.version, + "x-acs-date": dateISO, + "x-acs-signature-nonce": nonce, + "x-acs-content-sha256": hashedBody, + "content-type": "application/json", + }; + + // Build canonical headers (sorted, lowercase) + const signedHeaderKeys = Object.keys(headers) + .filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-")) + .sort(); + + const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n"; + + const signedHeadersStr = signedHeaderKeys.join(";"); + + // Build canonical request + const canonicalRequest = [ + method, + cfg.pathname, + "", // query string (empty for POST) + canonicalHeaders, + signedHeadersStr, + hashedBody, + ].join("\n"); + + // Build string to sign + const algorithm = "ACS3-HMAC-SHA256"; + const hashedCanonical = sha256Hex(canonicalRequest); + const stringToSign = `${algorithm}\n${hashedCanonical}`; + + // Calculate signature + const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign); + + headers["authorization"] = + `${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`; + + return headers; +} + +function sha256Hex(data: string): string { + return createHash("sha256").update(data, "utf8").digest("hex"); +} + +function hmacSHA256Hex(key: string, data: string): string { + return createHmac("sha256", key).update(data, "utf8").digest("hex"); +} diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts new file mode 100644 index 0000000..fec9f0b --- /dev/null +++ b/packages/core/src/client/endpoints.ts @@ -0,0 +1,80 @@ +// ---- Chat (OpenAI Compatible) ---- + +export function chatEndpoint(baseUrl: string): string { + return `${baseUrl}/compatible-mode/v1/chat/completions`; +} + +// ---- Image Generation (DashScope) ---- + +export function imageEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v1/services/aigc/image-generation/generation`; +} + +// Synchronous image generation (qwen-image-2.0 / qwen-image-max series) +export function imageSyncEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v1/services/aigc/multimodal-generation/generation`; +} + +// ---- Video Generation (DashScope) ---- + +export function videoGenerateEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v1/services/aigc/video-generation/video-synthesis`; +} + +// ---- Async Task Query ---- + +export function taskEndpoint(baseUrl: string, taskId: string): string { + return `${baseUrl}/api/v1/tasks/${taskId}`; +} + +// ---- Application (Agent / Workflow) ---- + +export function appCompletionEndpoint(baseUrl: string, appId: string): string { + return `${baseUrl}/api/v1/apps/${appId}/completion`; +} + +// ---- Memory (DashScope v2) ---- + +export function memoryAddEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v2/apps/memory/add`; +} + +export function memorySearchEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v2/apps/memory/memory_nodes/search`; +} + +export function memoryListEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v2/apps/memory/memory_nodes`; +} + +export function memoryNodeEndpoint(baseUrl: string, nodeId: string): string { + return `${baseUrl}/api/v2/apps/memory/memory_nodes/${nodeId}`; +} + +// ---- Speech Synthesis (TTS) ---- + +export function speechSynthesizeEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v1/services/audio/tts/SpeechSynthesizer`; +} + +// ---- Speech Recognition (ASR) ---- + +export function speechRecognizeEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v1/services/audio/asr/transcription`; +} + +// ---- Memory Profile (DashScope v2) ---- + +export function profileSchemaEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v2/apps/memory/profile_schemas`; +} + +export function userProfileEndpoint(baseUrl: string, schemaId: string): string { + return `${baseUrl}/api/v2/apps/memory/profile_schemas/${schemaId}/profiles`; +} + +// ---- MCP Services (Streamable HTTP) ---- + +export function mcpWebSearchEndpoint(baseUrl: string): string { + return `${baseUrl}/api/v1/mcps/WebSearch/mcp`; +} diff --git a/packages/core/src/client/headers.ts b/packages/core/src/client/headers.ts new file mode 100644 index 0000000..d572950 --- /dev/null +++ b/packages/core/src/client/headers.ts @@ -0,0 +1,23 @@ +/** + * Shared HTTP request headers for all outgoing requests. + * + * Centralises the `x-dashscope-source-config` header so every fetch call + * (both via the central http client and the bypass paths) uses the + * same values from a single source of truth. + */ + +export const CHANNEL = "bailian-cli"; + +export const TAGS = { t1: "public", t2: "" }; + +export const SOURCE_CONFIG = JSON.stringify({ + channel: CHANNEL, + tags: TAGS, +}); + +/** Standard tracking headers required on every outbound request. */ +export function trackingHeaders(): Record { + return { + "x-dashscope-source-config": SOURCE_CONFIG, + }; +} diff --git a/packages/core/src/client/http.ts b/packages/core/src/client/http.ts new file mode 100644 index 0000000..1a30f30 --- /dev/null +++ b/packages/core/src/client/http.ts @@ -0,0 +1,144 @@ +import type { Config } from "../config/schema.ts"; +import type { ApiErrorBody } from "../errors/api.ts"; +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 { SOURCE_CONFIG, trackingHeaders } from "./headers.ts"; + +export interface RequestOpts { + url: string; + method?: string; + body?: unknown; + headers?: Record; + timeout?: number; + stream?: boolean; + noAuth?: boolean; + async?: boolean; // Add X-DashScope-Async: enable header + signal?: AbortSignal; +} + +/** + * Bailian requires `X-DashScope-OssResourceResolve: enable` on any request whose body + * references an `oss://` URL (returned by the upload API). Detected automatically here + * so callers don't need to track it manually. + */ +function bodyReferencesOssUrl(body: unknown): boolean { + if (body == null || typeof body !== "object") return false; + if (body instanceof FormData) return false; + return JSON.stringify(body).includes("oss://"); +} + +export async function request(config: Config, opts: RequestOpts): Promise { + const isFormData = typeof FormData !== "undefined" && opts.body instanceof FormData; + + const clientName = config.clientName ?? "bailian-cli-core"; + const version = config.clientVersion ?? "0.0.0-dev"; + const headers: Record = { + "User-Agent": `${clientName}/${version}`, + ...trackingHeaders(), + ...opts.headers, + }; + + if (!isFormData && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + if (opts.async) { + headers["X-DashScope-Async"] = "enable"; + } + + if (bodyReferencesOssUrl(opts.body)) { + headers["X-DashScope-OssResourceResolve"] = "enable"; + } + + if (!opts.noAuth) { + const credential = await resolveCredential(config); + headers["Authorization"] = `Bearer ${credential.token}`; + + if (config.verbose) { + console.error(`> ${opts.method ?? "GET"} ${opts.url}`); + console.error(`> Auth: ${credential.token.slice(0, 8)}...`); + console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`); + } + } + + const timeoutMs = (opts.timeout ?? config.timeout) * 1000; + + const requestSignal = createRequestSignal(timeoutMs, opts.signal); + const res = await fetch(opts.url, { + method: opts.method ?? "GET", + headers, + body: opts.body + ? isFormData + ? (opts.body as FormData) + : JSON.stringify(opts.body) + : undefined, + signal: requestSignal.signal, + }).finally(requestSignal.cleanup); + + if (config.verbose) { + console.error(`< ${res.status} ${res.statusText}`); + const reqId = res.headers.get("x-request-id"); + if (reqId) { + console.error(`request_id: ${reqId}`); + } + } + + if (!res.ok) { + let body: ApiErrorBody = {}; + try { + body = (await res.json()) as ApiErrorBody; + } catch { + /* non-JSON */ + } + throw mapApiError(res.status, body, opts.url); + } + + return res; +} + +function createRequestSignal( + timeoutMs: number, + parentSignal?: AbortSignal, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const abortFromParent = () => controller.abort(parentSignal?.reason); + const cleanup = () => { + clearTimeout(timeout); + parentSignal?.removeEventListener("abort", abortFromParent); + }; + + if (parentSignal?.aborted) abortFromParent(); + else parentSignal?.addEventListener("abort", abortFromParent, { once: true }); + controller.signal.addEventListener("abort", cleanup, { once: true }); + + return { signal: controller.signal, cleanup }; +} + +export async function requestJson(config: Config, opts: RequestOpts): Promise { + const res = await request(config, opts); + let data: T & { code?: string; message?: string; request_id?: string }; + try { + data = (await res.json()) as T & { code?: string; message?: string; request_id?: string }; + } catch { + const contentType = res.headers.get("content-type") || ""; + throw new BailianError( + `API returned non-JSON response (${contentType || "unknown type"}). Server may be experiencing issues.`, + ExitCode.GENERAL, + ); + } + + // DashScope error format: { code: "ErrorCode", message: "..." } + if ( + data.code && + typeof data.code === "string" && + data.code !== "200" && + data.code !== "Success" + ) { + throw mapApiError(200, { error: { message: data.message, type: data.code } }, opts.url); + } + + return data; +} diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts new file mode 100644 index 0000000..016efef --- /dev/null +++ b/packages/core/src/client/index.ts @@ -0,0 +1,26 @@ +export type { AkSignConfig } from "./ak-sign.ts"; +export { signRequest } from "./ak-sign.ts"; +export { + appCompletionEndpoint, + chatEndpoint, + imageEndpoint, + imageSyncEndpoint, + memoryAddEndpoint, + memoryListEndpoint, + memoryNodeEndpoint, + memorySearchEndpoint, + mcpWebSearchEndpoint, + profileSchemaEndpoint, + speechRecognizeEndpoint, + speechSynthesizeEndpoint, + taskEndpoint, + userProfileEndpoint, + videoGenerateEndpoint, +} from "./endpoints.ts"; +export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts"; +export type { RequestOpts } from "./http.ts"; +export { request, requestJson } from "./http.ts"; +export type { McpTool, McpToolResult } from "./mcp.ts"; +export { McpClient } from "./mcp.ts"; +export type { ServerSentEvent } from "./stream.ts"; +export { parseSSE } from "./stream.ts"; diff --git a/packages/core/src/client/mcp.ts b/packages/core/src/client/mcp.ts new file mode 100644 index 0000000..330ed1a --- /dev/null +++ b/packages/core/src/client/mcp.ts @@ -0,0 +1,189 @@ +/** + * MCP (Model Context Protocol) streamable HTTP client. + * + * Implements the JSON-RPC 2.0 based MCP protocol over streamable HTTP transport. + * Used by DashScope MCP services like WebSearch. + * + * Protocol flow: initialize → tools/list → tools/call + */ + +import type { Config } from "../config/schema.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import { resolveCredential } from "../auth/resolver.ts"; +import { trackingHeaders } from "./headers.ts"; + +// ---- JSON-RPC 2.0 Types ---- + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +// ---- MCP Tool Types ---- + +export interface McpTool { + name: string; + description?: string; + inputSchema?: Record; +} + +export interface McpToolResult { + content: Array<{ + type: string; + text?: string; + data?: string; + mimeType?: string; + }>; + isError?: boolean; +} + +// ---- MCP Client ---- + +export class McpClient { + private baseUrl: string; + private sessionId: string | undefined; + private nextId = 1; + private config: Config; + private authToken: string | undefined; + + constructor(config: Config, baseUrl: string) { + this.config = config; + this.baseUrl = baseUrl; + } + + /** + * Initialize the MCP session. Must be called before any other method. + */ + async initialize(): Promise { + const credential = await resolveCredential(this.config); + this.authToken = credential.token; + + const result = await this.rpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: this.config.clientName ?? "bailian-cli-core", + version: this.config.clientVersion ?? "0.0.0-dev", + }, + }); + + if (this.config.verbose) { + console.error(`[MCP] Session initialized: ${this.sessionId ?? "no session"}`); + console.error(`[MCP] Server: ${JSON.stringify(result)}`); + } + + // Send initialized notification (no id = notification) + await this.notify("notifications/initialized"); + } + + /** + * List available tools from the MCP server. + */ + async listTools(): Promise { + const result = (await this.rpc("tools/list")) as { tools: McpTool[] }; + return result.tools || []; + } + + /** + * Call a tool on the MCP server. + */ + async callTool(name: string, args: Record): Promise { + const result = (await this.rpc("tools/call", { name, arguments: args })) as McpToolResult; + return result; + } + + // ---- Internal Methods ---- + + private async rpc(method: string, params?: Record): Promise { + const id = this.nextId++; + const body: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + ...(params ? { params } : {}), + }; + + const response = await this.send(body); + const data = (await response.json()) as JsonRpcResponse; + + if (data.error) { + throw new BailianError( + `MCP error (${data.error.code}): ${data.error.message}`, + ExitCode.GENERAL, + ); + } + + return data.result; + } + + private async notify(method: string, params?: Record): Promise { + const body = { + jsonrpc: "2.0" as const, + method, + ...(params ? { params } : {}), + }; + + await this.send(body); + } + + private async send(body: unknown): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "User-Agent": `${this.config.clientName ?? "bailian-cli-core"}/${this.config.clientVersion ?? "0.0.0-dev"}`, + ...trackingHeaders(), + }; + + if (this.authToken) { + headers["Authorization"] = `Bearer ${this.authToken}`; + } + + if (this.sessionId) { + headers["Mcp-Session-Id"] = this.sessionId; + } + + if (this.config.verbose) { + console.error(`> POST ${this.baseUrl}`); + console.error(`> Method: ${(body as { method?: string }).method}`); + } + + const timeoutMs = this.config.timeout * 1000; + const res = await fetch(this.baseUrl, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (this.config.verbose) { + console.error(`< ${res.status} ${res.statusText}`); + } + + // Capture session ID from response + const sid = res.headers.get("Mcp-Session-Id") || res.headers.get("mcp-session-id"); + if (sid) this.sessionId = sid; + + if (!res.ok) { + let errMsg = `MCP request failed: ${res.status} ${res.statusText}`; + try { + const errBody = await res.text(); + if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`; + } catch { + /* ignore */ + } + throw new BailianError(errMsg, ExitCode.GENERAL); + } + + return res; + } +} diff --git a/packages/core/src/client/stream.ts b/packages/core/src/client/stream.ts new file mode 100644 index 0000000..56904aa --- /dev/null +++ b/packages/core/src/client/stream.ts @@ -0,0 +1,67 @@ +export interface ServerSentEvent { + event?: string; + data: string; + id?: string; +} + +export async function* parseSSE(response: Response): AsyncGenerator { + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + let event: Partial = {}; + + for (const line of lines) { + if (line === "") { + if (event.data !== undefined) { + yield { data: event.data, event: event.event, id: event.id }; + } + event = {}; + continue; + } + + if (line.startsWith(":")) continue; // comment + + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + + const field = line.slice(0, colonIndex); + const value = line.slice(colonIndex + 1).trimStart(); + + switch (field) { + case "data": + event.data = event.data !== undefined ? `${event.data}\n${value}` : value; + break; + case "event": + event.event = value; + break; + case "id": + event.id = value; + break; + } + } + } + + // Flush remaining + if (buffer.trim() && buffer.includes("data:")) { + const colonIndex = buffer.indexOf(":"); + if (colonIndex !== -1) { + yield { data: buffer.slice(colonIndex + 1).trimStart() }; + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts new file mode 100644 index 0000000..6ba0f83 --- /dev/null +++ b/packages/core/src/config/index.ts @@ -0,0 +1,4 @@ +export type { Config, ConfigFile, Region } from "./schema.ts"; +export { BAILIAN_HOST, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts"; +export { loadConfig, readConfigFile, writeConfigFile } from "./loader.ts"; +export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts new file mode 100644 index 0000000..30ce611 --- /dev/null +++ b/packages/core/src/config/loader.ts @@ -0,0 +1,100 @@ +import { readFileSync, writeFileSync, renameSync, existsSync } from "fs"; +import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from "./schema.ts"; +import { ensureConfigDir, getConfigPath } from "./paths.ts"; +import { detectOutputFormat, type OutputFormat } from "../output/formatter.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import type { GlobalFlags } from "../types/flags.ts"; + +export function readConfigFile(): ConfigFile { + const path = getConfigPath(); + if (!existsSync(path)) return {}; + try { + return parseConfigFile(JSON.parse(readFileSync(path, "utf-8"))); + } catch (err) { + const e = err as Error; + if (e instanceof SyntaxError || e.message.includes("JSON")) { + console.warn("Warning: config file is corrupted; using defaults."); + } + return {}; + } +} + +export async function writeConfigFile(data: Record): Promise { + await ensureConfigDir(); + const path = getConfigPath(); + const tmp = path + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); +} + +export function loadConfig(flags: GlobalFlags): Config { + const file = readConfigFile(); + + const apiKey = flags.apiKey || undefined; + const fileApiKey = file.api_key; + const accessTokenEnv = process.env.DASHSCOPE_ACCESS_TOKEN?.trim() || undefined; + const fileAccessToken = file.access_token?.trim() || undefined; + + const explicitRegion = (flags.region as string) || process.env.DASHSCOPE_REGION || undefined; + const cachedRegion = file.region; + const region = (explicitRegion || cachedRegion || "cn") as Region; + + const baseUrl = + flags.baseUrl || + process.env.DASHSCOPE_BASE_URL || + file.base_url || + REGIONS[region] || + REGIONS.cn; + + const output: OutputFormat = detectOutputFormat( + flags.output || process.env.DASHSCOPE_OUTPUT || file.output, + ); + + const envTimeout = process.env.DASHSCOPE_TIMEOUT + ? Number(process.env.DASHSCOPE_TIMEOUT) + : undefined; + const validEnvTimeout = + envTimeout !== undefined && Number.isFinite(envTimeout) && envTimeout > 0 + ? envTimeout + : undefined; + const timeout = flags.timeout ?? validEnvTimeout ?? file.timeout ?? 300; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new BailianError("Timeout must be a positive finite number.", ExitCode.USAGE); + } + + return { + apiKey, + accessTokenEnv, + fileAccessToken, + fileApiKey, + fileRegion: file.region, + configPath: getConfigPath(), + region, + baseUrl, + output, + outputDir: file.output_dir || undefined, + timeout, + defaultTextModel: file.default_text_model, + defaultVideoModel: file.default_video_model, + defaultImageModel: file.default_image_model, + defaultSpeechModel: file.default_speech_model, + defaultOmniModel: file.default_omni_model, + accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID || file.access_key_id || undefined, + accessKeySecret: + process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET || file.access_key_secret || undefined, + workspaceId: process.env.BAILIAN_WORKSPACE_ID || file.workspace_id || undefined, + consoleGatewayUrl: + process.env.BAILIAN_CONSOLE_GATEWAY_URL || + file.console_gateway_url || + "https://bailian-cs.console.aliyun.com", + verbose: flags.verbose || process.env.DASHSCOPE_VERBOSE === "1", + quiet: flags.quiet || false, + noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY, + yes: flags.yes || false, + dryRun: flags.dryRun || false, + nonInteractive: flags.nonInteractive || false, + async: flags.async || false, + telemetry: process.env.DO_NOT_TRACK === "1" ? false : (file.telemetry ?? true), + }; +} diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts new file mode 100644 index 0000000..9e0de01 --- /dev/null +++ b/packages/core/src/config/paths.ts @@ -0,0 +1,23 @@ +import { homedir } from "os"; +import { join } from "path"; + +const CONFIG_DIR_NAME = ".bailian"; + +export function getConfigDir(): string { + if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR; + return join(homedir(), CONFIG_DIR_NAME); +} + +export function getConfigPath(): string { + return join(getConfigDir(), "config.json"); +} + +export function getCredentialsPath(): string { + return join(getConfigDir(), "credentials.json"); +} + +export async function ensureConfigDir(): Promise { + const dir = getConfigDir(); + const fs = await import("fs/promises"); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); +} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts new file mode 100644 index 0000000..c568a2e --- /dev/null +++ b/packages/core/src/config/schema.ts @@ -0,0 +1,116 @@ +export const REGIONS = { + cn: "https://dashscope.aliyuncs.com", + us: "https://dashscope-us.aliyuncs.com", + intl: "https://dashscope-intl.aliyuncs.com", +} as const; + +export const DOCS_HOSTS = { + cn: "https://help.aliyun.com/zh/model-studio", + us: "https://help.aliyun.com/zh/model-studio", + intl: "https://help.aliyun.com/zh/model-studio", +} as const; + +export const BAILIAN_HOST = "https://bailian.cn-beijing.aliyuncs.com"; + +export type Region = keyof typeof REGIONS; + +export interface ConfigFile { + api_key?: string; + /** OAuth-style token from `bl auth login --console` callback; sent as `Authorization: Bearer …` */ + access_token?: string; + region?: Region; + base_url?: string; + output?: "text" | "json"; + output_dir?: string; + timeout?: number; + default_text_model?: string; + default_video_model?: string; + default_image_model?: string; + default_speech_model?: string; + default_omni_model?: string; + access_key_id?: string; + access_key_secret?: string; + workspace_id?: string; + console_gateway_url?: string; + telemetry?: boolean; +} + +const VALID_REGIONS = new Set(["cn", "us", "intl"]); +const VALID_OUTPUTS = new Set(["text", "json"]); + +export function parseConfigFile(raw: unknown): ConfigFile { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const obj = raw as Record; + const out: ConfigFile = {}; + + if (typeof obj.api_key === "string") out.api_key = obj.api_key; + if (typeof obj.access_token === "string" && obj.access_token.length > 0) + out.access_token = obj.access_token; + else if (typeof obj.accessToken === "string" && obj.accessToken.length > 0) + 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.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) + out.output_dir = obj.output_dir; + if (typeof obj.timeout === "number" && obj.timeout > 0) out.timeout = obj.timeout; + if (typeof obj.default_text_model === "string" && obj.default_text_model.length > 0) + out.default_text_model = obj.default_text_model; + if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0) + out.default_video_model = obj.default_video_model; + if (typeof obj.default_image_model === "string" && obj.default_image_model.length > 0) + out.default_image_model = obj.default_image_model; + if (typeof obj.default_speech_model === "string" && obj.default_speech_model.length > 0) + out.default_speech_model = obj.default_speech_model; + if (typeof obj.default_omni_model === "string" && obj.default_omni_model.length > 0) + out.default_omni_model = obj.default_omni_model; + if (typeof obj.access_key_id === "string" && obj.access_key_id.length > 0) + out.access_key_id = obj.access_key_id; + if (typeof obj.access_key_secret === "string" && obj.access_key_secret.length > 0) + 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")) + out.console_gateway_url = obj.console_gateway_url; + if (typeof obj.telemetry === "boolean") out.telemetry = obj.telemetry; + + return out; +} + +export interface Config { + clientName?: string; + clientVersion?: string; + apiKey?: string; + /** `DASHSCOPE_ACCESS_TOKEN` env (explicit override). */ + accessTokenEnv?: string; + /** `access_token` in config file (console login). */ + fileAccessToken?: string; + fileApiKey?: string; + fileRegion?: Region; + configPath?: string; + region: Region; + baseUrl: string; + output: "text" | "json"; + outputDir?: string; + timeout: number; + defaultTextModel?: string; + defaultVideoModel?: string; + defaultImageModel?: string; + defaultSpeechModel?: string; + defaultOmniModel?: string; + accessKeyId?: string; + accessKeySecret?: string; + workspaceId?: string; + consoleGatewayUrl: string; + verbose: boolean; + quiet: boolean; + noColor: boolean; + yes: boolean; + dryRun: boolean; + nonInteractive: boolean; + async: boolean; + telemetry: boolean; +} diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts new file mode 100644 index 0000000..f447c35 --- /dev/null +++ b/packages/core/src/console/gateway.ts @@ -0,0 +1,78 @@ +import type { Config } from "../config/schema.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +const GATEWAY_ACTION = "BroadScopeAspnGateway"; +const GATEWAY_PRODUCT = "sfm_bailian"; + +export interface ConsoleGatewayRequest { + /** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */ + api: string; + data: Record; + /** Console region (default: cn-beijing), distinct from DashScope `config.region`. */ + region?: string; +} + +function buildGatewayParams(api: string, data: Record): string { + return JSON.stringify({ + Api: api, + V: "1.0", + Data: { + ...data, + cornerstoneParam: { + protocol: "V2", + console: "ONE_CONSOLE", + productCode: "p_efm", + consoleSite: "BAILIAN_ALIYUN", + ...(typeof data.cornerstoneParam === "object" && data.cornerstoneParam !== null + ? (data.cornerstoneParam as Record) + : {}), + }, + }, + }); +} + +/** + * Invoke a Bailian **console** OpenAPI via the CLI gateway (`/cli/api.json`). + * `token` is the console `access_token` (from `bl auth login --console`); when + * omitted the request is sent without an Authorization header, which works for + * public console APIs that don't require a login session. + */ +export async function callConsoleGateway( + config: Config, + token: string | undefined, + { api, data, region = "cn-beijing" }: ConsoleGatewayRequest, +): Promise { + const params = buildGatewayParams(api, data); + const body = new URLSearchParams({ params, region }); + const timeoutMs = config.timeout * 1000; + + const gatewayBase = config.consoleGatewayUrl; + + const headers: Record = { + Accept: "*/*", + "Content-Type": "application/x-www-form-urlencoded", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch( + `${gatewayBase}/cli/api.json?action=${GATEWAY_ACTION}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`, + { + method: "POST", + headers, + body: body.toString(), + signal: AbortSignal.timeout(timeoutMs), + }, + ); + + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new BailianError( + `Console CLI gateway failed: HTTP ${res.status} ${res.statusText}`, + ExitCode.GENERAL, + t.slice(0, 500), + ); + } + + return res.json() as Promise; +} diff --git a/packages/core/src/console/index.ts b/packages/core/src/console/index.ts new file mode 100644 index 0000000..861e868 --- /dev/null +++ b/packages/core/src/console/index.ts @@ -0,0 +1,2 @@ +export type { ConsoleGatewayRequest } from "./gateway.ts"; +export { callConsoleGateway } from "./gateway.ts"; diff --git a/packages/core/src/errors/api.ts b/packages/core/src/errors/api.ts new file mode 100644 index 0000000..2a682bb --- /dev/null +++ b/packages/core/src/errors/api.ts @@ -0,0 +1,32 @@ +import { BailianError } from "./base.ts"; +import { ExitCode } from "./codes.ts"; + +export interface ApiErrorBody { + error?: { + message?: string; + type?: string; + code?: number | string; + }; + code?: string; + message?: string; + request_id?: string; +} + +export function mapApiError(status: number, body: ApiErrorBody, _url?: string): BailianError { + const apiMsg = body.error?.message || body.message || `HTTP ${status}`; + const rawCode = body.error?.type ?? body.code; + const apiCode = + typeof rawCode === "string" + ? rawCode + : typeof rawCode === "number" + ? String(rawCode) + : undefined; + + return new BailianError(apiMsg, ExitCode.GENERAL, undefined, { + api: { + httpStatus: status, + apiCode, + requestId: body.request_id, + }, + }); +} diff --git a/packages/core/src/errors/base.ts b/packages/core/src/errors/base.ts new file mode 100644 index 0000000..ebaaa4c --- /dev/null +++ b/packages/core/src/errors/base.ts @@ -0,0 +1,64 @@ +import { ExitCode } from "./codes.ts"; + +export interface ApiErrorContext { + httpStatus?: number; + apiCode?: string; + requestId?: string; +} + +export interface BailianErrorOptions { + cause?: unknown; + api?: ApiErrorContext; +} + +export class BailianError extends Error { + readonly exitCode: ExitCode; + readonly hint?: string; + readonly api?: ApiErrorContext; + + constructor( + message: string, + exitCode: ExitCode = ExitCode.GENERAL, + hint?: string, + options?: BailianErrorOptions, + ) { + super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); + this.name = "BailianError"; + this.exitCode = exitCode; + this.hint = hint; + this.api = options?.api; + } + + toJSON() { + const causeJson = serializeCause(this.cause); + return { + error: { + code: this.exitCode, + message: this.message, + ...(this.hint ? { hint: this.hint } : {}), + ...(this.api?.httpStatus !== undefined ? { http_status: this.api.httpStatus } : {}), + ...(this.api?.apiCode ? { api_code: this.api.apiCode } : {}), + ...(this.api?.requestId ? { request_id: this.api.requestId } : {}), + ...(causeJson ? { cause: causeJson } : {}), + }, + }; + } +} + +function serializeCause(cause: unknown): Record | undefined { + if (cause == null) return undefined; + if (cause instanceof Error) { + const out: Record = { message: cause.message }; + const code = (cause as NodeJS.ErrnoException).code; + if (code) out.code = code; + return out; + } + if (typeof cause === "string" || typeof cause === "number" || typeof cause === "boolean") { + return { message: String(cause) }; + } + try { + return { message: JSON.stringify(cause) }; + } catch { + return undefined; + } +} diff --git a/packages/core/src/errors/codes.ts b/packages/core/src/errors/codes.ts new file mode 100644 index 0000000..83e4f8b --- /dev/null +++ b/packages/core/src/errors/codes.ts @@ -0,0 +1,12 @@ +export const ExitCode = { + SUCCESS: 0, + GENERAL: 1, + USAGE: 2, + AUTH: 3, + QUOTA: 4, + TIMEOUT: 5, + NETWORK: 6, + CONTENT_FILTER: 10, +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts new file mode 100644 index 0000000..a2931ae --- /dev/null +++ b/packages/core/src/files/index.ts @@ -0,0 +1 @@ +export { uploadFile, isLocalFile, resolveFileUrl } from "./upload.ts"; diff --git a/packages/core/src/files/upload.ts b/packages/core/src/files/upload.ts new file mode 100644 index 0000000..c5ecdb0 --- /dev/null +++ b/packages/core/src/files/upload.ts @@ -0,0 +1,176 @@ +/** + * Upload local files to DashScope temporary OSS storage. + * + * Returns an `oss://` prefixed URL valid for 48 hours. + * When using this URL in API calls, the request MUST include: + * X-DashScope-OssResourceResolve: enable + */ +import { existsSync, readFileSync, statSync } from "fs"; +import { basename } from "path"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import { trackingHeaders } from "../client/headers.ts"; +import { REGIONS } from "../config/schema.ts"; + +// Pinned to cn region; thread baseUrl through if overseas upload becomes a requirement. +const UPLOAD_API = `${REGIONS.cn}/api/v1/uploads`; + +interface UploadPolicy { + upload_host: string; + upload_dir: string; + oss_access_key_id: string; + signature: string; + policy: string; + x_oss_object_acl: string; + x_oss_forbid_overwrite: string; +} + +interface UploadPolicyResponse { + data: UploadPolicy; + request_id?: string; +} + +/** + * Step 1: Fetch the upload policy (presigned credentials) from DashScope. + */ +async function getUploadPolicy( + apiKey: string, + model: string, + signal?: AbortSignal, +): Promise { + const url = `${UPLOAD_API}?action=getPolicy&model=${encodeURIComponent(model)}`; + const policySignal = combineWithTimeout(15_000, signal); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + ...trackingHeaders(), + }, + signal: policySignal.signal, + }).finally(policySignal.cleanup); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new BailianError( + `Failed to get upload policy (HTTP ${res.status}): ${text}`, + ExitCode.GENERAL, + ); + } + + const body = (await res.json()) as UploadPolicyResponse; + return body.data; +} + +/** + * Step 2: Upload the file to OSS using the policy. + */ +async function uploadToOSS( + policy: UploadPolicy, + filePath: string, + signal?: AbortSignal, +): Promise { + const fileName = basename(filePath); + const key = `${policy.upload_dir}/${fileName}`; + + const fileData = readFileSync(filePath); + + const form = new FormData(); + form.append("OSSAccessKeyId", policy.oss_access_key_id); + form.append("Signature", policy.signature); + form.append("policy", policy.policy); + form.append("x-oss-object-acl", policy.x_oss_object_acl); + form.append("x-oss-forbid-overwrite", policy.x_oss_forbid_overwrite); + form.append("key", key); + form.append("success_action_status", "200"); + form.append("file", new Blob([fileData]), fileName); + + const uploadSignal = combineWithTimeout(120_000, signal); + const res = await fetch(policy.upload_host, { + method: "POST", + headers: { + ...trackingHeaders(), + }, + body: form, + signal: uploadSignal.signal, + }).finally(uploadSignal.cleanup); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new BailianError( + `Failed to upload file to OSS (HTTP ${res.status}): ${text}`, + ExitCode.GENERAL, + ); + } + + return `oss://${key}`; +} + +export interface UploadOptions { + apiKey: string; + model: string; + filePath: string; + signal?: AbortSignal; +} + +/** + * Upload a local file to DashScope temporary storage and return the oss:// URL. + * The URL is valid for 48 hours. + */ +export async function uploadFile(opts: UploadOptions): Promise { + const { apiKey, model, filePath, signal } = opts; + + if (!existsSync(filePath)) { + throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE); + } + + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE); + } + + const policy = await getUploadPolicy(apiKey, model, signal); + return uploadToOSS(policy, filePath, signal); +} + +/** + * Check if a string looks like a local file path (not a URL). + */ +export function isLocalFile(input: string): boolean { + if (input.startsWith("http://") || input.startsWith("https://")) return false; + if (input.startsWith("oss://")) return false; + if (input.startsWith("data:")) return false; + return existsSync(input); +} + +/** + * Resolve a file argument: if it's a local path, upload it and return the oss:// URL. + * If it's already a URL, return as-is. + */ +export async function resolveFileUrl( + input: string, + apiKey: string, + model: string, + opts: { signal?: AbortSignal } = {}, +): Promise { + if (!isLocalFile(input)) return input; + return uploadFile({ apiKey, model, filePath: input, signal: opts.signal }); +} + +function combineWithTimeout( + timeoutMs: number, + parentSignal?: AbortSignal, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const abortFromParent = () => controller.abort(parentSignal?.reason); + const cleanup = () => { + clearTimeout(timeout); + parentSignal?.removeEventListener("abort", abortFromParent); + }; + + if (parentSignal?.aborted) abortFromParent(); + else parentSignal?.addEventListener("abort", abortFromParent, { once: true }); + controller.signal.addEventListener("abort", cleanup, { once: true }); + + return { signal: controller.signal, cleanup }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..d679633 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,14 @@ +export { BailianError } from "./errors/base.ts"; +export { mapApiError, type ApiErrorBody } from "./errors/api.ts"; +export { ExitCode } from "./errors/codes.ts"; + +export type * from "./types/api.ts"; +export * from "./auth/index.ts"; +export * from "./client/index.ts"; +export * from "./console/index.ts"; +export * from "./config/index.ts"; +export * from "./output/index.ts"; +export * from "./files/index.ts"; +export * from "./types/index.ts"; +export * from "./utils/index.ts"; +export * from "./telemetry/index.ts"; diff --git a/packages/core/src/output/formatter.ts b/packages/core/src/output/formatter.ts new file mode 100644 index 0000000..f0da8fd --- /dev/null +++ b/packages/core/src/output/formatter.ts @@ -0,0 +1,23 @@ +import { formatText } from "./text.ts"; +import { formatJson } from "./json.ts"; + +export type OutputFormat = "text" | "json"; + +export function detectOutputFormat(flagValue?: string): OutputFormat { + if (flagValue === "json" || flagValue === "text") { + return flagValue; + } + if (!process.stdout.isTTY) { + return "json"; + } + return "text"; +} + +export function formatOutput(data: unknown, format: OutputFormat): string { + switch (format) { + case "json": + return formatJson(data); + case "text": + return formatText(data); + } +} diff --git a/packages/core/src/output/index.ts b/packages/core/src/output/index.ts new file mode 100644 index 0000000..7488044 --- /dev/null +++ b/packages/core/src/output/index.ts @@ -0,0 +1,4 @@ +export type { OutputFormat } from "./formatter.ts"; +export { detectOutputFormat, formatOutput } from "./formatter.ts"; +export { formatErrorJson, formatJson } from "./json.ts"; +export { formatText } from "./text.ts"; diff --git a/packages/core/src/output/json.ts b/packages/core/src/output/json.ts new file mode 100644 index 0000000..56e8665 --- /dev/null +++ b/packages/core/src/output/json.ts @@ -0,0 +1,17 @@ +export function formatJson(data: unknown): string { + return JSON.stringify(data, null, 2); +} + +export function formatErrorJson(code: number, message: string, hint?: string): string { + return JSON.stringify( + { + error: { + code, + message, + ...(hint ? { hint } : {}), + }, + }, + null, + 2, + ); +} diff --git a/packages/core/src/output/text.ts b/packages/core/src/output/text.ts new file mode 100644 index 0000000..3e38d82 --- /dev/null +++ b/packages/core/src/output/text.ts @@ -0,0 +1,5 @@ +import { stringify } from "yaml"; + +export function formatText(data: unknown): string { + return stringify(data).replace(/\n$/, ""); +} diff --git a/packages/core/src/telemetry/env.ts b/packages/core/src/telemetry/env.ts new file mode 100644 index 0000000..b8391d6 --- /dev/null +++ b/packages/core/src/telemetry/env.ts @@ -0,0 +1,21 @@ +/** + * 判断当前运行环境。任一条件为真即视为 dev,默认 prod。 + * + * 1. NODE_ENV=development — Node 圈通用约定,测试同学/CI 可显式声明 + * 2. 当前模块文件路径不在 node_modules 里 — 自动识别从源码运行(pnpm dev / + * npm link / 直接 node packages/cli/src/main.ts),避免开发者忘记设环境变量 + * 时仍把数据打到 prod + * + * 缓存结果,模块加载期算一次就行。 + */ +let cachedEnv: "dev" | "prod" | undefined; + +export function detectEnv(): "dev" | "prod" { + if (cachedEnv) return cachedEnv; + if (process.env.NODE_ENV === "development") { + cachedEnv = "dev"; + return cachedEnv; + } + cachedEnv = import.meta.url.includes("/node_modules/") ? "prod" : "dev"; + return cachedEnv; +} diff --git a/packages/core/src/telemetry/event.ts b/packages/core/src/telemetry/event.ts new file mode 100644 index 0000000..3eee293 --- /dev/null +++ b/packages/core/src/telemetry/event.ts @@ -0,0 +1,84 @@ +export interface TrackingEvent { + command: string; + timestamp: string; + durationMs: number; + success: boolean; + errorMessage?: string; + httpStatus?: number; + requestId?: string; + cliVersion: string; + region: string; + nodeVersion: string; + os: string; + authMethod?: string; + params?: Record; +} + +export function createTrackingEvent(opts: { + command: string; + durationMs: number; + success: boolean; + error?: { message?: string; httpStatus?: number; requestId?: string }; + cliVersion: string; + region: string; + authMethod?: string; + params?: Record; +}): TrackingEvent { + const event: TrackingEvent = { + command: opts.command, + timestamp: new Date().toISOString(), + durationMs: opts.durationMs, + success: opts.success, + cliVersion: opts.cliVersion, + region: opts.region, + nodeVersion: process.version, + os: process.platform, + }; + + if (opts.authMethod) { + event.authMethod = opts.authMethod; + } + + if (!opts.success && opts.error) { + if (opts.error.message) event.errorMessage = opts.error.message; + if (opts.error.httpStatus !== undefined) event.httpStatus = opts.error.httpStatus; + if (opts.error.requestId) event.requestId = opts.error.requestId; + } + + if (opts.params && Object.keys(opts.params).length > 0) { + event.params = opts.params; + } + + return event; +} + +const AEM_TEXT_MAX = 500; + +function aemText(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + const s = typeof value === "string" ? value : JSON.stringify(value); + return s.length <= AEM_TEXT_MAX ? s : s.slice(0, AEM_TEXT_MAX); +} + +export function buildRemoteAemOptions(event: TrackingEvent): Record { + const { command: _command, params, ...extFields } = event; + + const opts: Record = { + et: "EXP", + ext: extFields, + c1: params, + c2: event.success ? "success" : "failure", + }; + + if (event.httpStatus !== undefined) { + opts.c3 = String(event.httpStatus); + } + if (event.errorMessage) { + opts.c4 = aemText(event.errorMessage); + } + if (event.requestId) { + opts.c5 = event.requestId; + } + + return opts; +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts new file mode 100644 index 0000000..a21f785 --- /dev/null +++ b/packages/core/src/telemetry/index.ts @@ -0,0 +1,4 @@ +export type { TrackingEvent } from "./event.ts"; +export { createTrackingEvent } from "./event.ts"; +export { localSink, remoteSink, flushTelemetry } from "./sink.ts"; +export { trackCommandExecution } from "./tracker.ts"; diff --git a/packages/core/src/telemetry/sink.ts b/packages/core/src/telemetry/sink.ts new file mode 100644 index 0000000..d7ecc16 --- /dev/null +++ b/packages/core/src/telemetry/sink.ts @@ -0,0 +1,106 @@ +import { appendFileSync, statSync, unlinkSync } from "fs"; +import { join } from "path"; +import { getConfigDir, ensureConfigDir } from "../config/paths.ts"; +import { buildRemoteAemOptions, type TrackingEvent } from "./event.ts"; +import { detectEnv } from "./env.ts"; +import Tracker from "../../lib/remote-telemetry/tracker.js"; +import EventPlugin from "../../lib/remote-telemetry/event-plugin.js"; + +const TELEMETRY_FILE = () => join(getConfigDir(), "telemetry.jsonl"); + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB + +let remoteSendEvent: ((name: string, opts: Record) => void) | undefined; + +// 追踪在途(in-flight)的远端发送请求,以便在进程退出前等待它们完成。 +// 底层 tracker 是 fire-and-forget 模式(发起 fetch 后不再持有 promise); +// 如果不追踪,CLI 在短命令或 SIGINT 场景下会在请求真正落网络前就退出。 +// 配合 tracker.js 中的 `keepalive: true`,await 这些 promise 可以在不阻塞 +// 主流程的前提下,尽最大努力把埋点送达。 +const inflightSends = new Set>(); +let remoteClient: any = undefined; + +try { + // env 传给 Tracker 顶层 config 后会进 URL 查询参数, AEM 后台 "环境" 列 + // (原始字段 env) 直接读它, 值域 prod/pre/daily/dev。我们目前只用 prod/dev。 + const client = new Tracker({ pid: "bailian-cli-node", env: detectEnv() }); + // 仅对当前实例的 `send` 做猴补丁,把每次 fetch 的 promise 收集起来。 + // 不修改 prototype,避免污染其他可能存在的 Tracker 使用方。 + const originalSend = client.send.bind(client); + client.send = function (payload: string) { + const result = originalSend(payload); + if (result && typeof (result as Promise).then === "function") { + const p = result as Promise; + inflightSends.add(p); + void p.finally(() => inflightSends.delete(p)); + } + return result; + }; + remoteClient = client; + remoteSendEvent = client.use(EventPlugin); +} catch { + // 埋点逻辑任何异常都不能影响 CLI 主流程 +} + +/** + * 尽力等待所有在途的埋点发送完成(best-effort)。 + * + * 1. 先调用 `_sendAll` 排空 tracker 内部的去抖队列,把还卡在 500ms 合并窗口里 + * 的事件立刻推上网络。 + * 2. 然后用硬超时 race 所有已追踪的 fetch promise。 + * + * 埋点永远不应阻塞 CLI:调用方应传入较短的超时(例如 1000ms),并始终与超时 + * race。错误与超时一律静默吞掉。 + */ +export async function flushTelemetry(timeoutMs = 1000): Promise { + try { + if (remoteClient) { + try { + // 排空去抖队列:把卡在 500ms 合并窗口里的事件立刻发出去 + // (每次发出都会被追加到 inflightSends 里)。 + if (typeof remoteClient._sendAll === "function") { + remoteClient._sendAll(); + } + } catch { + // flush 自身绝不抛出 + } + } + if (inflightSends.size === 0) return; + const pending = [...inflightSends].map((p) => p.catch(() => undefined)); + await Promise.race([ + Promise.allSettled(pending), + new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.()), + ]); + } catch { + // 埋点逻辑任何异常都不能影响 CLI 主流程 + } +} + +export async function localSink(event: TrackingEvent): Promise { + try { + await ensureConfigDir(); + const path = TELEMETRY_FILE(); + + try { + const stat = statSync(path); + if (stat.size > MAX_FILE_SIZE) { + unlinkSync(path); + } + } catch { + // 文件还不存在,忽略 + } + + appendFileSync(path, JSON.stringify(event) + "\n"); + } catch { + // 埋点逻辑任何异常都不能影响 CLI 主流程 + } +} + +export async function remoteSink(event: TrackingEvent): Promise { + try { + if (!remoteSendEvent) return; + remoteSendEvent(event.command, buildRemoteAemOptions(event)); + } catch { + // 埋点逻辑任何异常都不能影响 CLI 主流程 + } +} diff --git a/packages/core/src/telemetry/tracker.ts b/packages/core/src/telemetry/tracker.ts new file mode 100644 index 0000000..8d5b16e --- /dev/null +++ b/packages/core/src/telemetry/tracker.ts @@ -0,0 +1,144 @@ +import type { Config } from "../config/schema.ts"; +import type { GlobalFlags } from "../types/flags.ts"; +import { BailianError } from "../errors/base.ts"; +import { createTrackingEvent } from "./event.ts"; +import { localSink, remoteSink } from "./sink.ts"; + +const GLOBAL_FLAG_KEYS = new Set([ + "apiKey", + "baseUrl", + "output", + "quiet", + "verbose", + "timeout", + "noColor", + "yes", + "dryRun", + "help", + "nonInteractive", + "async", + "region", + "console", +]); + +/** + * Allowlist of flag names safe to send to telemetry. + * + * Default is to NOT report. Only flags whose value space is enumerable / numeric / boolean + * (and therefore cannot leak user content, credentials, file paths, URLs, or customer IDs) + * belong here. When adding a new flag, ask: could this field carry PII, secrets, prompts, + * file paths, URLs, or tenant identifiers? If yes, do NOT add it. + */ +const PARAM_ALLOWLIST = new Set([ + // Pagination / counts + "page", + "pageSize", + "n", + "count", + // Model / voice / language selectors (public identifiers) + "model", + "voice", + "language", + "provider", + "capability", + // Generation params + "temperature", + "topP", + "topK", + "maxTokens", + "seed", + "stream", + // Media specs + "size", + "resolution", + "ratio", + "duration", + "format", + "audioFormat", + "sampleRate", + "pitch", + "rate", + "volume", + // Console gateway API name (public service identifier, not PII) + "api", + // Mode / behavior flags + "mode", + "download", + "noWait", + "textOnly", + "promptExtend", + "noPromptExtend", + "enableSsml", + "watermark", + "hasThoughts", + "listTools", + "rerank", + "rerankTopN", + "diarization", +]); + +function extractParams(flags: GlobalFlags): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(flags)) { + if (key.startsWith("_")) continue; + if (GLOBAL_FLAG_KEYS.has(key)) continue; + if (!PARAM_ALLOWLIST.has(key)) continue; + if (value === undefined || value === false) continue; + params[key] = value; + } + return params; +} + +export async function trackCommandExecution( + config: Config, + commandPath: string[], + flags: GlobalFlags, + fn: () => Promise, +): Promise { + if (!config.telemetry) { + await fn(); + return; + } + + const start = performance.now(); + let success = true; + let errorMessage: string | undefined; + let httpStatus: number | undefined; + let requestId: string | undefined; + + try { + await fn(); + } catch (err) { + success = false; + if (err instanceof BailianError) { + errorMessage = err.message; + httpStatus = err.api?.httpStatus; + requestId = err.api?.requestId; + } else if (err instanceof Error) { + errorMessage = err.message; + } + throw err; + } finally { + const durationMs = Math.round(performance.now() - start); + + let authMethod: string | undefined; + if (config.apiKey) authMethod = "api-key"; + else if (config.fileApiKey) authMethod = "api-key"; + else if (config.accessTokenEnv || config.fileAccessToken) authMethod = "access-token"; + + const event = createTrackingEvent({ + command: commandPath.join(" "), + durationMs, + success, + error: success ? undefined : { message: errorMessage, httpStatus, requestId }, + cliVersion: config.clientVersion ?? "unknown", + region: config.region, + authMethod, + params: extractParams(flags), + }); + + // Fire-and-forget — never block the CLI exit or mask errors + localSink(event).catch(() => {}); + remoteSink(event).catch(() => {}); + } +} diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts new file mode 100644 index 0000000..585339e --- /dev/null +++ b/packages/core/src/types/api.ts @@ -0,0 +1,501 @@ +// ---- Chat (OpenAI Compatible) ---- + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string | ChatMessageContent[]; +} + +export type ChatMessageContent = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + | { type: "input_audio"; input_audio: { data: string; format?: string } } + | { type: "audio_url"; audio_url: { url: string } } + | { type: "video"; video: string[] } + | { type: "video_url"; video_url: { url: string } }; + +export interface ChatTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +export interface ChatRequest { + model: string; + messages: ChatMessage[]; + max_tokens?: number; + temperature?: number; + top_p?: number; + stream?: boolean; + tools?: ChatTool[]; + tool_choice?: "auto" | "none" | { type: "function"; function: { name: string } }; + enable_thinking?: boolean; + thinking_budget?: number; + modalities?: string[]; + audio?: { voice: string; format?: string }; + stream_options?: { include_usage?: boolean }; +} + +export interface ChatChoice { + index: number; + message: { + role: "assistant"; + content: string | null; + reasoning_content?: string | null; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }>; + }; + finish_reason: string; +} + +export interface ChatResponse { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: ChatChoice[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +// ---- Streaming (OpenAI SSE) ---- + +export interface StreamChoice { + index: number; + delta: { + role?: string; + content?: string | null; + reasoning_content?: string | null; + audio?: { data?: string; id?: string; expires_at?: number }; + tool_calls?: Array<{ + index: number; + id?: string; + type?: "function"; + function?: { name?: string; arguments?: string }; + }>; + }; + finish_reason: string | null; +} + +export interface StreamChunk { + id: string; + object: "chat.completion.chunk"; + created: number; + model: string; + choices: StreamChoice[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +// ---- Image (DashScope) ---- + +export interface DashScopeImageRequest { + model: string; + input: { + messages: Array<{ + role: "user"; + content: Array<{ text?: string; image?: string }>; + }>; + }; + parameters?: { + size?: string; + n?: number; + seed?: number; + prompt_extend?: boolean; + watermark?: boolean; + negative_prompt?: string; + }; +} + +export interface DashScopeImageSyncResponse { + output: { + choices: Array<{ + finish_reason: string; + message: { + role: "assistant"; + content: Array<{ image: string; type: "image" }>; + }; + }>; + finished: boolean; + }; + usage: { + image_count: number; + }; + request_id: string; +} + +// ---- Video (DashScope) ---- + +export interface DashScopeVideoRequest { + model: string; + input: { + prompt: string; + negative_prompt?: string; + img_url?: string; + media?: Array<{ + type: "image" | "video" | "first_frame" | "last_frame" | "driving_audio" | "first_clip"; + url: string; + }>; + }; + parameters?: { + resolution?: string; + ratio?: string; + duration?: number; + prompt_extend?: boolean; + watermark?: boolean; + seed?: number; + }; +} + +export interface DashScopeVideoRefRequest { + model: string; + input: { + prompt: string; + media: Array<{ + type: "reference_image" | "reference_video"; + url: string; + reference_voice?: string; + }>; + }; + parameters?: { + resolution?: string; + ratio?: string; + duration?: number; + prompt_extend?: boolean; + watermark?: boolean; + seed?: number; + }; +} + +export interface DashScopeVideoEditRequest { + model: string; + input: { + prompt?: string; + negative_prompt?: string; + media: Array<{ + type: "video" | "reference_image"; + url: string; + }>; + }; + parameters?: { + resolution?: string; + ratio?: string; + duration?: number; + audio_setting?: "auto" | "origin"; + prompt_extend?: boolean; + watermark?: boolean; + seed?: number; + }; +} + +// ---- Application (Agent / Workflow) ---- + +export interface AppCompletionRequest { + input: { + prompt?: string; + session_id?: string; + image_list?: string[]; + file_ids?: string[]; + biz_params?: Record; + }; + parameters?: { + has_thoughts?: boolean; + incremental_output?: boolean; + rag_options?: { + pipeline_ids?: string[]; + knowledge_base_ids?: string[]; + }; + memory_id?: string; + }; + debug?: Record; +} + +export interface AppCompletionResponse { + output: { + text: string; + finish_reason: string; + session_id: string; + thoughts?: Array<{ + thought: string; + action_type: string; + action_name: string; + action: string; + action_input_stream: string; + action_input: string; + response: string; + observation: string; + }>; + doc_references?: Array<{ + index_id: string; + title: string; + doc_id: string; + doc_name: string; + text: string; + images?: string[]; + }>; + }; + usage: { + models: Array<{ + model_id: string; + input_tokens: number; + output_tokens: number; + }>; + }; + request_id: string; +} + +export interface AppStreamChunk { + output: { + text: string; + finish_reason: string; + session_id: string; + thoughts?: AppCompletionResponse["output"]["thoughts"]; + doc_references?: AppCompletionResponse["output"]["doc_references"]; + }; + usage?: AppCompletionResponse["usage"]; + request_id: string; +} + +// ---- Memory (DashScope v2) ---- + +export interface MemoryMessage { + role: "user" | "assistant"; + content: string; +} + +export interface MemoryAddRequest { + user_id: string; + messages?: MemoryMessage[]; + custom_content?: string; + profile_schema?: string; + memory_library_id?: string; +} + +export interface MemoryAddResponse { + request_id: string; + memory_ids?: string[]; +} + +export interface MemorySearchRequest { + user_id: string; + messages?: MemoryMessage[]; + query?: string; + top_k?: number; + memory_library_id?: string; +} + +export interface MemoryNode { + memory_node_id: string; + content: string; + user_id?: string; + meta_data?: Record; + created_at?: string; + updated_at?: string; +} + +export interface MemorySearchResponse { + request_id: string; + memory_nodes: MemoryNode[]; +} + +export interface MemoryNodeListResponse { + request_id: string; + memory_nodes: MemoryNode[]; + total?: number; + page_num?: number; + page_size?: number; +} + +export interface MemoryNodeUpdateRequest { + user_id: string; + custom_content: string; + /** 非默认记忆库时必填(与控制台记忆库 ID 一致) */ + memory_library_id?: string; +} + +// ---- Memory Profile (DashScope v2) ---- + +export interface ProfileAttribute { + name: string; + description: string; + value?: string; +} + +export interface ProfileSchemaCreateRequest { + name: string; + description?: string; + attributes: ProfileAttribute[]; +} + +export interface ProfileSchemaCreateResponse { + request_id: string; + profile_schema_id: string; +} + +export interface UserProfileResponse { + request_id: string; + profile: { + schema_id: string; + user_id: string; + attributes: ProfileAttribute[]; + }; +} + +// ---- Knowledge Retrieve (Bailian Cloud API) ---- + +export interface KnowledgeRetrieveRequest { + IndexId: string; + Query: string; + DenseSimilarityTopK?: number; + SparseSimilarityTopK?: number; + EnableReranking?: boolean; + EnableRewrite?: boolean; + RerankTopN?: number; + TopK?: number; + Rerank?: boolean; + RerankTopN_legacy?: number; + SearchFilters?: Array<{ + Key: string; + Value: string; + Operator: string; + }>; +} + +export interface KnowledgeRetrieveResponse { + Success: boolean; + RequestId: string; + Data: { + Nodes: Array<{ + Text: string; + Score: number; + Metadata: Record; + }>; + }; +} + +// ---- Speech Synthesis / TTS (DashScope) ---- + +export interface DashScopeTTSRequest { + model: string; + input: { + text: string; + voice?: string; + format?: "mp3" | "pcm" | "wav" | "opus"; + sample_rate?: number; + volume?: number; + rate?: number; + pitch?: number; + seed?: number; + language_hints?: string[]; + instruction?: string; + enable_ssml?: boolean; + }; +} + +export interface DashScopeTTSResponse { + output: { + audio: { url: string; expires_at?: string }; + finish_reason?: string; + }; + usage?: Record; + request_id: string; +} + +export interface DashScopeTTSStreamChunk { + output: { + audio: { data?: string; url?: string; expires_at?: string }; + finish_reason?: string; + }; + usage?: Record; + request_id?: string; +} + +// ---- Speech Recognition / ASR (DashScope) ---- + +export interface DashScopeASRRequest { + model: string; + input: { + file_urls: string[]; + }; + parameters?: { + channel_id?: number[]; + language_hints?: string[]; + diarization_enabled?: boolean; + speaker_count?: number; + vocabulary_id?: string; + }; +} + +export interface DashScopeASRTaskResult { + output: { + task_id: string; + task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN"; + results?: Array<{ + file_url?: string; + transcription_url?: string; + subtask_status?: string; + code?: string; + message?: string; + }>; + task_metrics?: { + TOTAL: number; + SUCCEEDED: number; + FAILED: number; + }; + }; + usage?: Record; + request_id: string; +} + +// ---- Async Task (DashScope) ---- + +export interface DashScopeAsyncResponse { + output: { + task_id: string; + task_status: string; + }; + request_id: string; +} + +export interface DashScopeTaskResponse { + output: { + task_id: string; + task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN"; + finished?: boolean; + task_metrics?: { + TOTAL?: number; + SUCCEEDED?: number; + FAILED?: number; + }; + // Image generation (wan2.x) returns choices + choices?: Array<{ + finish_reason: string; + message: { + role: "assistant"; + content: Array<{ image: string; type: "image" }>; + }; + }>; + // Some models return results array + results?: Array<{ url: string }>; + // Video generation returns video_url + video_url?: string; + submit_time?: string; + scheduled_time?: string; + end_time?: string; + code?: string; + message?: string; + }; + usage?: Record; + request_id: string; +} diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts new file mode 100644 index 0000000..4f236dd --- /dev/null +++ b/packages/core/src/types/command.ts @@ -0,0 +1,58 @@ +import type { Config } from "../config/schema.ts"; +import type { GlobalFlags } from "./flags.ts"; + +export interface OptionDef { + flag: string; + description: string; + type?: "string" | "number" | "boolean" | "array"; + required?: boolean; +} + +export interface Command { + name: string; + description: string; + usage?: string; + options?: OptionDef[]; + examples?: string[]; + apiDocs?: string; + execute: (config: Config, flags: GlobalFlags) => Promise; +} + +export interface CommandSpec { + name: string; + description: string; + usage?: string; + options?: OptionDef[]; + examples?: string[]; + apiDocs?: string; + run: (config: Config, flags: GlobalFlags) => Promise; +} + +export function defineCommand(spec: CommandSpec): Command { + return { + name: spec.name, + description: spec.description, + usage: spec.usage, + options: spec.options, + examples: spec.examples, + apiDocs: spec.apiDocs, + execute: (config, flags) => spec.run(config, flags), + }; +} + +/** Global flags shared by all commands — drives the parser's type resolution. */ +export const GLOBAL_OPTIONS: OptionDef[] = [ + { flag: "--api-key ", description: "API key" }, + { flag: "--region ", description: "API region: cn (default), us, intl" }, + { flag: "--base-url ", description: "API base URL" }, + { flag: "--output ", description: "Output format: text, json" }, + { flag: "--timeout ", description: "Request timeout", type: "number" }, + { flag: "--quiet", description: "Suppress non-essential output" }, + { flag: "--verbose", description: "Print HTTP request/response details" }, + { flag: "--no-color", description: "Disable ANSI colors" }, + { flag: "--dry-run", description: "Dry run mode" }, + { flag: "--non-interactive", description: "Disable interactive prompts" }, + { flag: "--concurrent ", description: "Run N parallel requests (default: 1)", type: "number" }, + { flag: "--help", description: "Show help" }, + { flag: "--version", description: "Print version" }, +]; diff --git a/packages/core/src/types/flags.ts b/packages/core/src/types/flags.ts new file mode 100644 index 0000000..4f29f85 --- /dev/null +++ b/packages/core/src/types/flags.ts @@ -0,0 +1,15 @@ +export interface GlobalFlags { + apiKey?: string; + baseUrl?: string; + output?: string; + quiet: boolean; + verbose: boolean; + timeout?: number; + noColor: boolean; + yes: boolean; + dryRun: boolean; + help: boolean; + nonInteractive: boolean; + async: boolean; + [key: string]: unknown; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts new file mode 100644 index 0000000..fa1e40e --- /dev/null +++ b/packages/core/src/types/index.ts @@ -0,0 +1,42 @@ +export type { Command, CommandSpec, OptionDef } from "./command.ts"; +export { defineCommand, GLOBAL_OPTIONS } from "./command.ts"; +export type { GlobalFlags } from "./flags.ts"; +export type { + AppCompletionRequest, + AppCompletionResponse, + AppStreamChunk, + ChatChoice, + ChatMessage, + ChatMessageContent, + ChatRequest, + ChatResponse, + ChatTool, + DashScopeASRRequest, + DashScopeASRTaskResult, + DashScopeAsyncResponse, + DashScopeImageRequest, + DashScopeImageSyncResponse, + DashScopeTaskResponse, + DashScopeTTSRequest, + DashScopeTTSResponse, + DashScopeTTSStreamChunk, + DashScopeVideoEditRequest, + DashScopeVideoRefRequest, + DashScopeVideoRequest, + KnowledgeRetrieveRequest, + KnowledgeRetrieveResponse, + MemoryAddRequest, + MemoryAddResponse, + MemoryMessage, + MemoryNode, + MemoryNodeListResponse, + MemoryNodeUpdateRequest, + MemorySearchRequest, + MemorySearchResponse, + ProfileAttribute, + ProfileSchemaCreateRequest, + ProfileSchemaCreateResponse, + StreamChoice, + StreamChunk, + UserProfileResponse, +} from "./api.ts"; diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts new file mode 100644 index 0000000..17aff44 --- /dev/null +++ b/packages/core/src/utils/env.ts @@ -0,0 +1,38 @@ +/** + * Environment detection utilities for bailian-cli. + * + * Used to determine whether the CLI is running in an interactive terminal + * (human user) or in a non-interactive environment (CI, agent, pipe, etc.), + * so commands can adjust their behavior accordingly. + */ + +/** + * Detects whether the current environment is interactive. + * + * Returns false when: + * - stdout or stdin is not a TTY + * - The --non-interactive flag was explicitly set + * - The process is running in a known CI environment (CI env var present) + * + * Returns true when stdout and stdin are both TTYs and --non-interactive + * was not passed. + */ +export function isInteractive(options?: { nonInteractive?: boolean }): boolean { + if (options?.nonInteractive === true) return false; + if (process.env.CI) return false; + return process.stdout.isTTY === true && process.stdin.isTTY === true; +} + +/** + * Detects whether the current process is running in a CI environment. + */ +export function isCI(): boolean { + return !!( + process.env.CI || + process.env.GITHUB_ACTIONS || + process.env.GITLAB_CI || + process.env.JENKINS_URL || + process.env.TRAVIS || + process.env.CIRCLECI + ); +} diff --git a/packages/core/src/utils/filename.ts b/packages/core/src/utils/filename.ts new file mode 100644 index 0000000..b13291d --- /dev/null +++ b/packages/core/src/utils/filename.ts @@ -0,0 +1,22 @@ +/** + * 生成文件名前缀 + * @param prefix prompt的前10个字符 + * @param suffix timestamp + * @returns + */ +function sanitizeFilenamePart(input: string, fallback: string): string { + const normalized = input + .normalize("NFKC") + .replace(/[\\/:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + + return normalized || fallback; +} + +export function generateFilename(prefix: string, prompt: string): string { + const safePrefix = sanitizeFilenamePart(prefix || "image", "image"); + const promptPart = sanitizeFilenamePart((prompt || "").substring(0, 20), "untitled"); + return `${safePrefix}_${promptPart}_${Date.now()}`; +} diff --git a/packages/core/src/utils/fs.ts b/packages/core/src/utils/fs.ts new file mode 100644 index 0000000..a8fe0b4 --- /dev/null +++ b/packages/core/src/utils/fs.ts @@ -0,0 +1,5 @@ +import { readFileSync } from "fs"; + +export function readTextFromPathOrStdin(path: string): string { + return readFileSync(path === "-" ? "/dev/stdin" : path, "utf-8"); +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000..e8d098f --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,7 @@ +export { generateFilename } from "./filename.ts"; +export { resolveOutputDir } from "./output-dir.ts"; +export { generateToolSchema } from "./schema.ts"; +export { maskToken } from "./token.ts"; +export { isInteractive } from "./env.ts"; +export { isCI } from "./env.ts"; +export { stripUndefined } from "./object.ts"; diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts new file mode 100644 index 0000000..741b079 --- /dev/null +++ b/packages/core/src/utils/object.ts @@ -0,0 +1,19 @@ +/** + * Generic object-cleaning utilities. + */ + +/** + * Remove all keys whose value is `undefined` from a plain object (in-place). + * Returns the same reference for chaining convenience. + * + * ```ts + * const params = { a: 1, b: undefined }; + * stripUndefined(params); // { a: 1 } + * ``` + */ +export function stripUndefined>(obj: T): T { + for (const key of Object.keys(obj)) { + if (obj[key] === undefined) delete obj[key]; + } + return obj; +} diff --git a/packages/core/src/utils/output-dir.ts b/packages/core/src/utils/output-dir.ts new file mode 100644 index 0000000..a00217f --- /dev/null +++ b/packages/core/src/utils/output-dir.ts @@ -0,0 +1,31 @@ +import { existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +import type { Config } from "../config/schema.ts"; + +const DEFAULT_OUTPUT_DIR = () => join(homedir(), "bailian-output"); + +/** + * Resolve the output directory for generated files. + * + * Priority: + * 1. User-specified dir (e.g. --out-dir flag) + * 2. Config file output_dir + * 3. Default: ~/bailian-output/ + * + * Optionally appends a subdirectory (e.g. 'images', 'videos', 'speech'). + * Creates the directory if it doesn't exist. + */ +export function resolveOutputDir( + config: Config, + options?: { flagDir?: string; subDir?: string }, +): string { + const base = options?.flagDir || config.outputDir || DEFAULT_OUTPUT_DIR(); + const dir = options?.subDir ? join(base, options.subDir) : base; + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + return dir; +} diff --git a/packages/core/src/utils/schema.ts b/packages/core/src/utils/schema.ts new file mode 100644 index 0000000..a368fc5 --- /dev/null +++ b/packages/core/src/utils/schema.ts @@ -0,0 +1,82 @@ +import type { Command } from "../types/command.ts"; + +/** + * Parse a CLI flag string (e.g. "--prompt ", "--stream") into + * a parameter name and inferred type. + */ +function parseFlag(flag: string): { + name: string; + kebabName: string; + inferredType: string; + isArray: boolean; +} { + // e.g. "--prompt " -> "prompt" + const match = flag.match(/^--([a-zA-Z0-9-]+)/); + const kebabName = match ? match[1]! : ""; + // camelCase to match internal API conventions + const name = kebabName.replace(/-([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase()); + + let inferredType = "string"; + let isArray = false; + + if (!flag.includes("<") && !flag.includes("[")) { + // No parameter value — typically a boolean flag like --stream + inferredType = "boolean"; + } else if ( + flag.includes("") || + flag.includes("") || + flag.includes("") || + flag.includes("") + ) { + inferredType = "number"; + } + + if (flag.toLowerCase().includes("repeatable")) { + isArray = true; + } + + return { name, kebabName, inferredType, isArray }; +} + +export function generateToolSchema(cmd: Command): Record { + const toolName = `bailian_${cmd.name.replace(/ /g, "_")}`; + + const schema: Record = { + name: toolName, + description: cmd.description, + input_schema: { + type: "object", + properties: {} as Record, + required: [] as string[], + }, + }; + + if (cmd.options) { + for (const opt of cmd.options) { + const { name, inferredType, isArray } = parseFlag(opt.flag); + if (!name) continue; + + // Explicit type from OptionDef takes precedence; fall back to inference + const explicitType = opt.type; + const effectiveType = isArray ? "array" : (explicitType ?? inferredType); + + const propSchema: Record = { description: opt.description }; + + if (effectiveType === "array") { + propSchema.type = "array"; + propSchema.items = { type: "string" }; + } else { + propSchema.type = effectiveType; + } + + const inputSchema = schema.input_schema as Record; + (inputSchema.properties as Record)[name] = propSchema; + + if (opt.required) { + (inputSchema.required as string[]).push(name); + } + } + } + + return schema; +} diff --git a/packages/core/src/utils/token.ts b/packages/core/src/utils/token.ts new file mode 100644 index 0000000..1559ffc --- /dev/null +++ b/packages/core/src/utils/token.ts @@ -0,0 +1,3 @@ +export function maskToken(token: string): string { + return token.length > 8 ? `${token.slice(0, 4)}...${token.slice(-4)}` : "***"; +} diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts new file mode 100644 index 0000000..a81d1d0 --- /dev/null +++ b/packages/core/tests/index.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "vite-plus/test"; +import type { Config } from "../src/index.ts"; +import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts"; + +function testConfig(overrides: Partial = {}): Config { + return { + region: "cn", + baseUrl: "https://dashscope.aliyuncs.com", + output: "json", + timeout: 30, + verbose: false, + quiet: true, + noColor: true, + yes: true, + dryRun: false, + nonInteractive: true, + async: false, + telemetry: true, + consoleGatewayUrl: "https://bailian-cs.console.aliyun.com", + ...overrides, + }; +} + +test("BailianError carries exitCode and hint", () => { + const err = new BailianError("nope", ExitCode.AUTH, "do this"); + expect(err.name).toBe("BailianError"); + expect(err.exitCode).toBe(ExitCode.AUTH); + expect(err.hint).toBe("do this"); + expect(err.toJSON()).toEqual({ + error: { code: ExitCode.AUTH, message: "nope", hint: "do this" }, + }); +}); + +test("mapApiError keeps server message verbatim and surfaces metadata via err.api", () => { + const err = mapApiError(401, { error: { message: "bad key" } }); + expect(err).toBeInstanceOf(BailianError); + expect(err.exitCode).toBe(ExitCode.GENERAL); + expect(err.message).toBe("bad key"); + expect(err.api?.httpStatus).toBe(401); + expect(err.api?.apiCode).toBeUndefined(); + expect(err.api?.requestId).toBeUndefined(); +}); + +test("mapApiError captures apiCode and request_id when present", () => { + const err = mapApiError(429, { + error: { message: "too many", type: "Throttling" }, + request_id: "req-abc-123", + }); + expect(err.exitCode).toBe(ExitCode.GENERAL); + expect(err.message).toBe("too many"); + expect(err.api).toEqual({ + httpStatus: 429, + apiCode: "Throttling", + requestId: "req-abc-123", + }); +}); + +test("BailianError propagates cause via options-bag and exposes it in toJSON", () => { + const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { + code: "ENOTFOUND", + }); + const err = new BailianError("Network request failed: ENOTFOUND", ExitCode.NETWORK, "hint", { + cause: root, + }); + expect(err.cause).toBe(root); + expect(err.toJSON()).toEqual({ + error: { + code: ExitCode.NETWORK, + message: "Network request failed: ENOTFOUND", + hint: "hint", + cause: { message: root.message, code: "ENOTFOUND" }, + }, + }); +}); + +test("toJSON splits service-error metadata into structured fields", () => { + const err = mapApiError(404, { + error: { message: "The model `qwen3.7` does not exist", type: "invalid_request_error" }, + request_id: "c55e1acc", + }); + expect(err.toJSON()).toEqual({ + error: { + code: ExitCode.GENERAL, + message: "The model `qwen3.7` does not exist", + http_status: 404, + api_code: "invalid_request_error", + request_id: "c55e1acc", + }, + }); +}); + +test("request uses injected client identity for User-Agent", async () => { + const originalFetch = globalThis.fetch; + let userAgent: string | undefined; + + globalThis.fetch = async (_url, init) => { + const headers = init?.headers as Record | undefined; + userAgent = headers?.["User-Agent"]; + return new Response("{}", { status: 200 }); + }; + + try { + await request(testConfig({ clientName: "test-client", clientVersion: "9.8.7" }), { + url: "https://example.test", + noAuth: true, + }); + } finally { + globalThis.fetch = originalFetch; + } + + expect(userAgent).toBe("test-client/9.8.7"); +}); + +test("request propagates caller AbortSignal to fetch", async () => { + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + let fetchSignal: AbortSignal | undefined; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchStarted = new Promise((resolve) => { + globalThis.fetch = async (_url, init) => { + fetchSignal = init?.signal as AbortSignal | undefined; + resolve(); + return await new Promise((resolveResponse) => { + resolveFetch = resolveResponse; + }); + }; + }); + + const requestPromise = request(testConfig(), { + url: "https://example.test", + noAuth: true, + signal: controller.signal, + }); + try { + await fetchStarted; + expect(fetchSignal).toBeDefined(); + expect(fetchSignal?.aborted).toBe(false); + controller.abort(); + expect(fetchSignal?.aborted).toBe(true); + resolveFetch?.(new Response("{}", { status: 200 })); + await requestPromise; + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("McpClient uses injected client identity for initialize and User-Agent", async () => { + const originalFetch = globalThis.fetch; + const bodies: unknown[] = []; + const userAgents: string[] = []; + + globalThis.fetch = async (_url, init) => { + const headers = init?.headers as Record | undefined; + userAgents.push(headers?.["User-Agent"] ?? ""); + const body = init?.body; + if (typeof body === "string") bodies.push(JSON.parse(body)); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), { status: 200 }); + }; + + try { + const client = new McpClient( + testConfig({ apiKey: "sk-test", clientName: "test-client", clientVersion: "9.8.7" }), + "https://mcp.example.test", + ); + await client.initialize(); + } finally { + globalThis.fetch = originalFetch; + } + + expect(userAgents).toEqual(["test-client/9.8.7", "test-client/9.8.7"]); + expect(bodies[0]).toMatchObject({ + method: "initialize", + params: { clientInfo: { name: "test-client", version: "9.8.7" } }, + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..ff4adab --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + } +} diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts new file mode 100644 index 0000000..bffbbc1 --- /dev/null +++ b/packages/core/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + dts: { + tsgo: true, + }, + exports: true, + }, + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5d407b8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1737 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@types/node': + specifier: ^24 + version: 24.12.2 + ajv: + specifier: ^8.20.0 + version: 8.20.0 + vite-plus: + specifier: latest + version: 0.1.22 + yaml: + specifier: ^2.8.3 + version: 2.8.3 + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + +importers: + + .: + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + + packages/cli: + dependencies: + bailian-cli-core: + specifier: workspace:* + version: link:../core + devDependencies: + '@clack/prompts': + specifier: ^0.7.0 + version: 0.7.0 + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260328.1 + version: 7.0.0-dev.20260328.1 + ajv: + specifier: 'catalog:' + version: 8.20.0 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + yaml: + specifier: 'catalog:' + version: 2.8.3 + + packages/core: + dependencies: + yaml: + specifier: ^2.8.3 + version: 2.8.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260328.1 + version: 7.0.0-dev.20260328.1 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + +packages: + + '@clack/core@0.3.5': + resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} + + '@clack/prompts@0.7.0': + resolution: {integrity: sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==} + bundledDependencies: + - is-unicode-supported + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/runtime@0.129.0': + resolution: {integrity: sha512-0+S67blQakgeNqoKGozOUp5rQBrz2ynXZ2QIINXZPiafsD0YL0UogB9hAWc1S7k6VSNwKYC/N7MqT0V6IzpHkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} + + '@oxfmt/binding-android-arm-eabi@0.48.0': + resolution: {integrity: sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.48.0': + resolution: {integrity: sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.48.0': + resolution: {integrity: sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.48.0': + resolution: {integrity: sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.48.0': + resolution: {integrity: sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': + resolution: {integrity: sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.48.0': + resolution: {integrity: sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.48.0': + resolution: {integrity: sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.48.0': + resolution: {integrity: sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.48.0': + resolution: {integrity: sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.48.0': + resolution: {integrity: sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.48.0': + resolution: {integrity: sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.48.0': + resolution: {integrity: sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.48.0': + resolution: {integrity: sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.48.0': + resolution: {integrity: sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.48.0': + resolution: {integrity: sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.48.0': + resolution: {integrity: sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.48.0': + resolution: {integrity: sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.48.0': + resolution: {integrity: sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@0.22.1': + resolution: {integrity: sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.22.1': + resolution: {integrity: sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.22.1': + resolution: {integrity: sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.22.1': + resolution: {integrity: sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.22.1': + resolution: {integrity: sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.22.1': + resolution: {integrity: sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.63.0': + resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.63.0': + resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.63.0': + resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.63.0': + resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.63.0': + resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.63.0': + resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.63.0': + resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.63.0': + resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.63.0': + resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.63.0': + resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.61.0': + resolution: {integrity: sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-BmJGDWC0bSQ2w5O/E+Mw9eTv9RklJ3vjshu7UdD92bUMxc4V4dkBhYj5r0qxbl4f+VFNX7fXvcDDI+9o+Kb6yw==} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-osc0XQn+AV4X/Vz4hehMm9YtwjZU8VN57FBx4/bsoZ2Z3H1KCA2vbrPQx1hxobrA/+LxkTEk/i6L+z1XwI3RTw==} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-UdPWbxynH/yu54Bx9SSmUsdBQVcKeB8hVLXWiF6qKGDQxwUmqo04xa+PUdxryUXxYzjedbqKMhDLL/W0AlbUMQ==} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-869rJ0Clw7aQTApV1dts2bKV+V6E0qNFJae3SNRo+4TPmrwlmYct3ouGrsQsDCat6XIaCdul8YOBzmj4QUzuMw==} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-0ZPwzToIRV4r2L/wZUwTD9DvZsVnezrc7x5xwZedGvuRifUKMAAwI+rGaKHqHq5nE5Y1gQA/wwMPPJ4xq6hzVw==} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-iCgWfPDIbs0xB+zkVu5IFfcco3II3b7DhatIa1hQiTFH4vGs0A4/LskLbSYyWOId4j5WEkCKK5T0KNnEYfbg1g==} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-k1/yoqrELzkm6eOFaYm9x+M7mDOlArO1P0YvEgEmcdnL6Igm+0ZmGy6eDmhk9pshPb0GfL1knN6c+5sJA7YReA==} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260328.1': + resolution: {integrity: sha512-e2f1LaETJ1wFIZSZAJwsAumWixGaRslUjESf0nSrZGUensq3ZwXddoDJPPoDLkSAr/Fa3v5aff+dJ39UbNfbNQ==} + hasBin: true + + '@voidzero-dev/vite-plus-core@0.1.22': + resolution: {integrity: sha512-OC7tChagbJCoY7YKzD5MuyxJO1km5IF42B3ltZoQ9Twc8UuPrMuWZrVoP984tJKYd/gFJuQFM/lrbNtBm9kyDg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.0 + '@tsdown/exe': 0.22.0 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@0.1.22': + resolution: {integrity: sha512-+6sRVGCAQSpO96WC0EZtSLJ01VzNiZL/eQUQ8NLVl1oH+0+KgHF2UXyqUXGCGf/JCu34egEwBEjDU3WUwN2mxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@0.1.22': + resolution: {integrity: sha512-rqsCW/Brt2froW7VhLE+gVHKtGniyLdHlfcmTLfuM5vnd5skdQlymibRw/lviJU+mSl0x8pGcXZbvA4TLHbCoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.22': + resolution: {integrity: sha512-OL/WT6pvJpFS1L+hWe8g2LCEHCJfEBSgxV0vbSoQDdfTuilUJaVK8rljVWgtIVjUQSjIx8jKfPsl/I8iEBh0GQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.22': + resolution: {integrity: sha512-SdZLL2aXm9XlbNfygsIifxhTjnRa2gI5oXNCh9QmLmXN36yhXt816I5Tl5IQ5DJwxhnG1+5Uqp2D6tHaT5g6nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.22': + resolution: {integrity: sha512-Qn6WPTn61A47ZBCPm+v8kCrMgXlI5p10pllKYkce2PFeCotN6v1bqu2GBZIkLVSi534ywGqdkiG1kaesM9e1vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.22': + resolution: {integrity: sha512-DsVE09IgvYBR2PY2Bohd08tScYDa8K8KJkIGc8Y6uRXR14NEldoufmWJdCmEsGLA8puRv5HV3ZheWFFjmw5Liw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-test@0.1.22': + resolution: {integrity: sha512-6VKDNXH+ygDyTXpBYn+g+2a9j3zuAZRlP2ZSx0RcjPMdGUMpX6Mox4CmdK8SkZUvi+f6a1siX50ZnCOcQoTgmQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.22': + resolution: {integrity: sha512-/1JDhTu7SAIjpqJVAN3D3zmN/O8cCRwdn63Qs0ep5GzNYh3+TtVyw3xdUY7YHeyuSypgKlqnr6IPeMlNijOG/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.22': + resolution: {integrity: sha512-GITqtIWeTaWZ7mo1799sIB6XhhSAL1TmuJvrtBz8e3SAUpjDsIYACDYumUDhowPdIHRO3rasyJg9jJZA82BjKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + oxfmt@0.48.0: + resolution: {integrity: sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint-tsgolint@0.22.1: + resolution: {integrity: sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==} + hasBin: true + + oxlint@1.63.0: + resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + vite-plus@0.1.22: + resolution: {integrity: sha512-fCCmEKjI+Hv74PdL/MKcrBkdYPHFNcqD5568KxwN0sa4SGxtcbs55i/577LxKs0w5zIjuLRZZ0zQPu9MO+9itg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@clack/core@0.3.5': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.7.0': + dependencies: + '@clack/core': 0.3.5 + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/runtime@0.129.0': {} + + '@oxc-project/types@0.127.0': {} + + '@oxc-project/types@0.129.0': {} + + '@oxfmt/binding-android-arm-eabi@0.48.0': + optional: true + + '@oxfmt/binding-android-arm64@0.48.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.48.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.48.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.48.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.48.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.48.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.48.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.48.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.48.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.48.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@0.22.1': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.22.1': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.22.1': + optional: true + + '@oxlint-tsgolint/linux-x64@0.22.1': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.22.1': + optional: true + + '@oxlint-tsgolint/win32-x64@0.22.1': + optional: true + + '@oxlint/binding-android-arm-eabi@1.63.0': + optional: true + + '@oxlint/binding-android-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-x64@1.63.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.63.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.63.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.63.0': + optional: true + + '@oxlint/plugins@1.61.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + optional: true + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260328.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260328.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260328.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260328.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260328.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260328.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260328.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260328.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260328.1 + + '@voidzero-dev/vite-plus-core@0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3)': + dependencies: + '@oxc-project/runtime': 0.129.0 + '@oxc-project/types': 0.129.0 + lightningcss: 1.32.0 + postcss: 8.5.12 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 2.6.1 + typescript: 6.0.3 + yaml: 2.8.3 + + '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3)': + dependencies: + '@oxc-project/runtime': 0.129.0 + '@oxc-project/types': 0.129.0 + lightningcss: 1.32.0 + postcss: 8.5.12 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + jiti: 2.6.1 + typescript: 6.0.3 + yaml: 2.8.3 + + '@voidzero-dev/vite-plus-darwin-arm64@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-test@0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + vite: 8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3) + ws: 8.20.0 + optionalDependencies: + '@types/node': 24.12.2 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3) + ws: 8.20.0 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.22': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.22': + optional: true + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + detect-libc@2.1.2: {} + + es-module-lexer@1.7.0: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + jiti@2.6.1: + optional: true + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + mrmime@2.0.1: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + oxfmt@0.48.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.48.0 + '@oxfmt/binding-android-arm64': 0.48.0 + '@oxfmt/binding-darwin-arm64': 0.48.0 + '@oxfmt/binding-darwin-x64': 0.48.0 + '@oxfmt/binding-freebsd-x64': 0.48.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.48.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.48.0 + '@oxfmt/binding-linux-arm64-gnu': 0.48.0 + '@oxfmt/binding-linux-arm64-musl': 0.48.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.48.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.48.0 + '@oxfmt/binding-linux-riscv64-musl': 0.48.0 + '@oxfmt/binding-linux-s390x-gnu': 0.48.0 + '@oxfmt/binding-linux-x64-gnu': 0.48.0 + '@oxfmt/binding-linux-x64-musl': 0.48.0 + '@oxfmt/binding-openharmony-arm64': 0.48.0 + '@oxfmt/binding-win32-arm64-msvc': 0.48.0 + '@oxfmt/binding-win32-ia32-msvc': 0.48.0 + '@oxfmt/binding-win32-x64-msvc': 0.48.0 + + oxlint-tsgolint@0.22.1: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.22.1 + '@oxlint-tsgolint/darwin-x64': 0.22.1 + '@oxlint-tsgolint/linux-arm64': 0.22.1 + '@oxlint-tsgolint/linux-x64': 0.22.1 + '@oxlint-tsgolint/win32-arm64': 0.22.1 + '@oxlint-tsgolint/win32-x64': 0.22.1 + + oxlint@1.63.0(oxlint-tsgolint@0.22.1): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.63.0 + '@oxlint/binding-android-arm64': 1.63.0 + '@oxlint/binding-darwin-arm64': 1.63.0 + '@oxlint/binding-darwin-x64': 1.63.0 + '@oxlint/binding-freebsd-x64': 1.63.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 + '@oxlint/binding-linux-arm-musleabihf': 1.63.0 + '@oxlint/binding-linux-arm64-gnu': 1.63.0 + '@oxlint/binding-linux-arm64-musl': 1.63.0 + '@oxlint/binding-linux-ppc64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-musl': 1.63.0 + '@oxlint/binding-linux-s390x-gnu': 1.63.0 + '@oxlint/binding-linux-x64-gnu': 1.63.0 + '@oxlint/binding-linux-x64-musl': 1.63.0 + '@oxlint/binding-openharmony-arm64': 1.63.0 + '@oxlint/binding-win32-arm64-msvc': 1.63.0 + '@oxlint/binding-win32-ia32-msvc': 1.63.0 + '@oxlint/binding-win32-x64-msvc': 1.63.0 + oxlint-tsgolint: 0.22.1 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + + pngjs@7.0.0: {} + + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + require-from-string@2.0.2: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + source-map-js@1.2.1: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + totalist@3.0.1: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + undici-types@7.19.2: + optional: true + + vite-plus@0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.129.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.48.0 + oxlint: 1.63.0(oxlint-tsgolint@0.22.1) + oxlint-tsgolint: 0.22.1 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.22 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.22 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.22 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.22 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.22 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.22 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.22 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.22 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + + vite-plus@0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.129.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.22(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.48.0 + oxlint: 1.63.0(oxlint-tsgolint@0.22.1) + oxlint-tsgolint: 0.22.1 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.22 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.22 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.22 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.22 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.22 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.22 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.22 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.22 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + + vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.8.3 + + vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.8.3 + + ws@8.20.0: {} + + yaml@2.8.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..483ef3a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,24 @@ +packages: + - packages/* + - tools/* + +catalog: + "@types/node": ^24 + ajv: ^8.20.0 + typescript: ^5 + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + yaml: ^2.8.3 + +catalogMode: prefer +overrides: + vite: "catalog:" + vitest: "catalog:" +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: "*" + vitest: "*" diff --git a/tools/browser-probe-localhost-console.js b/tools/browser-probe-localhost-console.js new file mode 100644 index 0000000..d3d3d8f --- /dev/null +++ b/tools/browser-probe-localhost-console.js @@ -0,0 +1,43 @@ +/** + * 在目标页面(例如 https://bailian.console.aliyun.com/...)打开 DevTools → Console, + * 将本文件全文粘贴回车执行;按提示输入 bl 监听的本机端口(127.0.0.1:PORT)。 + * + * 用于观察:当前 HTTPS 页面能否对 http://127.0.0.1:PORT 发起请求(混合内容 / CORS 等)。 + * + * 注意:控制台里若报 Failed to fetch / NetworkError,常见是混合内容被拦截,与 CLI 服务是否正常无关。 + */ +void (async () => { + const portStr = window.prompt("请输入本机监听端口(数字,例如 54321)"); + if (portStr === null) { + console.log("[probe] 已取消"); + return; + } + const port = parseInt(portStr.trim(), 10); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + console.error("[probe] 无效端口:", portStr); + return; + } + + const base = `http://127.0.0.1:${port}`; + const url = `${base}/`; + + console.log("[probe] 当前页面:", location.href); + console.log("[probe] 目标 URL:", url); + + // 1) 普通 GET(会走 CORS;若混合内容被拦,通常在这里失败) + try { + const res = await fetch(url, { method: "GET", cache: "no-store" }); + const text = await res.text(); + console.log("[probe] GET 成功 status=", res.status, "body前200字=", text.slice(0, 200)); + } catch (e) { + console.error("[probe] GET 失败:", e?.name, e?.message, e); + } + + // 2) no-cors:不读响应体;若仍失败,多为混合内容等 + try { + const res = await fetch(url, { method: "GET", mode: "no-cors", cache: "no-store" }); + console.log("[probe] no-cors GET 完成 opaque type=", res.type, "status(常为0)=", res.status); + } catch (e) { + console.error("[probe] no-cors GET 失败:", e?.name, e?.message, e); + } +})(); diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts new file mode 100644 index 0000000..a97391d --- /dev/null +++ b/tools/generate-reference.ts @@ -0,0 +1,207 @@ +/** + * Generator: reads `packages/cli/src/commands/catalog.ts` and writes: + * - `tools/generated/reference/index.md` — quick index, global flags, notes + * - `tools/generated/reference/.md` — per top-level command group details + * + * Output is temporary — the new skill install mechanism (`npx add skills`) will + * consume these files from a yet-to-be-decided location. + * + * Run: pnpm --filter bailian-cli run generate:reference + */ +import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DOCS_HOSTS, + GLOBAL_OPTIONS, + type Command, + type OptionDef, +} from "../packages/core/dist/index.mjs"; +import { commands } from "../packages/cli/src/commands/catalog.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REF_DIR = join(__dirname, "generated/reference"); +const INDEX_PATH = join(REF_DIR, "index.md"); + +const GENERATED_BANNER = + "> Auto-generated from `packages/cli/src/commands/catalog.ts`. Do not edit by hand.\n" + + "> Regenerate: `pnpm --filter bailian-cli run generate:reference`."; + +function escCell(s: string): string { + return s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim(); +} + +function topLevel(path: string): string { + return path.split(" ")[0]!; +} + +function optionType(opt: OptionDef): string { + if (opt.type) return opt.type; + if (!opt.flag.includes("<") && !opt.flag.includes("[")) return "boolean"; + return "string"; +} + +function formatOptionsTable(options: OptionDef[] | undefined): string { + if (!options?.length) return "_No command-specific options._\n"; + const rows = options.map((o) => { + const req = o.required ? "yes" : "no"; + return `| \`${escCell(o.flag)}\` | ${escCell(optionType(o))} | ${req} | ${escCell(o.description)} |`; + }); + return [ + "| Flag | Type | Required | Description |", + "| --- | --- | --- | --- |", + ...rows, + "", + ].join("\n"); +} + +function formatExamples(examples: string[] | undefined): string { + if (!examples?.length) return "_No examples._\n"; + return examples.map((ex) => ["```bash", ex, "```"].join("\n")).join("\n\n") + "\n"; +} + +function commandSection(path: string, cmd: Command): string { + const lines: string[] = []; + lines.push(`### \`bl ${path}\``, ""); + lines.push(`| Field | Value |`, `| --- | --- |`); + lines.push(`| **Name** | \`${escCell(cmd.name)}\` |`); + lines.push(`| **Description** | ${escCell(cmd.description)} |`); + if (cmd.usage) { + lines.push(`| **Usage** | \`${escCell(cmd.usage)}\` |`); + } + if (cmd.apiDocs) { + const url = `${DOCS_HOSTS.cn}${cmd.apiDocs}`; + lines.push(`| **API docs** | [${escCell(cmd.apiDocs)}](${url}) |`); + } + lines.push(""); + + lines.push("#### Options", ""); + lines.push(formatOptionsTable(cmd.options)); + + lines.push("#### Examples", ""); + lines.push(formatExamples(cmd.examples)); + + return lines.join("\n"); +} + +function groupByTopLevel(entries: [string, Command][]): Map { + const groups = new Map(); + for (const entry of entries) { + const key = topLevel(entry[0]); + const list = groups.get(key) ?? []; + list.push(entry); + groups.set(key, list); + } + for (const list of groups.values()) { + list.sort(([a], [b]) => a.localeCompare(b)); + } + return groups; +} + +function buildGroupFile(group: string, groupEntries: [string, Command][]): string { + const lines: string[] = [ + `# \`bl ${group}\` commands`, + "", + GENERATED_BANNER, + "", + `Index: [index.md](index.md)`, + "", + "## Commands in this group", + "", + "| Command | Description |", + "| --- | --- |", + ]; + + for (const [path, cmd] of groupEntries) { + lines.push(`| \`bl ${path}\` | ${escCell(cmd.description)} |`); + } + + lines.push("", "## Command details", ""); + for (const [path, cmd] of groupEntries) { + lines.push(commandSection(path, cmd)); + } + + return lines.join("\n"); +} + +function buildIndex( + entries: [string, Command][], + groups: Map, +): string { + const lines: string[] = [ + "# bailian-cli (`bl`) command reference", + "", + GENERATED_BANNER, + "", + "Command **details** are in sibling `.md` files in this directory.", + "Use this index for the full quick index and global flags.", + "", + "## Quick index", + "", + "| Command | Description | Detail |", + "| --- | --- | --- |", + ]; + + for (const [path, cmd] of entries) { + const group = topLevel(path); + lines.push(`| \`bl ${path}\` | ${escCell(cmd.description)} | [${group}.md](${group}.md) |`); + } + + lines.push("", "## By group", "", "| Group | Commands | Reference |", "| --- | --- | --- |"); + + const sortedGroups = [...groups.keys()].sort((a, b) => a.localeCompare(b)); + for (const group of sortedGroups) { + const groupEntries = groups.get(group)!; + const names = groupEntries.map(([path]) => path.slice(group.length).trim() || "(root)"); + lines.push( + `| \`${group}\` | ${names.map((n) => `\`${n}\``).join(", ")} | [${group}.md](${group}.md) |`, + ); + } + + lines.push( + "", + "## Global flags", + "", + "Available on every command (in addition to command-specific options):", + "", + formatOptionsTable(GLOBAL_OPTIONS), + "", + "## Notes", + "", + "- Console commands (`app list`, `usage free`, `console call`) require `bl auth login --console`.", + "- Most API commands use `DASHSCOPE_API_KEY` or `bl auth login --api-key`.", + "- Default output: **text** in TTY; **json** when piped.", + "", + ); + + return lines.join("\n"); +} + +function writeReference(): void { + const entries = Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)); + const groups = groupByTopLevel(entries); + + mkdirSync(REF_DIR, { recursive: true }); + + // Remove stale generated files from previous runs + for (const name of readdirSync(REF_DIR)) { + if (name.endsWith(".md")) { + rmSync(join(REF_DIR, name)); + } + } + + const groupNames: string[] = []; + for (const group of [...groups.keys()].sort((a, b) => a.localeCompare(b))) { + const outPath = join(REF_DIR, `${group}.md`); + writeFileSync(outPath, buildGroupFile(group, groups.get(group)!), "utf-8"); + groupNames.push(group); + } + + writeFileSync(INDEX_PATH, buildIndex(entries, groups), "utf-8"); + + console.log( + `Wrote ${INDEX_PATH} + ${groupNames.length} group files (${entries.length} commands)`, + ); +} + +writeReference(); diff --git a/tools/release.mjs b/tools/release.mjs new file mode 100644 index 0000000..548ac97 --- /dev/null +++ b/tools/release.mjs @@ -0,0 +1,351 @@ +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 validatePackages() { + const jsonByKey = new Map(); + + 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("Running format, lint, and type checks"); + run("pnpm", ["run", "check"]); + + step("Building packages"); + buildPackages(); + + 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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c785f30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "esModuleInterop": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6247a4b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + globalSetup: "./packages/cli/tests/e2e/global-setup.ts", + testTimeout: 60_000, + hookTimeout: 60_000, + }, + staged: { + "*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,yaml,yml}": "vp check --fix", + }, + lint: { options: { typeAware: true, typeCheck: true } }, + run: { + cache: true, + }, +});