Compare commits

...

4 Commits

Author SHA1 Message Date
Gong Shiqi fac2b2d18b Merge pull request #121 from modelstudioai/fix/install-doc-non-interactive
docs: fix installation guide flags
2026-07-24 11:19:40 +08:00
若麒 3e249279bc docs: fix installation guide flags 2026-07-24 11:08:06 +08:00
Gong Shiqi f9012a6330 Merge pull request #119 from modelstudioai/fix/base-url-origin-only
fix(core): normalize model base URL to origin
2026-07-23 19:35:24 +08:00
若麒 92ee845bdd fix(core): normalize model base URL to origin 2026-07-23 19:31:02 +08:00
12 changed files with 143 additions and 42 deletions
+1
View File
@@ -68,6 +68,7 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
| 鉴权扩展 | 加 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) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
+1 -1
View File
@@ -98,7 +98,7 @@ npx skills add modelstudioai/cli --all -g
### Agent 安全约束
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
- CI / 非交互环境:使用 `bl ... --non-interactive`通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
---
+1 -1
View File
@@ -43,7 +43,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
解析分工:
- `resolveApiKey()``auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一去除 query、fragment、尾斜杠和已知 SDK/API Base 后缀,同时保留自定义网关前缀
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin仅保留协议、host 和显式端口,去除 path、query、fragment
- `--config` 只选择 config 文件 block不提升该 block 的字段优先级;内置套餐 Profile当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
+42
View File
@@ -0,0 +1,42 @@
# 安装文档变更
## 触发条件
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
## 必查清单
### A. CLI 契约
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
- [ ] Node.js 用户安装要求与 `packages/cli/package.json``engines.node` 一致,不使用根 `package.json` 的开发环境要求
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
### B. 静态副本
- [ ]`INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
### C. 线上验证
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
## 完成后自查
```sh
pnpm -F bailian-cli test -- tests/install-doc.test.ts
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
```
## 常见漏点
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
-`package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
+73
View File
@@ -0,0 +1,73 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
import { monorepoRoot } from "e2e/monorepo-root";
import { describe, expect, test } from "vite-plus/test";
import { commands } from "../src/commands.ts";
const repositoryRoot = monorepoRoot();
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
const cliPackage = JSON.parse(
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
) as {
engines?: { node?: string };
};
function toFlagName(key: string): string {
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
}
function findDocumentedCommand(snippet: string): {
commandPath?: string;
command?: AnyCommand;
} {
const argumentText = snippet.slice("bl ".length).trim();
const commandPath = Object.keys(commands)
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
.find((candidatePath) => {
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
});
return commandPath ? { commandPath, command: commands[commandPath] } : {};
}
function documentedCommandSnippets(): string[] {
const fencedCommands = installGuide
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("bl "));
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
return [...new Set([...fencedCommands, ...inlineCommands])];
}
describe("INSTALL.md", () => {
test("发布包 Node.js 要求与安装文档一致", () => {
const nodeEngine = cliPackage.engines?.node;
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
});
test("示例只使用当前命令支持的 flags", () => {
for (const snippet of documentedCommandSnippets()) {
const { commandPath, command } = findDocumentedCommand(snippet);
const argumentText = snippet.slice("bl ".length).trim();
if (!commandPath || !command) {
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
}
const supportedFlags = {
...GLOBAL_FLAGS,
...(command ? credentialFlagDefs(command) : {}),
...command?.flags,
};
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
const unsupportedFlagNames = usedFlagNames.filter(
(flagName) => !supportedFlagNames.has(flagName),
);
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag${snippet}`).toEqual([]);
}
});
});
+2 -2
View File
@@ -114,10 +114,10 @@ test("POST /api/profile 写命名 profiletimeout 强制为 number空串
expect(readConfigFile("stage")).toMatchObject({
api_key: "sk-stage",
timeout: 90,
base_url: "https://proxy.example.com/team",
base_url: "https://proxy.example.com",
});
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com/team");
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com");
// 空串清除 api_key整块替换
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
@@ -247,10 +247,10 @@ describe("e2e: config", () => {
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
"https://proxy.example.com/bailian",
"https://proxy.example.com",
);
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
"https://proxy.example.com/bailian",
"https://proxy.example.com",
);
const invalidResult = await runCommandE2e(
+4 -18
View File
@@ -1,12 +1,10 @@
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
const KNOWN_API_BASE_SUFFIXES = ["/compatible-mode/v1", "/apps/anthropic"] as const;
/**
* Normalize a model-service base URL while preserving custom gateway prefixes.
* CLI endpoints append their own API paths, so known SDK/API base suffixes must
* not remain in the stored or resolved base URL.
* Normalize a model-service base URL to its origin.
* CLI endpoints append their own API paths, so user-provided paths, query
* parameters, and fragments must not remain in the stored or resolved base URL.
*/
export function normalizeModelBaseUrl(input: string): string {
const trimmed = input.trim();
@@ -21,19 +19,7 @@ export function normalizeModelBaseUrl(input: string): string {
throw invalidModelBaseUrl(input);
}
parsed.search = "";
parsed.hash = "";
let pathname = parsed.pathname.replace(/\/+$/, "");
const knownSuffix = KNOWN_API_BASE_SUFFIXES.find(
(suffix) => pathname === suffix || pathname.endsWith(suffix),
);
if (knownSuffix) {
pathname = pathname.slice(0, -knownSuffix.length).replace(/\/+$/, "");
}
parsed.pathname = pathname || "/";
return parsed.toString().replace(/\/$/, "");
return parsed.origin;
}
function invalidModelBaseUrl(input: string): BailianError {
+1 -1
View File
@@ -46,7 +46,7 @@ test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => {
const file: ConfigFile = { base_url: "https://file.example.com/gateway/" };
expect(resolveModelBaseUrl(src({ flags, env, file }))).toBe("https://flag.example.com");
expect(resolveModelBaseUrl(src({ env, file }))).toBe("https://env.example.com");
expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com/gateway");
expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com");
expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com");
});
+2 -2
View File
@@ -54,9 +54,9 @@ test("ConfigStore/AuthStore 写入前归一化 model Base URL", async () => {
await configStore.write({
base_url: "https://proxy.example.com/bailian/compatible-mode/v1/?query=one#fragment",
});
expect(readConfigFile().base_url).toBe("https://proxy.example.com/bailian");
expect(readConfigFile().base_url).toBe("https://proxy.example.com");
expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe(
"https://proxy.example.com/bailian",
"https://proxy.example.com",
);
const authStore = makeAuthStore(buildSources({}));
+1 -1
View File
@@ -321,7 +321,7 @@ test("parseConfigFile accepts only well-formed http(s) base_url", () => {
expect(
parseConfigFile({ base_url: "https://proxy.example.com/team/compatible-mode/v1?x=1#y" })
.base_url,
).toBe("https://proxy.example.com/team");
).toBe("https://proxy.example.com");
// Previously accepted because the value merely "starts with http".
expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined();
expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined();
+13 -14
View File
@@ -2,28 +2,27 @@ import { expect, test } from "vite-plus/test";
import { BailianError } from "../src/errors/base.ts";
import { normalizeModelBaseUrl } from "../src/config/model-base-url.ts";
test("normalizeModelBaseUrl removes URL noise and known API base suffixes", () => {
test("normalizeModelBaseUrl keeps only the URL origin", () => {
expect(normalizeModelBaseUrl(" https://dashscope.aliyuncs.com/?region=cn#docs ")).toBe(
"https://dashscope.aliyuncs.com",
);
expect(
normalizeModelBaseUrl("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/"),
normalizeModelBaseUrl(
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
),
).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com");
expect(normalizeModelBaseUrl("https://example.com/api/v1/agentstudio")).toBe(
"https://example.com",
);
expect(
normalizeModelBaseUrl("https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic"),
).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com");
normalizeModelBaseUrl(
"https://example.com/api/v1/services/aigc/image-generation/generation?model=qwen#docs",
),
).toBe("https://example.com");
});
test("normalizeModelBaseUrl preserves ports and custom gateway prefixes", () => {
expect(normalizeModelBaseUrl("http://localhost:8080/bailian/")).toBe(
"http://localhost:8080/bailian",
);
expect(
normalizeModelBaseUrl("https://proxy.example.com/bailian/compatible-mode/v1?tenant=one"),
).toBe("https://proxy.example.com/bailian");
expect(normalizeModelBaseUrl("https://proxy.example.com/custom/apps/anthropic#section")).toBe(
"https://proxy.example.com/custom",
);
test("normalizeModelBaseUrl preserves explicit ports while removing paths", () => {
expect(normalizeModelBaseUrl("http://localhost:8080/bailian/")).toBe("http://localhost:8080");
});
test("normalizeModelBaseUrl rejects non-http and malformed URLs", () => {