mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
feat(cli): add pinned standalone Unix installer and release assets
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Executable
+307
@@ -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"
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user