mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
merge: merged main to current branch
This commit is contained in:
+12
-5
@@ -77,14 +77,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Recommended — no Node required
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node users / developers (Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
# Recommended: full bailian-* skill family (includes bailian-protocol)
|
||||
|
||||
# Agent skills
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Supported install is `--all -g`. The skills CLI does **not** auto-install skill dependencies. Subset `-s` installs are advanced / not recommended; if you use them, you must include `bailian-protocol` yourself (e.g. `-s bailian-protocol -s bailian-gen`).
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
> Binary install does not require Node.js. `npm install -g` remains fully supported.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -213,8 +219,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# Self-update to latest version
|
||||
# Self-update to latest or a specific version
|
||||
bl update
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
@@ -75,14 +75,20 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 推荐 — 无需本机 Node.js
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows(PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node 用户 / 开发者(需要 Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
# 推荐:安装完整 bailian-* skill 家族(含 bailian-protocol)
|
||||
|
||||
# Agent skills
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 官方支持路径为 `--all -g`。`npx skills` **不会**按 metadata 自动安装依赖。子集 `-s` 为高级用法 / 不推荐;若坚持子集,须自行带上 `bailian-protocol`(例如 `-s bailian-protocol -s bailian-gen`)。
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -213,6 +219,9 @@ bl config set --key timeout --value 600
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
|
||||
# 安装指定版本
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { execSync } from "child_process";
|
||||
import { writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { defineCommand, getConfigDir } from "bailian-cli-core";
|
||||
import { ansi, fetchLatestVersion, type AnsiStyles } from "bailian-cli-runtime";
|
||||
import {
|
||||
BailianError,
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
defineCommand,
|
||||
getConfigDir,
|
||||
getUpdateInstallMethod,
|
||||
type InstallMethod,
|
||||
} from "bailian-cli-core";
|
||||
import {
|
||||
ansi,
|
||||
fetchLatestVersion,
|
||||
fetchBinaryChannelVersion,
|
||||
isValidUpdateTargetVersion,
|
||||
normalizeBinaryVersion,
|
||||
performBinaryUpdate,
|
||||
type AnsiStyles,
|
||||
} from "bailian-cli-runtime";
|
||||
|
||||
const SKILL_SOURCE = "modelstudioai/cli";
|
||||
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
|
||||
|
||||
/** Build the install command for the given npm package. */
|
||||
function detectInstallCommand(npmPackage: string): { cmd: string; label: string } {
|
||||
return { cmd: `npm install -g ${npmPackage}@latest`, label: "npm" };
|
||||
}
|
||||
|
||||
function updateAgentSkill(color: AnsiStyles): void {
|
||||
process.stderr.write("\nUpdating agent skill...\n");
|
||||
try {
|
||||
// Reinstall (not `skills update`) into ~/.agents/skills/ and sync to all agent apps.
|
||||
// `--all` on `skills add` means --skill '*' --agent '*' -y (Cursor, Claude Code, etc.).
|
||||
execSync(SKILL_INSTALL_CMD, { stdio: "inherit" });
|
||||
process.stderr.write(`${color.green("\u2713 Agent skill updated.")}\n`);
|
||||
} catch {
|
||||
@@ -26,56 +35,140 @@ function updateAgentSkill(color: AnsiStyles): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeUpdateState(version: string): void {
|
||||
try {
|
||||
const stateFile = join(getConfigDir(), "update-state.json");
|
||||
writeFileSync(stateFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: version }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLatest(method: InstallMethod, npmPackage: string): Promise<string | null> {
|
||||
if (method === "binary") {
|
||||
return (
|
||||
(await fetchBinaryChannelVersion("latest", 5000)) ??
|
||||
(await fetchLatestVersion(5000, npmPackage))
|
||||
);
|
||||
}
|
||||
return fetchLatestVersion(5000, npmPackage);
|
||||
}
|
||||
|
||||
function binaryReinstallHint(): string {
|
||||
if (process.platform === "win32") {
|
||||
return ` irm ${DEFAULT_INSTALL_PS1_URL} | iex\n`;
|
||||
}
|
||||
return ` curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash\n`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update the CLI to the latest version",
|
||||
description: "Update the CLI to the latest or a specified version",
|
||||
auth: "none",
|
||||
exampleArgs: [""],
|
||||
usageArgs: "[--to <version>]",
|
||||
flags: {
|
||||
to: {
|
||||
type: "string",
|
||||
valueHint: "<version>",
|
||||
description: "Install this exact version instead of the latest",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--to 0.1.14"],
|
||||
validate(flags) {
|
||||
if (flags.to === undefined) return undefined;
|
||||
if (!flags.to.trim()) return "--to requires a non-empty version";
|
||||
if (!isValidUpdateTargetVersion(flags.to)) {
|
||||
return `--to must be a semver version (e.g. 1.13.0, v1.13.0, 0.0.0-beta-<sha>-<YYYYMMDDHHMM>), got: ${flags.to.trim()}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { identity } = ctx;
|
||||
const npmPackage = identity.npmPackage;
|
||||
const binName = identity.binName;
|
||||
const currentVersion = identity.version;
|
||||
const color = ansi(process.stderr);
|
||||
const method = getUpdateInstallMethod(identity);
|
||||
const requestedTo = ctx.flags.to?.trim();
|
||||
const pinnedVersion = requestedTo ? normalizeBinaryVersion(requestedTo) : undefined;
|
||||
|
||||
process.stderr.write(`Current version: ${color.yellow(currentVersion)}\n`);
|
||||
process.stderr.write(`Install method: ${color.dim(method)}\n`);
|
||||
if (pinnedVersion) {
|
||||
process.stderr.write(`Target version: ${color.green(pinnedVersion)}\n`);
|
||||
} else {
|
||||
process.stderr.write("Checking for updates...\n");
|
||||
}
|
||||
|
||||
// Check latest version first
|
||||
process.stderr.write("Checking for updates...\n");
|
||||
const latest = await fetchLatestVersion(5000, npmPackage);
|
||||
|
||||
if (latest && latest === currentVersion) {
|
||||
process.stderr.write(`${color.green(`\u2713 Already up to date (${currentVersion}).`)}\n`);
|
||||
updateAgentSkill(color);
|
||||
if (method === "brew" || method === "winget") {
|
||||
const cmd =
|
||||
method === "brew" ? "brew upgrade bailian-cli" : "winget upgrade Aliyun.BailianCLI";
|
||||
process.stderr.write(
|
||||
`${color.yellow(`This CLI was installed via ${method}. Update with:`)}\n ${cmd}\n`,
|
||||
);
|
||||
if (pinnedVersion) {
|
||||
process.stderr.write(
|
||||
`${color.dim(`Note: --to is not supported for ${method} installs.`)}\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest) {
|
||||
process.stderr.write(`Latest version: ${color.green(latest)}\n\n`);
|
||||
const targetVersion = pinnedVersion ?? (await resolveLatest(method, npmPackage));
|
||||
|
||||
if (!targetVersion) {
|
||||
process.stderr.write(`${color.yellow("Could not determine the latest version.")}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { cmd, label } = detectInstallCommand(npmPackage);
|
||||
process.stderr.write(`Updating ${npmPackage} via ${label}...\n\n`);
|
||||
if (targetVersion === currentVersion) {
|
||||
const message = pinnedVersion
|
||||
? `\u2713 Already at ${currentVersion}.`
|
||||
: `\u2713 Already up to date (${currentVersion}).`;
|
||||
process.stderr.write(`${color.green(message)}\n`);
|
||||
if (method === "npm") updateAgentSkill(color);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pinnedVersion) {
|
||||
process.stderr.write(`Latest version: ${color.green(targetVersion)}\n\n`);
|
||||
} else {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
|
||||
if (method === "binary") {
|
||||
process.stderr.write(`Updating via binary channel...\n\n`);
|
||||
try {
|
||||
const newVer = await performBinaryUpdate(targetVersion);
|
||||
process.stderr.write(
|
||||
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
|
||||
);
|
||||
writeUpdateState(newVer);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const reinstall =
|
||||
error instanceof BailianError && error.hint
|
||||
? error.hint.replace(/^Re-run:\s*/i, "")
|
||||
: binaryReinstallHint().trim();
|
||||
process.stderr.write(`\nAutomatic binary update failed: ${message}\n`);
|
||||
process.stderr.write("Re-run the install script:\n");
|
||||
process.stderr.write(` ${reinstall}\n\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const npmSpec = pinnedVersion ? `${npmPackage}@${pinnedVersion}` : `${npmPackage}@latest`;
|
||||
const cmd = `npm install -g ${npmSpec}`;
|
||||
process.stderr.write(`Updating ${npmPackage} via npm...\n\n`);
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
// Verify the installed version after update
|
||||
try {
|
||||
const rawVer = execSync(`${binName} --version 2>/dev/null`, { encoding: "utf-8" }).trim();
|
||||
// `<bin> --version` outputs "<bin> X.Y.Z" — extract just the version number
|
||||
const newVer = rawVer.replace(new RegExp(`^${binName}\\s+`), "");
|
||||
process.stderr.write(
|
||||
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\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 */
|
||||
}
|
||||
writeUpdateState(newVer);
|
||||
} catch {
|
||||
process.stderr.write(`\n${color.green("\u2713 Update complete.")}\n`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export const AUTH_ROUTES: E2eRouteExports = {
|
||||
"auth logout": "authLogout",
|
||||
};
|
||||
|
||||
export const UPDATE_ROUTES: E2eRouteExports = {
|
||||
update: "update",
|
||||
};
|
||||
|
||||
export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
|
||||
|
||||
export const CONFIG_ROUTES: E2eRouteExports = {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { runCommandE2e } from "./helpers.ts";
|
||||
import { UPDATE_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: update", () => {
|
||||
test("update --help 正常退出并展示 --to", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--to/);
|
||||
expect(stderr).toMatch(/<version>/);
|
||||
});
|
||||
|
||||
test("update --help 包含 --to 示例", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--to 0.1.14");
|
||||
});
|
||||
|
||||
test("update --to 缺值时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--to"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
});
|
||||
|
||||
test("update --to 非法版本时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, [
|
||||
"update",
|
||||
"--to",
|
||||
"not-a-version",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/semver|--to/i);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.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": {
|
||||
|
||||
@@ -16,4 +16,5 @@ export * from "./types/index.ts";
|
||||
export * from "./utils/index.ts";
|
||||
export * from "./telemetry/index.ts";
|
||||
export * from "./advisor/index.ts";
|
||||
export * from "./install/index.ts";
|
||||
export * from "./skills/index.ts";
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* End-user binary download base (OSS). CI publishes release assets and rolling
|
||||
* channel manifests here directly (tools/release/lib/oss-direct-upload.mjs);
|
||||
* no external FC is involved.
|
||||
*
|
||||
* Layout under the base:
|
||||
* v<version>/<asset>.zip —— immutable per-version binaries + SHA256SUMS
|
||||
* manifest.json —— stable install/update pointer (rolling-manifest shape)
|
||||
* latest.json —— stable alias; same body as manifest.json
|
||||
* sync-release.json —— official channel/verify rolling pointer (all bailian-cli
|
||||
* channel publishes overwrite this; npm dist-tag is separate)
|
||||
*
|
||||
* Legacy `{name}.json` files may still exist on CDN; install may resolve them, but
|
||||
* release tooling no longer creates per-dist-tag manifests.
|
||||
*
|
||||
* Override with `BAILIAN_CLI_CDN`.
|
||||
*/
|
||||
export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release";
|
||||
|
||||
/** GitHub Releases base — used when writing manifests attached to gh release assets. */
|
||||
export const GITHUB_RELEASES_BASE = "https://github.com/modelstudioai/cli/releases";
|
||||
|
||||
/** User-facing install entry (docs / update hints); asset downloads still use getCliCdnBase(). */
|
||||
export const DEFAULT_INSTALL_SCRIPT_URL = "https://bailian.aliyun.com/cli/install.sh";
|
||||
export const DEFAULT_INSTALL_PS1_URL = "https://bailian.aliyun.com/cli/install.ps1";
|
||||
|
||||
export function getCliCdnBase(): string {
|
||||
const fromEnv = process.env.BAILIAN_CLI_CDN?.trim();
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||
return DEFAULT_CLI_CDN_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling manifest URL at the CDN base root.
|
||||
* Stable (`latest` / `stable` / empty) → `manifest.json`.
|
||||
* Official verify line → `sync-release.json` (`channel=sync-release`).
|
||||
* Other names still map to `{channel}.json` for backward compatibility only.
|
||||
* All share the same rolling-manifest shape from binary-build.
|
||||
*/
|
||||
export function channelManifestUrl(channel = "latest"): string {
|
||||
const normalized = channel.trim();
|
||||
if (!normalized || normalized === "latest" || normalized === "stable") {
|
||||
return `${getCliCdnBase()}/manifest.json`;
|
||||
}
|
||||
return `${getCliCdnBase()}/${normalized}.json`;
|
||||
}
|
||||
|
||||
/** Immutable per-version asset: `{base}/v{version}/{fileName}`. */
|
||||
export function releaseAssetUrl(version: string, fileName: string): string {
|
||||
const tag = version.startsWith("v") ? version : `v${version}`;
|
||||
return `${getCliCdnBase()}/${tag}/${fileName}`;
|
||||
}
|
||||
|
||||
/** Platform triple used in asset names: `bl-<ver>-<os>-<arch>[.exe]`. */
|
||||
export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix: string } {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let os: string;
|
||||
if (platform === "darwin") os = "darwin";
|
||||
else if (platform === "linux") os = "linux";
|
||||
else if (platform === "win32") os = "windows";
|
||||
else {
|
||||
throw new Error(`Unsupported platform for binary updates: ${platform}`);
|
||||
}
|
||||
|
||||
let normalizedArch: string;
|
||||
if (arch === "arm64") normalizedArch = "arm64";
|
||||
else if (arch === "x64") normalizedArch = "x64";
|
||||
else {
|
||||
throw new Error(`Unsupported architecture for binary updates: ${arch}`);
|
||||
}
|
||||
|
||||
if (os === "linux" && normalizedArch === "arm64") {
|
||||
throw new Error(
|
||||
"linux arm64 is not supported for binary updates; use: npm install -g bailian-cli",
|
||||
);
|
||||
}
|
||||
if (os === "windows" && normalizedArch === "arm64") {
|
||||
throw new Error(
|
||||
"windows arm64 is not supported for binary updates; use: npm install -g bailian-cli",
|
||||
);
|
||||
}
|
||||
|
||||
const fileSuffix = platform === "win32" ? ".exe" : "";
|
||||
return { os, arch: normalizedArch, fileSuffix };
|
||||
}
|
||||
|
||||
/** Release download asset: `bl-<ver>-<os>-<arch>.zip`. */
|
||||
export function binaryAssetFileName(
|
||||
version: string,
|
||||
os: string,
|
||||
arch: string,
|
||||
_exe = false,
|
||||
): string {
|
||||
return `bl-${version}-${os}-${arch}.zip`;
|
||||
}
|
||||
|
||||
/** Uncompressed binary name inside the zip. */
|
||||
export function binaryInnerFileName(
|
||||
version: string,
|
||||
os: string,
|
||||
arch: string,
|
||||
exe = false,
|
||||
): string {
|
||||
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
BINARY_PRODUCT_CLIENT_NAME,
|
||||
detectInstallMethod,
|
||||
getInstallMethod,
|
||||
getUpdateInstallMethod,
|
||||
isCompiledBinary,
|
||||
writeInstallMethodSync,
|
||||
type InstallMethod,
|
||||
type InstallMethodIdentity,
|
||||
} from "./method.ts";
|
||||
export {
|
||||
DEFAULT_CLI_CDN_BASE,
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
GITHUB_RELEASES_BASE,
|
||||
binaryAssetFileName,
|
||||
binaryInnerFileName,
|
||||
channelManifestUrl,
|
||||
detectBinaryPlatform,
|
||||
getCliCdnBase,
|
||||
releaseAssetUrl,
|
||||
} from "./cdn.ts";
|
||||
export { extractZipEntryToFile } from "./unzip-asset.ts";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
|
||||
/** How the CLI was installed on this machine. */
|
||||
export type InstallMethod = "binary" | "npm" | "brew" | "winget" | "unknown";
|
||||
|
||||
/** Product that currently ships standalone binary artifacts (`bl` / `bailian`). */
|
||||
export const BINARY_PRODUCT_CLIENT_NAME = "bailian-cli";
|
||||
|
||||
const INSTALL_METHOD_FILE = "install-method";
|
||||
const VALID_METHODS = new Set<InstallMethod>(["binary", "npm", "brew", "winget", "unknown"]);
|
||||
|
||||
export type InstallMethodIdentity = {
|
||||
clientName: string;
|
||||
};
|
||||
|
||||
function installMethodPath(clientName?: string): string {
|
||||
if (!clientName) return join(getConfigDir(), INSTALL_METHOD_FILE);
|
||||
return join(getConfigDir(), `${INSTALL_METHOD_FILE}.${clientName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when running a Bun-compiled standalone executable
|
||||
* rather than via the Node/npm entry shim.
|
||||
*
|
||||
* Binary entrypoints set `BAILIAN_COMPILED=1` before other code runs.
|
||||
*/
|
||||
export function isCompiledBinary(): boolean {
|
||||
if (process.env.BAILIAN_COMPILED === "1") return true;
|
||||
const execPath = process.execPath.replaceAll("\\", "/");
|
||||
if (/(^|\/)node(\.exe)?$/i.test(execPath) || execPath.includes("/node/")) return false;
|
||||
if (/(^|\/)bun(\.exe)?$/i.test(execPath) || execPath.includes("/.bun/")) return false;
|
||||
return /\/(bl|bailian)(\.exe)?$/i.test(execPath);
|
||||
}
|
||||
|
||||
function parseInstallMethod(raw: string | undefined): InstallMethod | null {
|
||||
if (!raw) return null;
|
||||
const value = raw.trim().toLowerCase() as InstallMethod;
|
||||
return VALID_METHODS.has(value) ? value : null;
|
||||
}
|
||||
|
||||
function readInstallMethodFile(path: string): InstallMethod | null {
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
return parseInstallMethod(raw.split("\n")[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Infer install method when no marker file / env override is present. */
|
||||
export function detectInstallMethod(): InstallMethod {
|
||||
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
if (isCompiledBinary()) {
|
||||
const execPath = process.execPath.replaceAll("\\", "/");
|
||||
if (execPath.includes("/Cellar/") || execPath.includes("/homebrew/")) return "brew";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
return "npm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted install method, falling back to detection.
|
||||
*
|
||||
* When `identity` is provided, prefer `install-method.<clientName>`.
|
||||
* Legacy `~/.bailian/install-method` is only consulted for `bailian-cli`
|
||||
* so other products (e.g. kscli) are not polluted by a shared binary marker.
|
||||
*/
|
||||
export function getInstallMethod(identity?: InstallMethodIdentity): InstallMethod {
|
||||
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
if (identity?.clientName) {
|
||||
const productMethod = readInstallMethodFile(installMethodPath(identity.clientName));
|
||||
if (productMethod) return productMethod;
|
||||
|
||||
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
|
||||
const legacyMethod = readInstallMethodFile(installMethodPath());
|
||||
if (legacyMethod) return legacyMethod;
|
||||
}
|
||||
|
||||
return detectInstallMethod();
|
||||
}
|
||||
|
||||
const legacyMethod = readInstallMethodFile(installMethodPath());
|
||||
if (legacyMethod) return legacyMethod;
|
||||
|
||||
return detectInstallMethod();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install method for update / auto-update routing.
|
||||
* Only `bailian-cli` may follow the binary channel; other products always use npm
|
||||
* even if env or a mistaken marker claims `binary`.
|
||||
*/
|
||||
export function getUpdateInstallMethod(identity: {
|
||||
clientName: string;
|
||||
npmPackage: string;
|
||||
}): InstallMethod {
|
||||
const method = getInstallMethod(identity);
|
||||
if (method === "binary" && identity.npmPackage !== BINARY_PRODUCT_CLIENT_NAME) {
|
||||
return "npm";
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist install method under `~/.bailian/install-method.<clientName>` (best-effort).
|
||||
* For `bailian-cli`, also write the legacy `install-method` file for older readers.
|
||||
*/
|
||||
export function writeInstallMethodSync(
|
||||
method: InstallMethod,
|
||||
identity: InstallMethodIdentity = { clientName: BINARY_PRODUCT_CLIENT_NAME },
|
||||
): void {
|
||||
try {
|
||||
const dir = getConfigDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
writeFileSync(installMethodPath(identity.clientName), `${method}\n`, { mode: 0o600 });
|
||||
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
|
||||
writeFileSync(installMethodPath(), `${method}\n`, { mode: 0o600 });
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Extract a single file entry from a ZIP into `destPath` (overwrites).
|
||||
* Uses yauzl (already a core dependency for dataset ZIP validation).
|
||||
*/
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import * as yauzl from "yauzl";
|
||||
|
||||
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (error, zipfile) => {
|
||||
if (error || !zipfile) {
|
||||
reject(error ?? new Error(`Failed to open zip: ${zipPath}`));
|
||||
return;
|
||||
}
|
||||
resolve(zipfile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function entryBaseName(fileName: string): string {
|
||||
const normalized = fileName.replace(/\\/g, "/");
|
||||
return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `entryName` (or the first non-directory entry) from `zipPath` to `destPath`.
|
||||
* Returns the archive entry basename that was extracted.
|
||||
*/
|
||||
export async function extractZipEntryToFile(
|
||||
zipPath: string,
|
||||
destPath: string,
|
||||
entryName?: string,
|
||||
): Promise<string> {
|
||||
const zipfile = await openZip(zipPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
zipfile.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
const succeed = (baseName: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
zipfile.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resolve(baseName);
|
||||
};
|
||||
|
||||
zipfile.on("error", fail);
|
||||
zipfile.on("end", () => {
|
||||
if (settled) return;
|
||||
fail(
|
||||
new Error(
|
||||
entryName
|
||||
? `Zip entry not found: ${entryName} in ${zipPath}`
|
||||
: `Zip has no file entries: ${zipPath}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
zipfile.on("entry", (current: yauzl.Entry) => {
|
||||
if (settled) return;
|
||||
const name = current.fileName.replace(/\\/g, "/");
|
||||
if (name.endsWith("/")) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
const base = entryBaseName(name);
|
||||
const isMatch = entryName ? name === entryName || base === entryName : true;
|
||||
if (!isMatch) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.openReadStream(current, (streamError, readStream) => {
|
||||
if (streamError || !readStream) {
|
||||
fail(streamError ?? new Error(`Failed to read zip entry: ${current.fileName}`));
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await mkdir(dirname(destPath), { recursive: true });
|
||||
await pipeline(readStream, createWriteStream(destPath));
|
||||
succeed(base);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* 判断当前运行环境。任一条件为真即视为 dev,默认 prod。
|
||||
*
|
||||
* 1. NODE_ENV=development — Node 圈通用约定,测试同学/CI 可显式声明
|
||||
* 2. 当前模块文件路径不在 node_modules 里 — 自动识别从源码运行(pnpm dev /
|
||||
* 2. Bun 编译二进制(BAILIAN_COMPILED=1)— 一律 prod
|
||||
* 3. 当前模块文件路径不在 node_modules 里 — 自动识别从源码运行(pnpm dev /
|
||||
* npm link / 直接 pnpm -F bailian-cli exec tsx src/main.ts),避免开发者忘记设环境变量
|
||||
* 时仍把数据打到 prod
|
||||
*
|
||||
@@ -16,6 +17,10 @@ export function detectEnv(): "dev" | "prod" {
|
||||
cachedEnv = "dev";
|
||||
return cachedEnv;
|
||||
}
|
||||
if (process.env.BAILIAN_COMPILED === "1") {
|
||||
cachedEnv = "prod";
|
||||
return cachedEnv;
|
||||
}
|
||||
cachedEnv = import.meta.url.includes("/node_modules/") ? "prod" : "dev";
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
BINARY_PRODUCT_CLIENT_NAME,
|
||||
detectInstallMethod,
|
||||
getInstallMethod,
|
||||
getUpdateInstallMethod,
|
||||
isCompiledBinary,
|
||||
binaryAssetFileName,
|
||||
binaryInnerFileName,
|
||||
writeInstallMethodSync,
|
||||
} from "../src/install/index.ts";
|
||||
|
||||
test("isCompiledBinary respects BAILIAN_COMPILED", () => {
|
||||
const previous = process.env.BAILIAN_COMPILED;
|
||||
process.env.BAILIAN_COMPILED = "1";
|
||||
expect(isCompiledBinary()).toBe(true);
|
||||
if (previous === undefined) delete process.env.BAILIAN_COMPILED;
|
||||
else process.env.BAILIAN_COMPILED = previous;
|
||||
});
|
||||
|
||||
test("detectInstallMethod respects BAILIAN_INSTALL_METHOD", () => {
|
||||
const previous = process.env.BAILIAN_INSTALL_METHOD;
|
||||
process.env.BAILIAN_INSTALL_METHOD = "binary";
|
||||
expect(detectInstallMethod()).toBe("binary");
|
||||
process.env.BAILIAN_INSTALL_METHOD = "npm";
|
||||
expect(detectInstallMethod()).toBe("npm");
|
||||
if (previous === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = previous;
|
||||
});
|
||||
|
||||
test("getInstallMethod isolates products from shared legacy binary marker", () => {
|
||||
const savedConfigDir = process.env.BAILIAN_CONFIG_DIR;
|
||||
const savedInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-install-method-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
|
||||
try {
|
||||
writeFileSync(join(dir, "install-method"), "binary\n", { mode: 0o600 });
|
||||
|
||||
expect(getInstallMethod({ clientName: BINARY_PRODUCT_CLIENT_NAME })).toBe("binary");
|
||||
expect(getInstallMethod({ clientName: "knowledge-studio-cli" })).toBe("npm");
|
||||
expect(
|
||||
getUpdateInstallMethod({
|
||||
clientName: "knowledge-studio-cli",
|
||||
npmPackage: "knowledge-studio-cli",
|
||||
}),
|
||||
).toBe("npm");
|
||||
} finally {
|
||||
if (savedConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = savedConfigDir;
|
||||
if (savedInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = savedInstallMethod;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writeInstallMethodSync writes product marker and legacy for bailian-cli", () => {
|
||||
const savedConfigDir = process.env.BAILIAN_CONFIG_DIR;
|
||||
const savedInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-install-method-write-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
|
||||
try {
|
||||
writeInstallMethodSync("binary", { clientName: BINARY_PRODUCT_CLIENT_NAME });
|
||||
expect(getInstallMethod({ clientName: BINARY_PRODUCT_CLIENT_NAME })).toBe("binary");
|
||||
expect(getInstallMethod()).toBe("binary");
|
||||
expect(getInstallMethod({ clientName: "knowledge-studio-cli" })).toBe("npm");
|
||||
} finally {
|
||||
if (savedConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = savedConfigDir;
|
||||
if (savedInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = savedInstallMethod;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getUpdateInstallMethod forces npm for non-bailian products even with binary env", () => {
|
||||
const previous = process.env.BAILIAN_INSTALL_METHOD;
|
||||
process.env.BAILIAN_INSTALL_METHOD = "binary";
|
||||
expect(
|
||||
getUpdateInstallMethod({
|
||||
clientName: "knowledge-studio-cli",
|
||||
npmPackage: "knowledge-studio-cli",
|
||||
}),
|
||||
).toBe("npm");
|
||||
expect(
|
||||
getUpdateInstallMethod({
|
||||
clientName: BINARY_PRODUCT_CLIENT_NAME,
|
||||
npmPackage: BINARY_PRODUCT_CLIENT_NAME,
|
||||
}),
|
||||
).toBe("binary");
|
||||
if (previous === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = previous;
|
||||
});
|
||||
|
||||
test("binaryAssetFileName uses per-platform zip", () => {
|
||||
expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.zip");
|
||||
expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64.zip");
|
||||
});
|
||||
|
||||
test("binaryInnerFileName keeps exe suffix inside zip", () => {
|
||||
expect(binaryInnerFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe");
|
||||
expect(binaryInnerFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64");
|
||||
});
|
||||
|
||||
test("channelManifestUrl maps stable to manifest.json", async () => {
|
||||
const { channelManifestUrl } = await import("../src/install/cdn.ts");
|
||||
const previous = process.env.BAILIAN_CLI_CDN;
|
||||
delete process.env.BAILIAN_CLI_CDN;
|
||||
expect(channelManifestUrl()).toBe(
|
||||
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/manifest.json",
|
||||
);
|
||||
expect(channelManifestUrl("latest")).toBe(
|
||||
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/manifest.json",
|
||||
);
|
||||
expect(channelManifestUrl("sync-release")).toBe(
|
||||
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/sync-release.json",
|
||||
);
|
||||
if (previous === undefined) delete process.env.BAILIAN_CLI_CDN;
|
||||
else process.env.BAILIAN_CLI_CDN = previous;
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-studio-cli",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
|
||||
"keywords": [
|
||||
"alibaba-cloud",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-runtime",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, open, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
isCompiledBinary,
|
||||
type CommandPackManager,
|
||||
type CommandPackReport,
|
||||
type Identity,
|
||||
@@ -96,6 +97,17 @@ async function ensureSandboxAt(dir: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function runNpm(args: string[], cwd: string): Promise<void> {
|
||||
const npmCheck = spawnSync("npm", ["--version"], { encoding: "utf-8" });
|
||||
if (npmCheck.status !== 0) {
|
||||
const hint = isCompiledBinary()
|
||||
? "Command Packs need a local npm. Install Node.js, or use `npm install -g bailian-cli` instead of the binary install."
|
||||
: "Install Node.js / npm and retry.";
|
||||
throw new BailianError(
|
||||
"npm is required to install, link, or remove Command Packs, but was not found on PATH.",
|
||||
ExitCode.GENERAL,
|
||||
hint,
|
||||
);
|
||||
}
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn("npm", args, {
|
||||
cwd,
|
||||
|
||||
@@ -67,6 +67,22 @@ export {
|
||||
NPM_PACKAGE,
|
||||
NPM_REGISTRY,
|
||||
} from "./utils/update-checker.ts";
|
||||
export {
|
||||
ensureBinaryPathEntries,
|
||||
fetchBinaryChannelVersion,
|
||||
fetchBinaryChannelManifest,
|
||||
getBinaryBinRoot,
|
||||
getBinaryCurrentPath,
|
||||
getBinaryShareRoot,
|
||||
getBinaryVersionsDir,
|
||||
isValidUpdateTargetVersion,
|
||||
normalizeBinaryVersion,
|
||||
performBinaryUpdate,
|
||||
pruneBinaryVersions,
|
||||
readCurrentVersionDir,
|
||||
resolveBinaryDownloadSpec,
|
||||
switchCurrentToVersion,
|
||||
} from "./utils/binary-update.ts";
|
||||
export {
|
||||
BOOL_FLAG_WATERMARK,
|
||||
BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
|
||||
|
||||
@@ -133,7 +133,11 @@ export const telemetryStage: Middleware = (ctx, next) => {
|
||||
* if `next()` throws, the notice is skipped (no update nag on failure).
|
||||
*/
|
||||
export const versionCheckStage: Middleware = async (ctx, next) => {
|
||||
const pending = checkForUpdate(ctx.identity.version, ctx.identity.npmPackage).catch(() => {});
|
||||
const pending = checkForUpdate(
|
||||
ctx.identity.version,
|
||||
ctx.identity.npmPackage,
|
||||
ctx.identity.clientName,
|
||||
).catch(() => {});
|
||||
await next();
|
||||
await pending;
|
||||
|
||||
@@ -142,7 +146,12 @@ export const versionCheckStage: Middleware = async (ctx, next) => {
|
||||
if (newVersion && !ctx.settings.quiet && !isUpdateCommand) {
|
||||
if (shouldAutoUpdate(newVersion, ctx.identity.version)) {
|
||||
// 大版本差距且目标为稳定版,自动更新
|
||||
await performAutoUpdate(ctx.identity.version, newVersion, ctx.identity.npmPackage);
|
||||
await performAutoUpdate(
|
||||
ctx.identity.version,
|
||||
newVersion,
|
||||
ctx.identity.npmPackage,
|
||||
ctx.identity.clientName,
|
||||
);
|
||||
} else {
|
||||
const color = ansi(process.stderr);
|
||||
process.stderr.write(
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
readdir,
|
||||
readlink,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
BINARY_PRODUCT_CLIENT_NAME,
|
||||
binaryAssetFileName,
|
||||
binaryInnerFileName,
|
||||
channelManifestUrl,
|
||||
detectBinaryPlatform,
|
||||
extractZipEntryToFile,
|
||||
getConfigDir,
|
||||
releaseAssetUrl,
|
||||
writeInstallMethodSync,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
export interface ChannelManifest {
|
||||
version: string;
|
||||
assets?: Record<string, { file?: string; sha256?: string; url?: string; inner?: string }>;
|
||||
}
|
||||
|
||||
/** Product share root: versions/, current, and (on Windows) bin/. */
|
||||
export function getBinaryShareRoot(): string {
|
||||
if (process.env.BAILIAN_SHARE_DIR) return process.env.BAILIAN_SHARE_DIR;
|
||||
if (process.platform === "win32") {
|
||||
return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "bailian-cli");
|
||||
}
|
||||
return join(homedir(), ".local", "share", "bailian-cli");
|
||||
}
|
||||
|
||||
/** PATH directory that should expose `bl` / `bailian`. */
|
||||
export function getBinaryBinRoot(): string {
|
||||
if (process.env.BAILIAN_BIN_DIR) return process.env.BAILIAN_BIN_DIR;
|
||||
if (process.platform === "win32") {
|
||||
return join(getBinaryShareRoot(), "bin");
|
||||
}
|
||||
return join(homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
export function getBinaryVersionsDir(): string {
|
||||
return join(getBinaryShareRoot(), "versions");
|
||||
}
|
||||
|
||||
export function getBinaryCurrentPath(): string {
|
||||
return join(getBinaryShareRoot(), "current");
|
||||
}
|
||||
|
||||
export async function fetchBinaryChannelVersion(
|
||||
channel = "latest",
|
||||
timeoutMs = 5000,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(channelManifestUrl(channel), {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as ChannelManifest;
|
||||
return data.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBinaryChannelManifest(
|
||||
channel = "latest",
|
||||
timeoutMs = 8000,
|
||||
): Promise<ChannelManifest | null> {
|
||||
try {
|
||||
const response = await fetch(channelManifestUrl(channel), {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as ChannelManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip a leading `v` from release-style tags (`v1.2.3` → `1.2.3`). */
|
||||
export function normalizeBinaryVersion(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (/^v\d/i.test(trimmed)) return trimmed.slice(1);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver core + optional pre-release / build metadata.
|
||||
* Accepts this repo's channel betas (`0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`) and
|
||||
* ordinary releases (`1.13.0`, `1.4.2-beta.1`). Optional leading `v` is allowed.
|
||||
*/
|
||||
const UPDATE_TARGET_VERSION_RE =
|
||||
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
|
||||
/** True if `raw` is a usable `--to` target after trim (optional `v` prefix). */
|
||||
export function isValidUpdateTargetVersion(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return UPDATE_TARGET_VERSION_RE.test(trimmed);
|
||||
}
|
||||
|
||||
async function fetchSha256FromVersionSums(
|
||||
version: string,
|
||||
fileName: string,
|
||||
timeoutMs = 8000,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(releaseAssetUrl(version, "SHA256SUMS"), {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const text = await response.text();
|
||||
for (const line of text.split("\n")) {
|
||||
const match = line.trim().match(/^([a-fA-F0-9]{64})\s+(\S+)$/);
|
||||
if (match?.[2] === fileName) return match[1].toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* optional checksum source */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface BinaryDownloadSpec {
|
||||
zipName: string;
|
||||
innerName: string;
|
||||
url: string;
|
||||
expectedSha?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve download URL / names for an exact binary version.
|
||||
* Always targets `v{version}/` assets; never reuses another version's rolling
|
||||
* manifest `url` / `file`. Checksum prefers per-version SHA256SUMS, then the
|
||||
* latest rolling manifest only when it points at the same version.
|
||||
*/
|
||||
export async function resolveBinaryDownloadSpec(
|
||||
targetVersion: string,
|
||||
): Promise<BinaryDownloadSpec> {
|
||||
const version = normalizeBinaryVersion(targetVersion);
|
||||
const { os, arch, fileSuffix } = detectBinaryPlatform();
|
||||
const exe = fileSuffix === ".exe";
|
||||
const zipName = binaryAssetFileName(version, os, arch, exe);
|
||||
const innerName = binaryInnerFileName(version, os, arch, exe);
|
||||
const url = releaseAssetUrl(version, zipName);
|
||||
|
||||
let expectedSha = await fetchSha256FromVersionSums(version, zipName);
|
||||
if (!expectedSha) {
|
||||
const manifest = await fetchBinaryChannelManifest("latest");
|
||||
if (manifest?.version === version) {
|
||||
expectedSha = manifest.assets?.[`${os}-${arch}`]?.sha256;
|
||||
}
|
||||
}
|
||||
|
||||
return { zipName, innerName, url, expectedSha };
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<Buffer> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
await writeFile(dest, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function sha256(buffer: Buffer): string {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function binaryFileName(): string {
|
||||
return process.platform === "win32" ? "bl.exe" : "bl";
|
||||
}
|
||||
|
||||
function aliasFileName(): string {
|
||||
return process.platform === "win32" ? "bailian.exe" : "bailian";
|
||||
}
|
||||
|
||||
/** Resolve which version directory `current` points at, if any. */
|
||||
export async function readCurrentVersionDir(): Promise<string | null> {
|
||||
const currentPath = getBinaryCurrentPath();
|
||||
try {
|
||||
const target = await readlink(currentPath);
|
||||
return target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)
|
||||
? target
|
||||
: join(dirname(currentPath), target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function versionNameFromDir(versionDir: string): string | null {
|
||||
const versionsRoot = getBinaryVersionsDir();
|
||||
const normalizedDir = versionDir.replaceAll("\\", "/");
|
||||
const normalizedRoot = versionsRoot.replaceAll("\\", "/").replace(/\/$/, "");
|
||||
if (!normalizedDir.startsWith(`${normalizedRoot}/`) && normalizedDir !== normalizedRoot) {
|
||||
// Also accept basename match when paths differ by symlink resolution
|
||||
const base = versionDir.replaceAll("\\", "/").split("/").pop();
|
||||
return base && base !== "versions" ? base : null;
|
||||
}
|
||||
return normalizedDir.slice(normalizedRoot.length + 1).split("/")[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point `shareRoot/current` at `versions/<version>/`.
|
||||
* Unix: directory symlink. Windows: directory junction.
|
||||
* Retargets in place so PATH entries that go through `current` keep working.
|
||||
*/
|
||||
export async function switchCurrentToVersion(version: string): Promise<string> {
|
||||
const versionDir = join(getBinaryVersionsDir(), version);
|
||||
const currentPath = getBinaryCurrentPath();
|
||||
await mkdir(getBinaryShareRoot(), { recursive: true });
|
||||
|
||||
try {
|
||||
await unlink(currentPath);
|
||||
} catch {
|
||||
try {
|
||||
await rm(currentPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
}
|
||||
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
if (process.platform === "win32") {
|
||||
await symlink(versionDir, currentPath, "junction");
|
||||
} else {
|
||||
await symlink(versionDir, currentPath);
|
||||
}
|
||||
return versionDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure PATH bin entries resolve through `current` (Codex-style).
|
||||
* - Unix: `~/.local/bin/{bl,bailian}` → `current/bl`
|
||||
* - Windows: `shareRoot/bin` is a junction → `current` (contains bl.exe + bailian.exe)
|
||||
*/
|
||||
export async function ensureBinaryPathEntries(version: string): Promise<void> {
|
||||
const versionDir = join(getBinaryVersionsDir(), version);
|
||||
const binaryName = binaryFileName();
|
||||
const currentBinary = join(getBinaryCurrentPath(), binaryName);
|
||||
const binDir = getBinaryBinRoot();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await ensureWindowsBinJunction(binDir);
|
||||
// Version dir must expose both aliases for the bin junction to work.
|
||||
const primary = join(versionDir, binaryName);
|
||||
const aliasPath = join(versionDir, aliasFileName());
|
||||
try {
|
||||
await lstat(aliasPath);
|
||||
} catch {
|
||||
try {
|
||||
const { link } = await import("node:fs/promises");
|
||||
await link(primary, aliasPath);
|
||||
} catch {
|
||||
await copyFile(primary, aliasPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
for (const name of ["bl", "bailian"] as const) {
|
||||
const linkPath = join(binDir, name);
|
||||
try {
|
||||
await unlink(linkPath);
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
await symlink(currentBinary, linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
function errnoCode(error: unknown): string {
|
||||
if (error && typeof error === "object" && "code" in error) {
|
||||
return String((error as { code?: unknown }).code ?? "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure `shareRoot/bin` is a junction → `current`.
|
||||
*
|
||||
* Install scripts / older layouts may leave a real `bin/` directory with
|
||||
* `bl.exe` inside. Deleting that directory fails with EACCES while this
|
||||
* process is the running image — rename-away first (Windows allows that),
|
||||
* then create the junction. Stale `bin.migrating-*` dirs are best-effort GC.
|
||||
*/
|
||||
export async function ensureWindowsBinJunction(binDir: string): Promise<void> {
|
||||
const currentPath = getBinaryCurrentPath();
|
||||
const { symlink, rename } = await import("node:fs/promises");
|
||||
|
||||
let migratedAside: string | null = null;
|
||||
|
||||
try {
|
||||
const stats = await lstat(binDir);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const target = await readlink(binDir);
|
||||
const resolved =
|
||||
target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)
|
||||
? target
|
||||
: join(dirname(binDir), target);
|
||||
if (
|
||||
resolved.replaceAll("\\", "/").toLowerCase() ===
|
||||
currentPath.replaceAll("\\", "/").toLowerCase()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await unlink(binDir);
|
||||
} else if (stats.isDirectory()) {
|
||||
// Prefer rename over rm: a running bl.exe inside bin locks delete/rm,
|
||||
// but rename of the directory usually succeeds on Windows.
|
||||
migratedAside = `${binDir}.migrating.${process.pid}`;
|
||||
try {
|
||||
await rename(binDir, migratedAside);
|
||||
} catch (renameError) {
|
||||
// Fallback: empty / unlocked real dirs can still be removed.
|
||||
try {
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
migratedAside = null;
|
||||
} catch (rmError) {
|
||||
const code = errnoCode(renameError) || errnoCode(rmError) || "EACCES";
|
||||
throw new Error(
|
||||
`Failed to migrate ${binDir} to a junction pointing at current (${code}). ` +
|
||||
`Close other bl sessions and re-run update, or re-run the install script once.`,
|
||||
{ cause: rmError },
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await unlink(binDir).catch(() => rm(binDir, { recursive: true, force: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
const code = errnoCode(error);
|
||||
if (code && code !== "ENOENT") {
|
||||
if (error instanceof Error && error.message.includes("Failed to migrate")) throw error;
|
||||
throw new Error(
|
||||
`Failed to migrate ${binDir} to a junction pointing at current (${code}). ` +
|
||||
`Close other bl sessions and re-run update, or re-run the install script once.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(dirname(binDir), { recursive: true });
|
||||
await symlink(currentPath, binDir, "junction");
|
||||
|
||||
if (migratedAside) {
|
||||
// Best-effort: locked exes may keep the aside dir until process exit.
|
||||
await rm(migratedAside, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the listed version directory names under `versions/`.
|
||||
* Always preserves directories that are still the live `current` target.
|
||||
*/
|
||||
export async function pruneBinaryVersions(keepVersions: string[]): Promise<void> {
|
||||
const versionsDir = getBinaryVersionsDir();
|
||||
const keep = new Set(keepVersions.filter(Boolean));
|
||||
const currentDir = await readCurrentVersionDir();
|
||||
const currentName = currentDir ? versionNameFromDir(currentDir) : null;
|
||||
if (currentName) keep.add(currentName);
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(versionsDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith(".")) {
|
||||
await rm(join(versionsDir, entry), { recursive: true, force: true }).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
if (keep.has(entry)) continue;
|
||||
await rm(join(versionsDir, entry), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install a newer standalone binary using Codex-style layout:
|
||||
* `versions/<ver>/` + retarget `current` + path entries through `current`.
|
||||
* After a successful switch, prune so only current + previous version remain.
|
||||
*
|
||||
* Does not overwrite a running executable image: old version files stay locked
|
||||
* by the current process; the next invocation follows the updated pointer.
|
||||
*/
|
||||
export async function performBinaryUpdate(targetVersion: string): Promise<string> {
|
||||
const version = normalizeBinaryVersion(targetVersion);
|
||||
const { zipName, innerName, url, expectedSha } = await resolveBinaryDownloadSpec(version);
|
||||
|
||||
const share = getBinaryShareRoot();
|
||||
const versionsDir = getBinaryVersionsDir();
|
||||
await mkdir(join(share, ".tmp"), { recursive: true });
|
||||
await mkdir(versionsDir, { recursive: true });
|
||||
|
||||
const previousVersionDir = await readCurrentVersionDir();
|
||||
const previousVersion = previousVersionDir ? versionNameFromDir(previousVersionDir) : null;
|
||||
|
||||
const tmpZip = join(share, ".tmp", zipName);
|
||||
const buffer = await downloadToFile(url, tmpZip);
|
||||
const actualSha = sha256(buffer);
|
||||
if (expectedSha && expectedSha !== actualSha) {
|
||||
await unlink(tmpZip).catch(() => {});
|
||||
throw new Error(`Checksum mismatch for ${zipName}`);
|
||||
}
|
||||
|
||||
const stagingDir = join(versionsDir, `.staging.${version}.${process.pid}`);
|
||||
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
||||
await mkdir(stagingDir, { recursive: true });
|
||||
|
||||
const binaryName = binaryFileName();
|
||||
const stagingBinary = join(stagingDir, binaryName);
|
||||
const tmpBinary = join(share, ".tmp", `${binaryName}.${process.pid}`);
|
||||
await extractZipEntryToFile(tmpZip, tmpBinary, innerName);
|
||||
await unlink(tmpZip).catch(() => {});
|
||||
await rename(tmpBinary, stagingBinary);
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(stagingBinary, 0o755);
|
||||
} else {
|
||||
const aliasPath = join(stagingDir, aliasFileName());
|
||||
try {
|
||||
const { link } = await import("node:fs/promises");
|
||||
await link(stagingBinary, aliasPath);
|
||||
} catch {
|
||||
await copyFile(stagingBinary, aliasPath);
|
||||
}
|
||||
}
|
||||
|
||||
const versionDir = join(versionsDir, version);
|
||||
await rm(versionDir, { recursive: true, force: true }).catch(() => {});
|
||||
await rename(stagingDir, versionDir);
|
||||
|
||||
await switchCurrentToVersion(version);
|
||||
await ensureBinaryPathEntries(version);
|
||||
|
||||
const keep = [version];
|
||||
if (previousVersion && previousVersion !== version) {
|
||||
keep.push(previousVersion);
|
||||
}
|
||||
await pruneBinaryVersions(keep);
|
||||
|
||||
writeInstallMethodSync("binary", { clientName: BINARY_PRODUCT_CLIENT_NAME });
|
||||
await mkdir(getConfigDir(), { recursive: true });
|
||||
return version;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
|
||||
import {
|
||||
BailianError,
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
getConfigDir,
|
||||
trackingHeaders,
|
||||
getUpdateInstallMethod,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
||||
/** Default npm package; products override per-call via the `npmPackage` argument. */
|
||||
@@ -207,13 +214,14 @@ function errorMessage(err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform auto-update: install latest version globally and update agent skill.
|
||||
* Perform auto-update for npm or binary installs.
|
||||
* Returns true if update succeeded, false otherwise.
|
||||
*/
|
||||
export async function performAutoUpdate(
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
npmPackage: string = NPM_PACKAGE,
|
||||
clientName: string = NPM_PACKAGE,
|
||||
): Promise<boolean> {
|
||||
const isTTY = process.stderr.isTTY;
|
||||
const green = isTTY ? "\x1b[32m" : "";
|
||||
@@ -222,6 +230,11 @@ export async function performAutoUpdate(
|
||||
const dim = isTTY ? "\x1b[2m" : "";
|
||||
const reset = isTTY ? "\x1b[0m" : "";
|
||||
|
||||
const method = getUpdateInstallMethod({ clientName, npmPackage });
|
||||
if (method === "brew" || method === "winget") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [latestMajor] = parseVersion(latestVersion);
|
||||
const [currentMajor] = parseVersion(currentVersion);
|
||||
const isMajorBump = latestMajor > currentMajor;
|
||||
@@ -240,17 +253,34 @@ export async function performAutoUpdate(
|
||||
process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`);
|
||||
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`);
|
||||
|
||||
if (method === "binary") {
|
||||
try {
|
||||
const { performBinaryUpdate } = await import("./binary-update.ts");
|
||||
const newVer = await performBinaryUpdate(latestVersion);
|
||||
writeState({ lastChecked: Date.now(), latestVersion: newVer });
|
||||
process.stderr.write(` ${green}✓ Update complete: ${currentVersion} → ${newVer}${reset}\n`);
|
||||
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
||||
pendingNotification = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
|
||||
const reinstall =
|
||||
err instanceof BailianError && err.hint
|
||||
? err.hint.replace(/^Re-run:\s*/i, "")
|
||||
: process.platform === "win32"
|
||||
? `irm ${DEFAULT_INSTALL_PS1_URL} | iex`
|
||||
: `curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash`;
|
||||
process.stderr.write(` ${yellow} Re-run:${reset} ${cyan}${reinstall}${reset}\n\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const cmd = `npm install -g ${npmPackage}@latest`;
|
||||
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
|
||||
// Verify the actually-installed version by reading the global package.json.
|
||||
// We must NOT rely on `bl --version`: the user may run via npx, a local
|
||||
// install, or a custom bin name, in which case `bl` on PATH points at the
|
||||
// wrong binary (or nothing at all). Reading the installed package directly
|
||||
// is correct regardless of how the CLI was invoked.
|
||||
let newVer: string | null = null;
|
||||
try {
|
||||
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
|
||||
@@ -264,8 +294,6 @@ export async function performAutoUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
// Update cached state. writeState swallows errors internally: state caching
|
||||
// is non-critical and must never break the CLI startup path.
|
||||
writeState({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion });
|
||||
|
||||
process.stderr.write(
|
||||
@@ -273,26 +301,20 @@ export async function performAutoUpdate(
|
||||
);
|
||||
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
||||
|
||||
// Update agent skill
|
||||
try {
|
||||
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
|
||||
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
|
||||
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
|
||||
} catch (err) {
|
||||
// Surface the reason the skill sync failed rather than swallowing it
|
||||
// silently, but keep degradation: the CLI itself already updated.
|
||||
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
|
||||
process.stderr.write(
|
||||
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear pending notification
|
||||
pendingNotification = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
// npm install failure — most commonly EACCES (global installs often need
|
||||
// elevated permissions). Tell the user *why* it failed, not just *that*.
|
||||
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
|
||||
process.stderr.write(
|
||||
` ${yellow} If this is a permissions error (EACCES), retry with sudo or fix npm perms.${reset}\n`,
|
||||
@@ -305,16 +327,26 @@ export async function performAutoUpdate(
|
||||
export async function checkForUpdate(
|
||||
currentVersion: string,
|
||||
npmPackage: string = NPM_PACKAGE,
|
||||
clientName: string = NPM_PACKAGE,
|
||||
): Promise<void> {
|
||||
const state = readState();
|
||||
const now = Date.now();
|
||||
|
||||
// Inside the throttle window (CHECK_INTERVAL_MS since the last fetch): no
|
||||
// network call and no notice. The state file is global, so the notice fires at
|
||||
// most once per window across all processes/sessions — not once per command.
|
||||
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) return;
|
||||
|
||||
const latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
||||
const method = getUpdateInstallMethod({ clientName, npmPackage });
|
||||
let latest: string | null = null;
|
||||
if (method === "binary") {
|
||||
try {
|
||||
const { fetchBinaryChannelVersion } = await import("./binary-update.ts");
|
||||
latest = await fetchBinaryChannelVersion("latest", FETCH_TIMEOUT_MS);
|
||||
} catch {
|
||||
latest = null;
|
||||
}
|
||||
if (!latest) latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
||||
} else {
|
||||
latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
||||
}
|
||||
if (!latest) return;
|
||||
|
||||
writeState({ lastChecked: now, latestVersion: latest });
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readlinkSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
ensureBinaryPathEntries,
|
||||
ensureWindowsBinJunction,
|
||||
getBinaryBinRoot,
|
||||
getBinaryCurrentPath,
|
||||
getBinaryShareRoot,
|
||||
getBinaryVersionsDir,
|
||||
isValidUpdateTargetVersion,
|
||||
normalizeBinaryVersion,
|
||||
pruneBinaryVersions,
|
||||
readCurrentVersionDir,
|
||||
resolveBinaryDownloadSpec,
|
||||
switchCurrentToVersion,
|
||||
} from "../src/utils/binary-update.ts";
|
||||
import { binaryAssetFileName, releaseAssetUrl } from "bailian-cli-core";
|
||||
|
||||
function withTempBinaryRoots(run: () => Promise<void>): Promise<void> {
|
||||
const root = mkdtempSync(join(tmpdir(), "bl-binary-layout-"));
|
||||
const previousShare = process.env.BAILIAN_SHARE_DIR;
|
||||
const previousBin = process.env.BAILIAN_BIN_DIR;
|
||||
process.env.BAILIAN_SHARE_DIR = root;
|
||||
process.env.BAILIAN_BIN_DIR = join(root, "path-bin");
|
||||
return run().finally(() => {
|
||||
if (previousShare === undefined) delete process.env.BAILIAN_SHARE_DIR;
|
||||
else process.env.BAILIAN_SHARE_DIR = previousShare;
|
||||
if (previousBin === undefined) delete process.env.BAILIAN_BIN_DIR;
|
||||
else process.env.BAILIAN_BIN_DIR = previousBin;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function seedVersion(version: string): string {
|
||||
const versionDir = join(getBinaryVersionsDir(), version);
|
||||
mkdirSync(versionDir, { recursive: true });
|
||||
const binaryName = process.platform === "win32" ? "bl.exe" : "bl";
|
||||
writeFileSync(join(versionDir, binaryName), "fake-binary");
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(join(versionDir, "bailian.exe"), "fake-binary");
|
||||
}
|
||||
return versionDir;
|
||||
}
|
||||
|
||||
test("share/bin roots respect BAILIAN_* overrides", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
expect(getBinaryShareRoot()).toContain("bl-binary-layout-");
|
||||
expect(getBinaryBinRoot()).toBe(join(getBinaryShareRoot(), "path-bin"));
|
||||
expect(getBinaryCurrentPath()).toBe(join(getBinaryShareRoot(), "current"));
|
||||
});
|
||||
});
|
||||
|
||||
test("switchCurrentToVersion retargets current pointer", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
const firstDir = seedVersion("1.0.0");
|
||||
await switchCurrentToVersion("1.0.0");
|
||||
expect(await readCurrentVersionDir()).toBe(firstDir);
|
||||
|
||||
const secondDir = seedVersion("1.1.0");
|
||||
await switchCurrentToVersion("1.1.0");
|
||||
expect(await readCurrentVersionDir()).toBe(secondDir);
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneBinaryVersions keeps current and requested previous only", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
seedVersion("1.0.0");
|
||||
seedVersion("1.1.0");
|
||||
seedVersion("1.2.0");
|
||||
await switchCurrentToVersion("1.2.0");
|
||||
await pruneBinaryVersions(["1.2.0", "1.1.0"]);
|
||||
|
||||
expect(existsSync(join(getBinaryVersionsDir(), "1.2.0"))).toBe(true);
|
||||
expect(existsSync(join(getBinaryVersionsDir(), "1.1.0"))).toBe(true);
|
||||
expect(existsSync(join(getBinaryVersionsDir(), "1.0.0"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeBinaryVersion strips release-style v prefix", () => {
|
||||
expect(normalizeBinaryVersion("v1.2.3")).toBe("1.2.3");
|
||||
expect(normalizeBinaryVersion("1.2.3")).toBe("1.2.3");
|
||||
expect(normalizeBinaryVersion(" 0.1.14-channel.1 ")).toBe("0.1.14-channel.1");
|
||||
});
|
||||
|
||||
test("isValidUpdateTargetVersion accepts semver and channel betas", () => {
|
||||
expect(isValidUpdateTargetVersion("1.13.0")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("v1.13.0")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("1.4.2-beta.1")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("0.0.0-beta-be3033b-202607311142")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("v0.0.0-beta-be3033b-202607311142")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("latest")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("foo")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("")).toBe(false);
|
||||
// Path-traversal / path-separator inputs must never reach versions/<ver>/
|
||||
expect(isValidUpdateTargetVersion("../../../..")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("..\\..\\..")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2.3/../x")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2.3\\..\\x")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("/etc/passwd")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("versions/../../tmp")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2.3/")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("..")).toBe(false);
|
||||
});
|
||||
|
||||
test("resolveBinaryDownloadSpec targets version assets, not latest manifest url", async () => {
|
||||
const version = "0.1.14-channel.1";
|
||||
const { detectBinaryPlatform } = await import("bailian-cli-core");
|
||||
const { os, arch } = detectBinaryPlatform();
|
||||
const zipName = binaryAssetFileName(version, os, arch);
|
||||
const sumsSha = "deadbeef".repeat(8);
|
||||
const previousFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/SHA256SUMS")) {
|
||||
return new Response(`${sumsSha} ${zipName}\n`, { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/manifest.json")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
version: "9.9.9",
|
||||
assets: {
|
||||
[`${os}-${arch}`]: {
|
||||
file: `bl-9.9.9-${os}-${arch}.zip`,
|
||||
sha256: "aa".repeat(32),
|
||||
url: "https://example.invalid/wrong.zip",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const spec = await resolveBinaryDownloadSpec(`v${version}`);
|
||||
expect(spec.zipName).toBe(zipName);
|
||||
expect(spec.url).toBe(releaseAssetUrl(version, zipName));
|
||||
expect(spec.url).not.toContain("wrong.zip");
|
||||
expect(spec.expectedSha).toBe(sumsSha);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureBinaryPathEntries wires PATH entries through current", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
seedVersion("2.0.0");
|
||||
await switchCurrentToVersion("2.0.0");
|
||||
await ensureBinaryPathEntries("2.0.0");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const binRoot = getBinaryBinRoot();
|
||||
const target = readlinkSync(binRoot);
|
||||
expect(target.replaceAll("/", "\\").toLowerCase()).toBe(
|
||||
getBinaryCurrentPath().replaceAll("/", "\\").toLowerCase(),
|
||||
);
|
||||
} else {
|
||||
const blLink = readlinkSync(join(getBinaryBinRoot(), "bl"));
|
||||
const bailianLink = readlinkSync(join(getBinaryBinRoot(), "bailian"));
|
||||
expect(blLink).toBe(join(getBinaryCurrentPath(), "bl"));
|
||||
expect(bailianLink).toBe(join(getBinaryCurrentPath(), "bl"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("ensureWindowsBinJunction migrates a real bin directory via rename", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
seedVersion("3.0.0");
|
||||
await switchCurrentToVersion("3.0.0");
|
||||
|
||||
const binRoot = getBinaryBinRoot();
|
||||
mkdirSync(binRoot, { recursive: true });
|
||||
writeFileSync(join(binRoot, "bl.exe"), "old-copy");
|
||||
|
||||
await ensureWindowsBinJunction(binRoot);
|
||||
|
||||
const target = readlinkSync(binRoot);
|
||||
expect(target.replaceAll("\\", "/").toLowerCase()).toBe(
|
||||
getBinaryCurrentPath().replaceAll("\\", "/").toLowerCase(),
|
||||
);
|
||||
expect(existsSync(`${binRoot}.migrating.${process.pid}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,11 @@ fs.appendFileSync(
|
||||
}) + "\\n",
|
||||
);
|
||||
|
||||
if (args[0] === "--version") {
|
||||
process.stdout.write("10.0.0\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const manifestPath = path.join(cwd, "package.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
manifest.dependencies ??= {};
|
||||
@@ -205,8 +210,9 @@ test("installs once on success and restores the previous version after validatio
|
||||
env: Record<string, string | null>;
|
||||
},
|
||||
);
|
||||
expect(successfulCalls).toHaveLength(1);
|
||||
expect(successfulCalls[0]?.env).toEqual({
|
||||
// runNpm probes `npm --version` before each install/uninstall.
|
||||
expect(successfulCalls.map((call) => call.args[0])).toEqual(["--version", "install"]);
|
||||
expect(successfulCalls[1]?.env).toEqual({
|
||||
registry: "https://registry.example.test",
|
||||
catalog: null,
|
||||
recursive: null,
|
||||
@@ -228,14 +234,11 @@ test("installs once on success and restores the previous version after validatio
|
||||
env: Record<string, string | null>;
|
||||
},
|
||||
);
|
||||
expect(rollbackCalls).toHaveLength(2);
|
||||
expect(rollbackCalls[0]?.args.slice(0, 2)).toEqual([
|
||||
"install",
|
||||
"@ali/bailian-plugin-agent@broken",
|
||||
]);
|
||||
expect(rollbackCalls[1]?.args.slice(0, 2)).toEqual([
|
||||
"install",
|
||||
"@ali/bailian-plugin-agent@1.0.0",
|
||||
expect(rollbackCalls.map((call) => call.args.slice(0, 2))).toEqual([
|
||||
["--version"],
|
||||
["install", "@ali/bailian-plugin-agent@broken"],
|
||||
["--version"],
|
||||
["install", "@ali/bailian-plugin-agent@1.0.0"],
|
||||
]);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
|
||||
Reference in New Issue
Block a user