feat(cli): add pinned standalone Unix installer and release assets

This commit is contained in:
abeatrix
2026-10-19 14:44:25 -07:00
parent 8a83434321
commit 8a2f7e7aeb
14 changed files with 1158 additions and 27 deletions
+8
View File
@@ -205,6 +205,12 @@ jobs:
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Test standalone Unix installer
run: bun test apps/cli/script/install.test.ts apps/cli/script/install-smoke.test.ts apps/cli/script/collect-release-assets.test.ts
- name: Collect standalone Unix release assets
run: bun apps/cli/script/collect-release-assets.ts
- name: Publish to NPM with latest tag
env:
NPM_CONFIG_PROVENANCE: "true"
@@ -260,6 +266,8 @@ jobs:
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.version.outputs.tag }}
fail_on_unmatched_files: true
files: apps/cli/dist/release/*
name: "CLI v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
+36 -1
View File
@@ -1,6 +1,41 @@
# CLI Distribution
The Cline CLI (`cline`) is distributed as compiled binaries via npm. Users run `npm i -g cline` and get a working `cline` command without needing Bun, Zig, or any other runtime installed.
The Cline CLI (`cline`) is distributed as compiled binaries via npm and pinned GitHub CLI releases. Users run `npm i -g cline` and get a working `cline` command without needing Bun, Zig, or any other runtime installed.
## Standalone Unix installer
After this installer and the self-contained runtime packaging in #15080 are
merged and included in a CLI release, macOS and glibc Linux users can install
without Node or Bun. Use an exact published CLI version (replace `X.Y.Z`):
```bash
curl -fsSL https://github.com/cline/cline/releases/download/cli-vX.Y.Z/install.sh | bash -s -- --version X.Y.Z
```
Run that command in an SSH terminal to install on the remote host. The script
selects Apple Silicon/Intel macOS or ARM64/x64 Linux, verifies the release SHA-256
checksum and SDK identity, and atomically activates `~/.local/bin/cline`.
Releases are retained under `~/.cline/packages/standalone/releases` so activation
does not overwrite files used by running processes. Linux x64 uses a baseline
CPU build. Alpine/musl and Windows are outside this installer's scope.
The installer adds its command directory to the detected shell's configuration.
Use `--no-modify-path` to manage PATH yourself. `--release cli-vX.Y.Z` is equivalent
to `--version X.Y.Z`. A compatible existing CLI is reused; package-owned files are
never overwritten. An interactive install can offer removal through its package
manager. `--replace-existing` keeps that external installation and selects the
standalone command, reporting any PATH conflict.
For a local binary, use `bash apps/cli/script/install.sh --binary /path/to/cline`.
To download for another machine without activating a terminal command, pass
`--target x86_64-unknown-linux-gnu --install-dir /path/to/cache --no-modify-path`.
These commands install the runtime only; provider authentication remains `cline auth`.
Older releases do not contain these assets. Nightly publication remains npm-only.
The stable CLI workflow collects the four Unix binaries and their `.sha256`,
`.build-id`, and `.build-epoch` files after platform signing, then attaches them
and `install.sh` to the matching `cli-vX.Y.Z` release. Collection never rebuilds a
binary after checksumming it. This is separate from desktop runtime downloading.
## Why Compiled Binaries?
+2 -1
View File
@@ -103,6 +103,7 @@
"@types/bun": "^1.4.2",
"@types/react": "19.2.14",
"tuistory": "^0.10.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"@cline/llms": "workspace:*"
}
}
+2
View File
@@ -90,6 +90,8 @@ function findFreePort(): Promise<number> {
function getBunTarget(
item: (typeof allTargets)[number],
): Bun.Build.CompileTarget {
if (item.os === "linux" && item.arch === "x64")
return "bun-linux-x64-baseline";
const targetOs = item.os === "win32" ? "windows" : item.os;
return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget;
}
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { createHash } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
collectReleaseAssets,
UNIX_RELEASE_TARGETS,
} from "./collect-release-assets";
test("release assets preserve executable bytes and describe the same SDK build", () => {
const root = mkdtempSync(join(tmpdir(), "cline-release-assets-"));
try {
const dist = join(root, "dist");
const output = join(root, "release");
const identity = { buildId: "sdk-fixture", buildEpochMs: 100 };
for (const { directory, target } of UNIX_RELEASE_TARGETS) {
const path = join(dist, directory, "bin");
mkdirSync(path, { recursive: true });
writeFileSync(
join(path, "cline"),
Buffer.from(`signed executable for ${target}\0`),
);
}
collectReleaseAssets(dist, output, identity);
for (const { directory, target } of UNIX_RELEASE_TARGETS) {
const name = `cline-runtime-${target}`;
const content = readFileSync(join(dist, directory, "bin/cline"));
expect(readFileSync(join(output, name)).equals(content)).toBe(true);
expect(readFileSync(join(output, `${name}.sha256`), "utf8")).toBe(
`${createHash("sha256").update(content).digest("hex")} ${name}\n`,
);
expect(readFileSync(join(output, `${name}.build-id`), "utf8")).toBe(
"sdk-fixture\n",
);
expect(readFileSync(join(output, `${name}.build-epoch`), "utf8")).toBe(
"100\n",
);
}
expect(readFileSync(join(output, "install.sh"), "utf8")).toContain(
"--release <cli-tag>",
);
expect(() =>
collectReleaseAssets(dist, output, { buildId: "", buildEpochMs: 0 }),
).toThrow("Missing runtime SDK");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+65
View File
@@ -0,0 +1,65 @@
// Collect the exact platform executables after signing, with release-pinned metadata.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
export const UNIX_RELEASE_TARGETS = [
{ directory: "cli-darwin-arm64", target: "aarch64-apple-darwin" },
{ directory: "cli-darwin-x64", target: "x86_64-apple-darwin" },
{ directory: "cli-linux-arm64", target: "aarch64-unknown-linux-gnu" },
{ directory: "cli-linux-x64", target: "x86_64-unknown-linux-gnu" },
] as const;
export function collectReleaseAssets(
dist: string,
output: string,
identity: { buildId: string; buildEpochMs?: number },
): void {
if (
!identity.buildId.trim() ||
!Number.isSafeInteger(identity.buildEpochMs) ||
(identity.buildEpochMs ?? 0) <= 0
)
throw new Error("Missing runtime SDK build identity or build epoch");
mkdirSync(output, { recursive: true });
for (const { directory, target } of UNIX_RELEASE_TARGETS) {
const name = `cline-runtime-${target}`;
const destination = join(output, name);
copyFileSync(join(dist, directory, "bin/cline"), destination);
writeFileSync(
`${destination}.sha256`,
`${createHash("sha256").update(readFileSync(destination)).digest("hex")} ${name}\n`,
);
writeFileSync(`${destination}.build-id`, `${identity.buildId}\n`);
writeFileSync(`${destination}.build-epoch`, `${identity.buildEpochMs}\n`);
}
copyFileSync(
resolve(import.meta.dir, "install.sh"),
join(output, "install.sh"),
);
}
if (import.meta.main) {
const dist = resolve(import.meta.dir, "../dist");
const nativeDirectory = `cli-${process.platform}-${process.arch}`;
const info = JSON.parse(
execFileSync(join(dist, nativeDirectory, "bin/cline"), ["--runtime-info"], {
encoding: "utf8",
timeout: 10000,
cwd: tmpdir(),
env: {
...process.env,
BUN_BE_BUN: undefined,
CLINE_HUB_BUILD_ID: undefined,
CLINE_HUB_BUILD_EPOCH_MS: undefined,
CLINE_NO_AUTO_UPDATE: "1",
},
}),
);
if (!info.compiled)
throw new Error("Release metadata must come from a compiled CLI");
collectReleaseAssets(dist, join(dist, "release"), info);
}
+1
View File
@@ -147,6 +147,7 @@ export async function compileCliBinary(
`${bunfsRoot}plugin-runtime`,
),
__CLINE_DASHBOARD_RUNTIME_DIR__: JSON.stringify(`${bunfsRoot}webview`),
CLINE_CLI_COMPILE_TARGET: JSON.stringify(input.bunTarget),
// Inline telemetry/OTEL env vars at build time so the compiled
// binary ships with production telemetry configuration baked in.
...buildInlinedEnvDefines(),
+74
View File
@@ -0,0 +1,74 @@
import { expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { compileCliBinary } from "./compile-binary";
test.skipIf(process.platform === "win32")(
"installs the compiled CLI without Node or Bun and retains its runtime identity",
async () => {
const root = mkdtempSync(join(tmpdir(), "cline-installer-smoke-"));
try {
const binary = join(root, "cline");
await compileCliBinary({
bunTarget:
`bun-${process.platform}-${process.arch}` as Bun.Build.CompileTarget,
outfile: binary,
autoloadLaunchDirectoryConfig: false,
});
const env = {
...process.env,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
CLINE_HUB_DISCOVERY_PATH: join(root, "hub-discovery.json"),
CLINE_NO_AUTO_UPDATE: "1",
CLINE_HUB_BUILD_ID: undefined,
CLINE_HUB_BUILD_EPOCH_MS: undefined,
CLINE_INSTALL_BUILD_ID: undefined,
CLINE_INSTALL_BUILD_EPOCH_MS: undefined,
BUN_BE_BUN: undefined,
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
};
const before = spawnSync(binary, ["--runtime-info"], {
env,
encoding: "utf8",
timeout: 10000,
});
expect(before.status, before.stderr).toBe(0);
const identity = JSON.parse(before.stdout);
expect(identity.compiled).toBe(true);
expect(identity.buildEpochMs, before.stdout).toBeGreaterThan(0);
const install = spawnSync(
"bash",
[
resolve(import.meta.dir, "install.sh"),
"--binary",
binary,
"--no-modify-path",
],
{ env, encoding: "utf8", timeout: 15000 },
);
expect(install.status, install.stderr).toBe(0);
const entry = join(root, ".local/bin/cline");
expect(install.stdout.trim()).toBe(entry);
const after = spawnSync(entry, ["--runtime-info"], {
env,
encoding: "utf8",
timeout: 10000,
});
expect(after.status, after.stderr).toBe(0);
expect(JSON.parse(after.stdout)).toMatchObject({
buildId: identity.buildId,
buildEpochMs: identity.buildEpochMs,
target: identity.target,
compiled: true,
});
expect(existsSync(env.CLINE_HUB_DISCOVERY_PATH)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
60000,
);
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'HELP'
Cline runtime installer (no Node, Bun, or administrator access required)
Usage: install.sh --release <cli-tag> [options]
Default command: ~/.local/bin/cline (versioned standalone releases)
--version <version> Shorthand for --release cli-v<version>
--target <triple> Download for another machine (e.g. an SSH host)
--install-dir <directory> Explicit runtime cache directory
--binary <path> Install a local binary without downloading
--managed Use versioned releases with a stable command entry
--replace-existing Install standalone alongside an external CLI; never uninstall
--no-modify-path Leave shell configuration untouched
-h, --help Show this help
HELP
}
fail() { printf 'Cline install: %s\n' "$*" >&2; exit 1; }
release=''
target=''
install_dir="$HOME/.local/bin"
managed=true
managed_option=false
replace_existing=false
binary=''
modify_path=true
explicit_directory=false
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--managed) managed_option=true; shift ;;
--replace-existing) replace_existing=true; shift ;;
--no-modify-path) modify_path=false; shift ;;
--release|--version|--target|--install-dir|--binary)
[[ $# -ge 2 && -n "$2" && "$2" != --* ]] || fail "$1 requires a value"
case "$1" in
--release) release="$2" ;;
--version) release="cli-v${2#v}" ;;
--target) target="$2" ;;
--install-dir) install_dir="$2"; explicit_directory=true ;;
--binary) binary="$2" ;;
esac
shift 2 ;;
*) fail "unknown option: $1" ;;
esac
done
if [[ "$explicit_directory" == true && "$managed_option" == false ]]; then managed=false; fi
case "$install_dir" in
/*) ;;
*) install_dir="$PWD/$install_dir" ;;
esac
if [[ -z "$target" ]]; then
arch=$(uname -m)
case "$arch" in arm64|aarch64) arch=aarch64 ;; x86_64) ;; *) fail "unsupported architecture: $arch" ;; esac
case "$(uname -s)" in
Darwin) target="$arch-apple-darwin" ;;
Linux)
if [[ -f /etc/alpine-release ]] || { command -v ldd >/dev/null && ldd --version 2>&1 | grep -qi musl; }; then
fail 'Linux runtime requires glibc; musl/Alpine is not supported'
fi
target="$arch-unknown-linux-gnu" ;;
*) fail 'this installer supports macOS and Linux only' ;;
esac
fi
case "$target" in
x86_64-apple-darwin|aarch64-apple-darwin|x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu) ;;
*) fail "unsupported target: $target" ;;
esac
if [[ -n "$release" || -z "$binary" ]]; then
[[ "$release" =~ ^cli-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail 'provide an exact CLI release tag with --release or --version'
fi
[[ -n "$binary" ]] || command -v curl >/dev/null || fail 'curl is required'
# Reuse a compatible terminal installation, including a package-manager
# wrapper. Never modify package-owned files or silently add a second CLI.
if [[ "$explicit_directory" == false ]]; then
existing=$(command -v cline || true)
if [[ -n "$existing" && "$existing" != "$install_dir/cline" ]]; then
# Resolve links only to identify package ownership; never delete their files.
owned="$existing"
for ((links=0; links<40; links++)); do
[[ -L "$owned" ]] || break
link=$(readlink "$owned")
case "$link" in /*) owned="$link" ;; *) owned="$(dirname "$owned")/$link" ;; esac
done
manager=''
case "$owned" in
*/Cellar/cline/*) manager=brew ;;
*/node_modules/cline/*) manager=npm ;;
esac
[[ "$owned" != "$HOME/.bun/"* ]] || manager=bun
if [[ -n "$manager" ]]; then
case "$manager" in
brew) removal=(brew uninstall cline) ;;
npm) removal=(npm uninstall -g cline) ;;
bun) removal=(bun remove -g cline) ;;
esac
printf 'Existing %s installation: %s. To remove it: %s\n' "$manager" "$existing" "${removal[*]}" >&2
if [[ "$replace_existing" == false && -t 2 && -r /dev/tty ]]; then
printf 'Remove this installation and install the standalone CLI? [y/N] ' >&2
answer=''; read -r answer </dev/tty || true
case "$answer" in
y|Y|yes|YES)
"${removal[@]}" >&2 || fail 'package-manager uninstall failed'
[[ ! -e "$existing" ]] || fail 'the original CLI still exists; remove it with its owning package manager before retrying'
replace_existing=true ;;
esac
fi
else
printf 'Existing CLI: %s. Remove it manually or use --replace-existing to select standalone.\n' "$existing" >&2
fi
fi
if [[ -n "$existing" && "$existing" != "$install_dir/cline" && "$replace_existing" == false ]]; then
if [[ -n "$binary" ]]; then
expected_build=$("$binary" --runtime-build-id 2>/dev/null) || fail 'local binary cannot report its SDK identity'
else
expected_build=$(curl --fail --location --silent --show-error --connect-timeout 15 --max-time 30 --proto '=https' --proto-redir '=https' "https://github.com/cline/cline/releases/download/$release/cline-runtime-$target.build-id")
fi
installed_target=$(CLINE_NO_AUTO_UPDATE=1 "$existing" --runtime-target 2>/dev/null) || fail 'installed CLI cannot report its target'
if [[ "$installed_target" != "$target" ]]; then
fail "existing CLI target $installed_target does not match requested $target; use --install-dir for a different machine"
fi
installed_build=$(CLINE_NO_AUTO_UPDATE=1 "$existing" --runtime-build-id 2>/dev/null) || fail "update or remove the existing CLI at $existing before installing; no second copy was installed"
[[ -n "$expected_build" && "$installed_build" == "$expected_build" ]] || fail "the existing CLI at $existing has an incompatible SDK build; update or remove it before installing"
installed_path=$(CLINE_NO_AUTO_UPDATE=1 "$existing" --runtime-path 2>/dev/null) || fail 'installed CLI could not report its native executable'
[[ -f "$installed_path" ]] || fail 'installed CLI reported an invalid native executable'
printf '%s\n' "$installed_path"
exit 0
fi
fi
hash_file() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | awk '{print $1}';
elif command -v shasum >/dev/null; then shasum -a 256 "$1" | awk '{print $1}';
else fail 'sha256sum or shasum is required'; fi
}
mkdir -p "$install_dir"
lock_dir="$install_dir"
if [[ "$managed" == true ]]; then
releases="$HOME/.cline/packages/standalone/releases"
mkdir -p "$releases"
lock_dir="$releases"
fi
# Serialize installs in this directory across desktop/SSH clients. Never
# expose a partial download. Shared installs replace the previous runtime.
lock="$lock_dir/.install-lock"
owner="$$ $(TZ=UTC ps -p $$ -o lstart=)"
owner_is_stale() {
local record="$1" pid="${1%% *}" start
if [[ "$pid" =~ ^[0-9]+$ ]]; then
start=$(TZ=UTC ps -p "$pid" -o lstart= 2>/dev/null | awk '{$1=$1; print}')
[[ "$(printf '%s\n' "$record" | awk '{$1=$1; print}')" != "$pid $start" ]]
else
[[ -n "$2" ]]
fi
}
# Recovery claims use the same owner protocol as the main lock. A dead claim
# is recovered through its own child claim, so interruption at any depth never
# leaves an unrecoverable directory or permits removing a live contender.
recover_lock() (
local path="$1" expected="$2" current aged destination
cd "$path" 2>/dev/null || exit 0
aged=$(find . -prune -mmin +1 2>/dev/null || true)
current=$(cat owner 2>/dev/null || true)
[[ "$current" == "$expected" ]] && owner_is_stale "$current" "$aged" || exit 0
if ! mkdir reclaim 2>/dev/null; then
recover_lock "$path/reclaim" "$(cat reclaim/owner 2>/dev/null || true)"
mkdir reclaim 2>/dev/null || exit 0
fi
printf '%s\n' "$owner" > reclaim/owner
trap 'if [[ "$path" -ef . && "$(cat reclaim/owner 2>/dev/null || true)" == "$owner" ]]; then rm -rf reclaim; fi' EXIT
current=$(cat owner 2>/dev/null || true)
if [[ "$current" == "$expected" && "$path" -ef . ]] && owner_is_stale "$current" "$aged"; then
destination="${path}.abandoned.$$"
if mv "$path" "$destination" 2>/dev/null; then rm -rf "$destination"; fi
fi
)
for ((attempt=0; ; attempt++)); do
if mkdir "$lock" 2>/dev/null; then printf '%s\n' "$owner" > "$lock/owner"; break; fi
previous=$(cat "$lock/owner" 2>/dev/null || true)
aged=$(find "$lock" -prune -mmin +1 2>/dev/null || true)
if owner_is_stale "$previous" "$aged"; then recover_lock "$lock" "$previous"; fi
[[ $attempt -lt 240 ]] || fail "another installer holds $lock"
sleep 0.5
done
temporary=''
cleanup() { [[ -z "$temporary" ]] || rm -rf "$temporary"; [[ "$(cat "$lock/owner" 2>/dev/null || true)" != "$owner" ]] || rm -rf "$lock"; }
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# A managed entry is always our symlink. Never replace an unrelated command.
if [[ "$managed" == true && ( -e "$install_dir/cline" || -L "$install_dir/cline" ) ]]; then
[[ -L "$install_dir/cline" ]] || fail "refusing to replace unrelated file $install_dir/cline"
case "$(readlink "$install_dir/cline")" in
"$HOME/.cline/packages/standalone/releases/"*/cline) ;;
*) fail "refusing to replace unrelated link $install_dir/cline" ;;
esac
fi
cached=false
state_dir="$install_dir"
if [[ "$managed" == true ]]; then
if [[ -z "$binary" ]]; then
for candidate in "$releases/$release-$target-"*; do
[[ -f "$candidate/cline" && -f "$candidate/cline.sha256" && -f "$candidate/release" && -f "$candidate/cline.build-epoch" ]] || continue
[[ "$(cat "$candidate/cline.build-epoch")" =~ ^[0-9]+$ ]] || continue
if [[ "$(cat "$candidate/release")" == "$release/$target" && "$(hash_file "$candidate/cline")" == "$(cat "$candidate/cline.sha256")" ]]; then
state_dir="$candidate"; cached=true; break
fi
done
fi
elif [[ -z "$binary" && -f "$install_dir/cline" && -f "$install_dir/cline.sha256" && -f "$install_dir/release" && -f "$install_dir/cline.build-epoch" ]]; then
if [[ "$(cat "$install_dir/release")" == "$release/$target" && "$(hash_file "$install_dir/cline")" == "$(cat "$install_dir/cline.sha256")" && "$(cat "$install_dir/cline.build-epoch")" =~ ^[0-9]+$ ]]; then cached=true; fi
fi
if [[ "$cached" == false ]]; then
if [[ "$managed" == true ]]; then temporary=$(mktemp -d "$releases/.download.XXXXXX")
else temporary=$(mktemp -d "$install_dir/.download.XXXXXX"); fi
if [[ -n "$binary" ]]; then
[[ -f "$binary" ]] || fail "binary not found: $binary"
cp "$binary" "$temporary/cline"
else
asset="cline-runtime-$target"
url="https://github.com/cline/cline/releases/download/$release/$asset"
printf 'Installing Cline runtime %s (%s)…\n' "$release" "$target" >&2
curl --fail --location --silent --show-error --connect-timeout 15 --max-time 180 --retry 2 --retry-max-time 180 --proto '=https' --proto-redir '=https' "$url.sha256" -o "$temporary/checksum"
expected=$(awk '{print $1}' "$temporary/checksum")
[[ "$expected" =~ ^[a-fA-F0-9]{64}$ ]] || fail 'invalid release checksum'
curl --fail --location --silent --show-error --connect-timeout 15 --max-time 180 --retry 2 --retry-max-time 180 --proto '=https' --proto-redir '=https' "$url" -o "$temporary/cline"
[[ "$(hash_file "$temporary/cline")" == "$expected" ]] || fail 'runtime checksum mismatch'
fi
chmod 755 "$temporary/cline"
hash_file "$temporary/cline" > "$temporary/cline.sha256"
printf '%s\n' "$release/$target" > "$temporary/release"
expected_epoch=${CLINE_INSTALL_BUILD_EPOCH_MS:-}
if [[ -z "$expected_epoch" ]]; then
if [[ -n "$binary" ]]; then expected_epoch=$("$binary" --runtime-build-epoch 2>/dev/null || true)
else expected_epoch=$(curl --fail --location --silent --show-error --connect-timeout 15 --max-time 30 --proto '=https' --proto-redir '=https' "$url.build-epoch"); fi
fi
[[ "$expected_epoch" =~ ^[0-9]+$ ]] || fail 'invalid runtime build epoch'
printf '%s\n' "$expected_epoch" > "$temporary/cline.build-epoch"
state_dir="$temporary"
fi
# Recheck under the activation lock, even when selecting a cached release.
expected_epoch=$(cat "$state_dir/cline.build-epoch")
installed_epoch=$(cat "$install_dir/cline.build-epoch" 2>/dev/null || true)
if [[ "$managed" == true && -L "$install_dir/cline" ]]; then
installed_epoch=$(cat "$(dirname "$(readlink "$install_dir/cline")")/cline.build-epoch" 2>/dev/null || true)
fi
if [[ -x "$install_dir/cline" ]]; then
actual_epoch=$(CLINE_NO_AUTO_UPDATE=1 "$install_dir/cline" --runtime-build-epoch 2>/dev/null || true)
[[ ! "$actual_epoch" =~ ^[0-9]+$ ]] || installed_epoch="$actual_epoch"
fi
if [[ "$installed_epoch" =~ ^[0-9]+$ ]] && (( installed_epoch > expected_epoch )); then fail 'the installed CLI is newer; no downgrade was installed'; fi
if [[ "$managed" == true ]]; then
# Host installs validate identity before the active command can change.
expected_build=${CLINE_INSTALL_BUILD_ID:-}
if [[ -z "$expected_build" ]]; then
if [[ -n "$binary" ]]; then expected_build=$("$binary" --runtime-build-id)
else expected_build=$(cat "$state_dir/build-id" 2>/dev/null || true)
[[ -n "$expected_build" ]] || expected_build=$(curl --fail --location --silent --show-error --connect-timeout 15 --max-time 30 --proto '=https' --proto-redir '=https' "https://github.com/cline/cline/releases/download/$release/cline-runtime-$target.build-id")
fi
fi
[[ -n "$expected_build" && "$(CLINE_NO_AUTO_UPDATE=1 "$state_dir/cline" --runtime-build-id)" == "$expected_build" ]] || fail 'downloaded runtime has an incompatible SDK build'
actual_target=$(CLINE_NO_AUTO_UPDATE=1 "$state_dir/cline" --runtime-target)
if [[ "$actual_target" != "$target" ]]; then
fail 'downloaded runtime target does not match requested target'
fi
[[ "$(CLINE_NO_AUTO_UPDATE=1 "$state_dir/cline" --runtime-build-epoch)" == "$expected_epoch" ]] || fail 'downloaded runtime build epoch does not match metadata'
[[ -f "$state_dir/build-id" ]] || printf '%s\n' "$expected_build" > "$state_dir/build-id"
selected="$releases/${release:-local}-$target-$(cat "$state_dir/cline.sha256")"
if [[ "$cached" == false ]]; then
if [[ -d "$selected" ]]; then
[[ -f "$selected/cline" && "$(hash_file "$selected/cline")" == "$(cat "$state_dir/cline.sha256")" ]] || fail 'existing release directory is corrupt; remove it before retrying'
# Repair interrupted metadata publication without changing running bytes.
for file in cline.sha256 cline.build-epoch release build-id; do mv -f "$state_dir/$file" "$selected/$file"; done
else mv "$temporary" "$selected"; temporary=''; fi
fi
# Stage the link beside the command, so activation is an atomic rename.
link_dir=$(mktemp -d "$install_dir/.activate.XXXXXX")
ln -s "$selected/cline" "$link_dir/cline"
mv -f "$link_dir/cline" "$install_dir/cline"
rmdir "$link_dir"
else
if [[ "$cached" == false ]]; then
for file in cline.sha256 cline.build-epoch release cline; do mv -f "$temporary/$file" "$install_dir/$file"; done
fi
fi
if [[ "$modify_path" == true ]]; then
# Quote paths, including spaces and apostrophes, as shell literals.
quoted="'${install_dir//\'/\'\\\'\'}'"
case "${SHELL##*/}" in
zsh) config="${ZDOTDIR:-$HOME}/.zshrc"; line="export PATH=$quoted:\$PATH" ;;
bash) config="$HOME/.bashrc"; [[ "$(uname -s)" != Darwin ]] || config="$HOME/.bash_profile"; line="export PATH=$quoted:\$PATH" ;;
fish) config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish"; line="fish_add_path $quoted" ;;
*) printf 'Add %s to your PATH to use cline.\n' "$install_dir" >&2; config='' ;;
esac
if [[ -n "$config" ]]; then
mkdir -p "$(dirname "$config")"
touch "$config"
if ! grep -Fxq "$line" "$config"; then printf '\n# Cline\n%s\n' "$line" >> "$config"; fi
printf 'Open a new terminal to use cline.\n' >&2
fi
fi
winner=''
[[ "$modify_path" == false ]] || winner=$(command -v cline || true)
if [[ -n "$winner" && "$winner" != "$install_dir/cline" ]]; then
printf 'PATH currently selects %s. Open a new terminal and check command -v cline.\n' "$winner" >&2
fi
printf '%s\n' "$install_dir/cline"
+536
View File
@@ -0,0 +1,536 @@
import { describe, expect, test } from "bun:test";
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const script = resolve(import.meta.dir, "install.sh");
const release = "cli-v0.0.43-beta.1";
const target = "x86_64-unknown-linux-gnu";
const content = "#!/bin/sh\necho cline\n";
const hash = createHash("sha256").update(content).digest("hex");
function fixture(run: (root: string, env: NodeJS.ProcessEnv) => void) {
const root = mkdtempSync(join(tmpdir(), "cline-install-"));
try {
const commands = join(root, "commands");
mkdirSync(commands);
// Simulate release transport without reaching GitHub or touching the real HOME.
writeFileSync(
join(commands, "curl"),
`#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' "$*" >> "$TEST_ROOT/requests"\n[[ "\${FAIL_DOWNLOAD:-}" != 1 ]] || exit 22\nwhile [[ $# -gt 0 ]]; do\n case "$1" in -o) output="$2"; shift 2 ;; https://*) url="$1"; shift ;; *) shift ;; esac\ndone\ncase "$url" in *.build-id) printf 'sdk-fixture\\n' ;; *.sha256) printf '%s\\n' "\${EXPECTED_HASH} runtime" > "$output" ;; *) printf '#!/bin/sh\\necho cline\\n' > "$output" ;; esac\n`,
);
chmodSync(join(commands, "curl"), 0o755);
run(root, {
...process.env,
HOME: root,
SHELL: "/bin/zsh",
PATH: `${commands}:${process.env.PATH}`,
TEST_ROOT: root,
EXPECTED_HASH: hash,
CLINE_INSTALL_BUILD_EPOCH_MS: "10",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function args(directory: string) {
return [
script,
"--release",
release,
"--target",
target,
"--install-dir",
directory,
"--no-modify-path",
];
}
describe.skipIf(process.platform === "win32")("Bash runtime installer", () => {
test("verifies a download, reuses it offline, and repairs a corrupted cache", () =>
fixture((root, env) => {
const directory = join(root, "runtime with spaces");
execFileSync("bash", args(directory), { env });
expect(readFileSync(join(directory, "cline"), "utf8")).toBe(content);
expect(existsSync(join(root, ".zshrc"))).toBe(false);
execFileSync("bash", args(directory), {
env: { ...env, FAIL_DOWNLOAD: "1" },
});
expect(
readFileSync(join(root, "requests"), "utf8").trim().split("\n"),
).toHaveLength(2);
writeFileSync(join(directory, "cline"), "corrupt");
execFileSync("bash", args(directory), { env });
expect(readFileSync(join(directory, "cline"), "utf8")).toBe(content);
expect(existsSync(join(directory, ".install-lock"))).toBe(false);
}));
test("repairs an interrupted cache with missing or invalid epoch metadata", () =>
fixture((root, env) => {
const directory = join(root, "runtime");
execFileSync("bash", args(directory), { env });
for (const epoch of [undefined, "invalid"]) {
const metadata = join(directory, "cline.build-epoch");
if (epoch) writeFileSync(metadata, epoch);
else rmSync(metadata);
execFileSync("bash", args(directory), { env });
expect(readFileSync(metadata, "utf8").trim()).toBe("10");
execFileSync("bash", args(directory), {
env: { ...env, FAIL_DOWNLOAD: "1" },
});
}
}));
test("rejects corrupt or failed downloads and cleans up without replacing the CLI", () =>
fixture((root, env) => {
for (const failure of [
{ EXPECTED_HASH: "0".repeat(64) },
{ FAIL_DOWNLOAD: "1" },
]) {
const directory = join(root, "failed");
const result = spawnSync("bash", args(directory), {
env: { ...env, ...failure },
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(existsSync(join(directory, "cline"))).toBe(false);
expect(existsSync(join(directory, ".install-lock"))).toBe(false);
}
}));
test("installs a local binary and adds a quoted PATH entry only once", () =>
fixture((root, env) => {
const binary = join(root, "local-cline");
const directory = join(root, "it's my runtime");
writeFileSync(binary, content);
const localArgs = [
script,
"--binary",
binary,
"--target",
target,
"--install-dir",
directory,
];
execFileSync("bash", localArgs, { env });
execFileSync("bash", localArgs, { env });
const config = readFileSync(join(root, ".zshrc"), "utf8");
expect(config.match(/# Cline/g)).toHaveLength(1);
const result = execFileSync(
"bash",
["-c", 'source "$HOME/.zshrc"; command -v cline'],
{ env, encoding: "utf8" },
);
expect(result.trim()).toBe(join(directory, "cline"));
}));
test("reuses an existing native CLI through its wrapper without copying it", () =>
fixture((root, env) => {
const native = join(root, "existing native CLI");
const report = `#!/bin/sh\ncase "$1" in --runtime-target) echo x86_64-unknown-linux-gnu ;; --runtime-build-id) echo sdk-fixture ;; --runtime-path) printf '%s\\n' '${native}' ;; *) exit 1 ;; esac\n`;
writeFileSync(native, report);
chmodSync(native, 0o755);
const wrapper = join(root, "commands", "cline");
writeFileSync(wrapper, report);
chmodSync(wrapper, 0o755);
const result = execFileSync(
"bash",
[script, "--binary", native, "--target", target, "--no-modify-path"],
{ env, encoding: "utf8" },
);
expect(result.trim()).toBe(native);
expect(existsSync(join(root, ".cline", "bin", "cline"))).toBe(false);
expect(existsSync(join(root, "requests"))).toBe(false);
writeFileSync(
wrapper,
report.replace("echo sdk-fixture", "echo incompatible-sdk"),
);
const mismatch = spawnSync(
"bash",
[script, "--binary", native, "--target", target, "--no-modify-path"],
{ env, encoding: "utf8" },
);
expect(mismatch.status).not.toBe(0);
expect(mismatch.stderr).toContain("incompatible SDK build");
expect(existsSync(join(root, ".cline"))).toBe(false);
}));
test("reuses a release-compatible external CLI after fetching only SDK metadata", () =>
fixture((root, env) => {
const native = join(root, "commands", "cline");
writeFileSync(
native,
`#!/bin/sh\ncase "$1" in --runtime-target) echo x86_64-unknown-linux-gnu ;; --runtime-build-id) echo sdk-fixture ;; --runtime-path) printf '%s\\n' '${native}' ;; *) exit 1 ;; esac\n`,
);
chmodSync(native, 0o755);
const result = execFileSync(
"bash",
[script, "--release", release, "--target", target, "--no-modify-path"],
{ env, encoding: "utf8" },
);
expect(result.trim()).toBe(native);
expect(readFileSync(join(root, "requests"), "utf8")).toContain(
".build-id",
);
expect(
readFileSync(join(root, "requests"), "utf8").trim().split("\n"),
).toHaveLength(1);
expect(existsSync(join(root, ".cline"))).toBe(false);
}));
test("recovers an abandoned install lock and blocks a downgrade under the lock", () =>
fixture((root, env) => {
const directory = join(root, "runtime");
const lock = join(directory, ".install-lock");
mkdirSync(lock, { recursive: true });
writeFileSync(join(lock, "owner"), "99999999 dead-process");
execFileSync("bash", args(directory), { env });
expect(existsSync(lock)).toBe(false);
const before = readFileSync(join(directory, "cline"));
writeFileSync(join(directory, "cline.build-epoch"), "20");
const result = spawnSync(
"bash",
[
...args(directory).slice(0, -1),
"--binary",
join(directory, "cline"),
"--no-modify-path",
],
{ env, encoding: "utf8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("no downgrade");
expect(readFileSync(join(directory, "cline"))).toEqual(before);
expect(existsSync(lock)).toBe(false);
}));
test("recovers an abandoned lock for a relative install directory and reuses its cache offline", () =>
fixture((root, env) => {
const directory = join(root, "runtime");
const options = {
cwd: root,
env,
encoding: "utf8" as const,
timeout: 3000,
};
execFileSync("bash", args("runtime"), options);
const lock = join(directory, ".install-lock");
mkdirSync(lock);
writeFileSync(join(lock, "owner"), "99999999 dead-process");
const result = execFileSync("bash", args("runtime"), {
...options,
env: { ...env, FAIL_DOWNLOAD: "1" },
});
expect(realpathSync(result.trim())).toBe(
realpathSync(join(directory, "cline")),
);
expect(existsSync(lock)).toBe(false);
expect(readFileSync(join(directory, "cline"), "utf8")).toBe(content);
}));
test("recovers interrupted claims including nested and ownerless recovery directories", () =>
fixture((root, env) => {
const directory = join(root, "runtime");
const lock = join(directory, ".install-lock");
const claim = join(lock, "reclaim");
const nested = join(claim, "reclaim");
mkdirSync(nested, { recursive: true });
writeFileSync(join(lock, "owner"), "99999999 dead-process");
writeFileSync(join(claim, "owner"), "99999998 dead-process");
const old = new Date(Date.now() - 3600000);
utimesSync(nested, old, old);
execFileSync("bash", args(directory), { env, timeout: 3000 });
expect(existsSync(lock)).toBe(false);
expect(readFileSync(join(directory, "cline"), "utf8")).toBe(content);
}));
test("preserves a live recovery claim while its parent owner is dead", () =>
fixture((root, env) => {
const directory = join(root, "runtime");
const lock = join(directory, ".install-lock");
const claim = join(lock, "reclaim");
mkdirSync(claim, { recursive: true });
writeFileSync(join(lock, "owner"), "99999999 dead-process");
const owner = `${process.pid} ${execFileSync("ps", ["-p", String(process.pid), "-o", "lstart="], { env: { ...env, TZ: "UTC" }, encoding: "utf8" }).trimEnd()}`;
writeFileSync(join(claim, "owner"), owner);
const sleep = join(root, "commands", "sleep");
writeFileSync(sleep, "#!/bin/sh\nexit 1\n");
chmodSync(sleep, 0o755);
const result = spawnSync("bash", args(directory), {
env,
timeout: 3000,
encoding: "utf8",
});
expect(existsSync(claim), result.stderr).toBe(true);
expect(result.status).not.toBe(0);
expect(readFileSync(join(claim, "owner"), "utf8")).toBe(owner);
expect(existsSync(join(directory, "cline"))).toBe(false);
}));
test("preserves a live successor when stale-lock recovery races another installer", () =>
fixture((root, env) => {
const directory = join(root, "runtime");
const lock = join(directory, ".install-lock");
mkdirSync(lock, { recursive: true });
writeFileSync(join(lock, "owner"), "99999999 dead-process");
const owner = `${process.pid} ${execFileSync("ps", ["-p", String(process.pid), "-o", "lstart="], { env: { ...env, TZ: "UTC" }, encoding: "utf8" }).trimEnd()}`;
const commands = join(root, "commands");
writeFileSync(
join(commands, "ps"),
`#!/bin/bash
if [[ "$*" == "-p 99999999 -o lstart=" ]]; then
/bin/mv "$TEST_LOCK" "$TEST_LOCK.old"
/bin/mkdir "$TEST_LOCK"
printf '%s\\n' "$TEST_OWNER" > "$TEST_LOCK/owner"
exit 1
fi
exec /bin/ps "$@"
`,
);
// End the wait after checking recovery; the live owner must retain its lock.
writeFileSync(join(commands, "sleep"), "#!/bin/sh\nexit 1\n");
chmodSync(join(commands, "ps"), 0o755);
chmodSync(join(commands, "sleep"), 0o755);
const result = spawnSync("bash", args(directory), {
env: { ...env, TEST_LOCK: lock, TEST_OWNER: owner },
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(readFileSync(join(lock, "owner"), "utf8").trimEnd()).toBe(owner);
expect(existsSync(join(lock, "reclaim"))).toBe(false);
expect(existsSync(join(directory, "cline"))).toBe(false);
}));
test("rejects an external CLI for a different requested target", () =>
fixture((root, env) => {
const existing = join(root, "commands", "cline");
writeFileSync(existing, "#!/bin/sh\necho x86_64-unknown-linux-gnu\n");
chmodSync(existing, 0o755);
const result = spawnSync(
"bash",
[
script,
"--release",
release,
"--target",
"aarch64-unknown-linux-gnu",
"--no-modify-path",
],
{ env, encoding: "utf8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("does not match requested");
expect(existsSync(join(root, ".cline"))).toBe(false);
}));
test("activates versioned releases through the default command across directory and PATH changes", () =>
fixture((root, env) => {
const binary = join(root, "local-cline");
const report = (epoch: number, build = "sdk-fixture") =>
`#!/bin/sh\ncase "$1" in --runtime-build-id) echo ${build} ;; --runtime-build-epoch) echo ${epoch} ;; --runtime-target) echo ${target} ;; *) echo works ;; esac\n`;
writeFileSync(binary, report(10));
chmodSync(binary, 0o755);
const install = [
script,
"--binary",
binary,
"--target",
target,
"--replace-existing",
];
const first = execFileSync("bash", install, {
env,
encoding: "utf8",
}).trim();
expect(first).toBe(join(root, ".local", "bin", "cline"));
const firstRelease = realpathSync(first);
expect(firstRelease).toContain(
"/.cline/packages/standalone/releases/local-",
);
writeFileSync(binary, report(20));
execFileSync("bash", install, {
env: { ...env, CLINE_INSTALL_BUILD_EPOCH_MS: "20" },
});
const secondRelease = realpathSync(first);
expect(secondRelease).not.toBe(firstRelease);
expect(existsSync(firstRelease)).toBe(true);
const config = readFileSync(join(root, ".zshrc"), "utf8");
expect(config.match(/# Cline/g)).toHaveLength(1);
const output = execFileSync(
"bash",
["-c", 'source "$HOME/.zshrc"; cd /; cline'],
{
env: { ...env, PATH: "/usr/bin:/bin" },
encoding: "utf8",
},
);
expect(output.trim()).toBe("works");
writeFileSync(binary, report(10));
const downgrade = spawnSync("bash", install, { env, encoding: "utf8" });
expect(downgrade.status).not.toBe(0);
expect(downgrade.stderr).toContain("no downgrade");
expect(realpathSync(first)).toBe(secondRelease);
writeFileSync(binary, report(30, "wrong-sdk"));
const mismatch = spawnSync("bash", install, {
env: {
...env,
CLINE_INSTALL_BUILD_EPOCH_MS: "30",
CLINE_INSTALL_BUILD_ID: "sdk-fixture",
},
encoding: "utf8",
});
expect(mismatch.status).not.toBe(0);
expect(mismatch.stderr).toContain("incompatible SDK build");
expect(realpathSync(first)).toBe(secondRelease);
}));
test("reuses a managed release offline and rejects activating an older cached release", () =>
fixture((root, env) => {
const binary = join(root, "native-cline");
const report = (epoch: number) =>
`#!/bin/sh\ncase "$1" in --runtime-build-id) echo sdk-fixture ;; --runtime-build-epoch) echo ${epoch} ;; --runtime-target) echo ${target} ;; esac\n`;
writeFileSync(binary, report(10));
chmodSync(binary, 0o755);
writeFileSync(
join(root, "commands", "curl"),
`#!/bin/bash
set -euo pipefail
[[ "\${FAIL_DOWNLOAD:-}" != 1 ]] || exit 22
printf '%s\\n' "$*" >> "$TEST_ROOT/requests"
while [[ $# -gt 0 ]]; do
case "$1" in -o) output="$2"; shift 2 ;; https://*) url="$1"; shift ;; *) shift ;; esac
done
case "$url" in
*.build-id) echo sdk-fixture ;;
*.sha256) echo "$EXPECTED_HASH runtime" > "$output" ;;
*) cp "$NETWORK_BINARY" "$output" ;;
esac
`,
);
const transport = {
...env,
NETWORK_BINARY: binary,
EXPECTED_HASH: createHash("sha256")
.update(readFileSync(binary))
.digest("hex"),
};
const install = [
script,
"--release",
release,
"--target",
target,
"--replace-existing",
"--no-modify-path",
];
const entry = execFileSync("bash", install, {
env: transport,
encoding: "utf8",
}).trim();
const requests = readFileSync(join(root, "requests"), "utf8");
execFileSync("bash", install, {
env: { ...transport, FAIL_DOWNLOAD: "1" },
});
expect(readFileSync(join(root, "requests"), "utf8")).toBe(requests);
writeFileSync(binary, report(20));
execFileSync(
"bash",
[
script,
"--binary",
binary,
"--target",
target,
"--replace-existing",
"--no-modify-path",
],
{ env: { ...transport, CLINE_INSTALL_BUILD_EPOCH_MS: "20" } },
);
const newer = realpathSync(entry);
const older = spawnSync("bash", install, {
env: { ...transport, FAIL_DOWNLOAD: "1" },
encoding: "utf8",
});
expect(older.status).not.toBe(0);
expect(older.stderr).toContain("no downgrade");
expect(realpathSync(entry)).toBe(newer);
}));
test("never overwrites an unrelated command or uninstalls packages without a terminal", () =>
fixture((root, env) => {
const binary = join(root, "local-cline");
writeFileSync(binary, "#!/bin/sh\necho 10\n");
chmodSync(binary, 0o755);
const directory = join(root, ".local", "bin");
mkdirSync(directory, { recursive: true });
const entry = join(directory, "cline");
writeFileSync(entry, "unrelated command");
const result = spawnSync(
"bash",
[script, "--binary", binary, "--target", target, "--replace-existing"],
{ env, encoding: "utf8" },
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("unrelated file");
expect(readFileSync(entry, "utf8")).toBe("unrelated command");
const packageDir = join(root, "node_modules", "cline", "bin");
mkdirSync(packageDir, { recursive: true });
const oldCli = join(packageDir, "cline");
writeFileSync(oldCli, "#!/bin/sh\nexit 1\n");
chmodSync(oldCli, 0o755);
const removal = join(root, "commands", "npm");
writeFileSync(removal, '#!/bin/sh\ntouch "$TEST_ROOT/uninstalled"\n');
chmodSync(removal, 0o755);
const conflict = spawnSync(
"bash",
[script, "--binary", binary, "--target", target],
{
env: { ...env, PATH: `${packageDir}:${env.PATH}` },
encoding: "utf8",
},
);
expect(conflict.status).not.toBe(0);
expect(conflict.stderr).toContain("npm uninstall -g cline");
expect(existsSync(join(root, "uninstalled"))).toBe(false);
expect(existsSync(oldCli)).toBe(true);
}));
test.each([
["Darwin", "arm64", "aarch64-apple-darwin"],
["Darwin", "x86_64", "x86_64-apple-darwin"],
["Linux", "aarch64", "aarch64-unknown-linux-gnu"],
["Linux", "x86_64", "x86_64-unknown-linux-gnu"],
])("selects the published asset for %s %s", (os, arch, expectedTarget) =>
fixture((root, env) => {
const uname = join(root, "commands/uname");
writeFileSync(
uname,
`#!/bin/sh\ncase "$1" in -s) echo '${os}' ;; -m) echo '${arch}' ;; esac\n`,
);
chmodSync(uname, 0o755);
const directory = join(root, "cache");
execFileSync(
"bash",
[
script,
"--version",
"0.0.43-beta.1",
"--install-dir",
directory,
"--no-modify-path",
],
{ env },
);
expect(readFileSync(join(root, "requests"), "utf8")).toContain(
`/cli-v0.0.43-beta.1/cline-runtime-${expectedTarget}`,
);
}));
test("rejects unpinned tags and unknown options", () =>
fixture((_root, env) => {
expect(
spawnSync("bash", [script, "--release", "cli-latest"], { env }).status,
).not.toBe(0);
expect(spawnSync("bash", [script, "--typo"], { env }).status).not.toBe(0);
}));
});
+21
View File
@@ -47,6 +47,27 @@ if (!isMainThread) {
const bootstrap = process.argv[process.argv.indexOf("--plugin-sandbox") + 1];
if (!bootstrap) throw new Error("Missing plugin sandbox bootstrap path");
await import(bootstrap);
} else if (
[
"--runtime-build-id",
"--runtime-build-epoch",
"--runtime-target",
"--runtime-path",
"--runtime-info",
].some((flag) => process.argv.includes(flag))
) {
const { getCliRuntimeInfo } = await import("./utils/runtime-info");
const info = getCliRuntimeInfo();
const value = process.argv.includes("--runtime-build-id")
? info.buildId
: process.argv.includes("--runtime-build-epoch")
? info.buildEpochMs
: process.argv.includes("--runtime-target")
? info.target
: process.argv.includes("--runtime-path")
? info.executablePath
: JSON.stringify(info);
process.stdout.write(`${value}\n`);
} else if (process.argv.includes("--telemetry-selfcheck")) {
// Inspect the config embedded in this executable without starting a hub,
// loading user settings, or initializing telemetry exporters.
+49
View File
@@ -0,0 +1,49 @@
import { closeSync, openSync, readSync } from "node:fs";
import { resolveHubBuildIdentity } from "@cline/core/hub";
import { version } from "../../package.json";
import { resolveCliLaunchSpec } from "./internal-launch";
declare const CLINE_CLI_COMPILE_TARGET: string | undefined;
export function getCliRuntimeTarget(): string {
if (process.platform === "darwin") {
const fd = openSync(process.execPath, "r");
try {
const header = Buffer.alloc(4);
readSync(fd, header, 0, 4, 0);
if (header.readUInt32BE(0) === 0xcafebabe)
return "universal-apple-darwin";
} finally {
closeSync(fd);
}
return `${process.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
}
return process.platform === "win32"
? `${process.arch === "arm64" ? "aarch64" : "x86_64"}-pc-windows-msvc`
: `${process.arch === "arm64" ? "aarch64" : "x86_64"}-unknown-linux-gnu`;
}
/** Probe an installed CLI without starting a Hub, telemetry, or an update. */
export function getCliRuntimeInfo() {
const compileTarget =
typeof CLINE_CLI_COMPILE_TARGET === "undefined"
? undefined
: CLINE_CLI_COMPILE_TARGET;
return {
cliVersion: version,
...resolveHubBuildIdentity(),
executablePath: process.execPath,
compiled: resolveCliLaunchSpec()?.mode === "compiled",
platform: process.platform,
arch: process.arch,
target: getCliRuntimeTarget(),
cpuBaseline: compileTarget?.includes("baseline") ?? false,
launchEnv: {
...(process.env.NODE_EXTRA_CA_CERTS
? { NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS }
: {}),
...(process.env.CLINE_WRAPPER_PATH
? { CLINE_WRAPPER_PATH: process.env.CLINE_WRAPPER_PATH }
: {}),
},
};
}
+1 -25
View File
@@ -1,31 +1,7 @@
{
"compilerOptions": {
"paths": {
"@cline/agents": ["../sdk/packages/agents/src/index.ts"],
"@cline/agents/*": [
"../sdk/packages/agents/src/*",
"../sdk/packages/agents/src/*/index.ts"
],
"@cline/cline-hub": ["./cline-hub/src/server.ts"],
"@cline/core": ["../sdk/packages/core/src/index.ts"],
"@cline/core/hub/daemon-entry": [
"../sdk/packages/core/src/hub/daemon/entry.ts"
],
"@cline/core/*": [
"../sdk/packages/core/src/*",
"../sdk/packages/core/src/*/index.ts"
],
"@cline/core/telemetry": [
"../sdk/packages/core/src/services/telemetry/index.ts"
],
"@cline/llms": ["../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": ["../sdk/packages/shared/src/storage/index.ts"],
"@cline/shared/db": ["../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../sdk/packages/shared/src/*",
"../sdk/packages/shared/src/*/index.ts"
]
"@cline/cline-hub": ["./cline-hub/src/server.ts"]
}
}
}
+1
View File
@@ -54,6 +54,7 @@
},
"devDependencies": {
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@microsoft/tui-test": "^0.0.2",
"@types/bun": "^1.4.2",