mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
refactor: Start the desktop and SSH remote hubs through the bundled Cline CLI (#14792)
* Let hosts choose the hub daemon launcher and defer spawn-only setup Adds setHubDaemonLauncher so an embedding process can start the hub daemon through a bundled Cline CLI instead of its own executable, and a beforeSpawn hook on ensureDetachedHubServer that only runs when a new daemon is actually started. * Add programmatic options to cline hub ensure hub ensure/start gain --json (URL + auth token), --allow-port-fallback, --no-connectors and --login-shell-path, and the hub command accepts --discovery-path for a dedicated discovery record. These let the desktop app and SSH remotes start or reuse a hub through the CLI. Also extracts the standalone binary compile step into script/compile-binary.ts so other packages can build the CLI binary. * Run SSH remote hubs through the Cline CLI SSH remotes now upload the Cline CLI binary and start or reuse the remote hub with 'cline hub ensure --json --discovery-path ...', stopping it with 'cline hub stop'. The dedicated remote helper entrypoint is removed; cleanup records from older releases still stop through the old flag. * Run the desktop backend on the bundled Cline CLI The desktop app no longer compiles its own executable. It bundles the self-contained Cline CLI (externalBin cline-cli) plus the backend as a JS bundle that runs on the CLI's embedded runtime. At startup the backend has the CLI run 'hub ensure', which reuses a compatible healthy Hub or starts one, and any Hub it starts later also goes through the CLI binary. Linux CLI copies are bundled for SSH remotes. * Launch the desktop backend through the bundled Cline CLI The Tauri shell resolves the bundled cline-cli and the backend bundle (per-platform resource layouts) and starts 'cline-cli run' on it. The Windows installer stops users of cline-cli.exe, and also stops and removes the backend executable of earlier releases on upgrade. * Recognize the CLI-hosted desktop backend in cline doctor --fix * Update desktop publish workflow and docs for the bundled Cline CLI Telemetry selfchecks and smoke tests run the backend bundle on the bundled CLI, and the Windows signature gate verifies cline-cli.exe. * fix(desktop): address runtime packaging review findings * fix(desktop): preserve failed hub cleanup and handle exited daemons * fix(hub): distinguish missing discovery records from read failures * fix(hub): preserve discovery recovery while enforcing strict shutdown reads * fix(cli): do not count hub commands as CLI activations (#14870) * fix(cli): do not count hub commands as CLI activations The desktop app runs `cline-cli hub ensure` on every launch and the SSH remote flow runs `cline hub ensure` on every connect. Both went through runCli(), which captured user.extension_activated unconditionally, so each desktop launch was double-counted as a CLI activation and every SSH remote host (with its own distinct id) showed up as a new CLI user. Skip the activation capture for the hub subcommand; it is plumbing other clients invoke, not a user activation. Hub-owned task telemetry is unaffected since the daemon path never enters runCli(). * test(cli): clear the hub command mock between main.test.ts cases * test(cli): stop stubbing the hub command module in main.test.ts The module-wide vi.mock("./commands/hub") replaced createHubCommand with a no-op, so `cline hub dashboard` (routed through createHubCommand) never reached the dashboard mock and three dashboard tests failed. Drop the module mock and assert the activation exclusion through `hub dashboard --no-open`, which is already mocked. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases ship three platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Linux: x64 `.deb` and `.rpm` packages (`<Product>_<version>_amd64.deb`, `<Product>_<version>_x86_64.rpm`) built on Ubuntu 22.04 in the `build-linux` job, deb + rpm only (no AppImage: linuxdeploy cannot process the Bun-compiled sidecar). Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
Desktop releases ship three platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Linux: x64 `.deb` and `.rpm` packages (`<Product>_<version>_amd64.deb`, `<Product>_<version>_x86_64.rpm`) built on Ubuntu 22.04 in the `build-linux` job, deb + rpm only (no AppImage: linuxdeploy cannot process the Bun-compiled Cline CLI the app bundles). Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
|
||||
## Release contract
|
||||
|
||||
@@ -50,12 +50,12 @@ For a **beta** release, work on `desktop-experimental` (check out `origin/deskto
|
||||
|
||||
```sh
|
||||
# stable (on main):
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml
|
||||
# beta (on desktop-experimental):
|
||||
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml
|
||||
```
|
||||
|
||||
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
The desktop backend and the bundled Cline CLI (which runs the Hub) are built from the monorepo's `@cline/core` and friends, so SDK and CLI changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
@@ -129,7 +129,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the bundled Cline CLI is lipo'd by `build-desktop-runtime.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
|
||||
@@ -335,12 +335,12 @@ jobs:
|
||||
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
|
||||
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
|
||||
# this step and inlines these values into the binary via `--define`
|
||||
# (scripts/telemetry-define-args.ts); a packaged app launched from
|
||||
# Finder/the Dock has no runtime env, so build-time inlining is the
|
||||
# only way the shipped sidecar can ever report telemetry.
|
||||
# Telemetry config for the desktop backend and bundled Cline CLI.
|
||||
# Tauri's beforeBuildCommand (`bun run build` -> build:runtime)
|
||||
# builds both during this step and inlines these values via
|
||||
# `--define` (scripts/telemetry-define-args.ts); a packaged app
|
||||
# launched from Finder/the Dock has no runtime env, so build-time
|
||||
# inlining is the only way the shipped backend can report telemetry.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
@@ -368,10 +368,11 @@ jobs:
|
||||
# bare DMG. macOS runners have a GUI session, so opt out of the skip.
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
# carries both slices before anything is published. A single-arch
|
||||
# sidecar would otherwise ship fine and only crash on the other arch.
|
||||
# Tauri lipos the main binary itself but the bundled Cline CLI is merged
|
||||
# by our own build-desktop-runtime.ts, so assert every Mach-O in the
|
||||
# bundle really carries both slices before anything is published. A
|
||||
# single-arch CLI would otherwise ship fine and only crash on the other
|
||||
# arch.
|
||||
- name: Verify bundle is a universal binary
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
@@ -450,49 +451,57 @@ jobs:
|
||||
echo "Updater endpoint verified: ${WANT:-disabled}"
|
||||
|
||||
# Guardrail: assert the telemetry config actually made it into the
|
||||
# compiled sidecar. Missing env on the build step (or a regression in
|
||||
# bundled desktop backend. Missing env on the build step (or a regression in
|
||||
# the --define inlining) would otherwise ship a release with telemetry
|
||||
# silently disabled — exactly what happened for every release before
|
||||
# this check existed. Being enabled is not enough on its own: an empty,
|
||||
# malformed, or non-http(s) OTLP endpoint would still drop every event
|
||||
# at runtime (the SDK exporters speak OTLP http/json only), so the
|
||||
# selfcheck must also report a usable endpoint host.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
- name: Verify CLI and desktop backend telemetry configs were inlined
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build, sign, and notarize desktop bundle' step and the"
|
||||
echo "--define inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
|
||||
echo "every event would be dropped at runtime. Check the"
|
||||
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
for COMPONENT in cli backend; do
|
||||
if [ "$COMPONENT" = cli ]; then
|
||||
SELFCHECK=$(./src-tauri/bin/cline-cli-universal-apple-darwin --telemetry-selfcheck)
|
||||
else
|
||||
SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-universal-apple-darwin run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck)
|
||||
fi
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged $COMPONENT reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build, sign, and notarize desktop bundle' step and the"
|
||||
echo "--define inlining in scripts/build-desktop-runtime.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged $COMPONENT reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
|
||||
echo "every event would be dropped at runtime. Check the"
|
||||
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Boot the sidecar that will actually ship — built with the production
|
||||
# --target, --minify and telemetry defines, on the OS it will run on —
|
||||
# against an isolated Hub, and require its ready line plus a healthy
|
||||
# /health. Desktop 0.0.31 shipped a sidecar whose Hub daemon loaded a
|
||||
# Boot the Cline CLI and desktop backend that will actually ship — built
|
||||
# with the production --target, --minify and telemetry defines, on the
|
||||
# OS they will run on — against an isolated Hub started by
|
||||
# `cline hub ensure`, and require the backend's ready line plus a
|
||||
# healthy /health. Desktop 0.0.31 shipped a backend whose Hub daemon loaded a
|
||||
# second copy of @cline/core with a different build id, so the app
|
||||
# retired its own Hub on every launch and never got past "backend
|
||||
# unavailable". PR CI runs this same test on ubuntu against a plain
|
||||
# `bun build --compile`, which catches that resolution class but not a
|
||||
# build from source, which catches that resolution class but not a
|
||||
# break specific to the shipped flags or platform; this is the
|
||||
# release-time half. The test isolates CLINE_DIR and the Hub discovery
|
||||
# path, so it cannot reach anything else on the runner. Telemetry is
|
||||
# inlined into the binary, so expect one activation event per publish.
|
||||
- name: Smoke-test the packaged sidecar against an isolated Hub
|
||||
# inlined into the backend, so expect one activation event per publish.
|
||||
- name: Smoke-test the packaged desktop runtime against an isolated Hub
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-universal-apple-darwin
|
||||
CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-universal-apple-darwin
|
||||
CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js
|
||||
run: bun test scripts/desktop-startup.test.ts
|
||||
|
||||
- name: Collect artifacts
|
||||
@@ -615,7 +624,7 @@ jobs:
|
||||
with:
|
||||
bun-version: "1.4.2"
|
||||
|
||||
# build-sidecar-bin.ts cross-compiles the Linux SSH remote helpers on
|
||||
# build-desktop-runtime.ts cross-compiles the Linux SSH remote CLIs on
|
||||
# every platform, and the Windows installer ships them (they are Tauri
|
||||
# resources, bundle.resources in tauri.conf.json). Bun cross-compiles by
|
||||
# downloading the target's release zip and extracting the executable
|
||||
@@ -627,13 +636,13 @@ jobs:
|
||||
# with the same runtimes from npm (@oven/bun-<target>, byte-identical to
|
||||
# the zip payload) and the build never enters the broken path. The macOS
|
||||
# job does not need this; the extractor works there.
|
||||
- name: Seed Bun cache with Linux remote-helper runtimes
|
||||
- name: Seed Bun cache with Linux remote CLI runtimes
|
||||
shell: bash
|
||||
run: |
|
||||
BUN_VERSION="$(bun --version)"
|
||||
CACHE_DIR="${RUNNER_TEMP//\\//}/bun-install-cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
# Keep these runtimes aligned with build-sidecar-bin.ts, including
|
||||
# Keep these runtimes aligned with build-desktop-runtime.ts, including
|
||||
# the baseline x64 runtime needed by SSH hosts without AVX2.
|
||||
for target in linux-x64-baseline linux-aarch64; do
|
||||
dest="$CACHE_DIR/bun-$target-v$BUN_VERSION"
|
||||
@@ -697,7 +706,7 @@ jobs:
|
||||
- name: Prime Azure Trusted Signing token
|
||||
run: az account get-access-token --resource https://codesigning.azure.net --query expiresOn --output tsv
|
||||
|
||||
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
|
||||
# Tauri invokes signCommand once per staged binary (main exe, Cline CLI,
|
||||
# NSIS uninstaller, and the installer itself). The overlay is generated
|
||||
# here rather than committed because signCommand needs an absolute path
|
||||
# to the signing script on this runner.
|
||||
@@ -728,7 +737,7 @@ jobs:
|
||||
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry inlined into the sidecar at compile time, same as macOS.
|
||||
# Telemetry inlined into the desktop backend at build time, same as macOS.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
@@ -794,33 +803,40 @@ jobs:
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT:-disabled}"
|
||||
|
||||
# Same guardrail as the macOS job, run natively on the Windows sidecar.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
# Same guardrail as the macOS job, run natively on the Windows build.
|
||||
- name: Verify CLI and desktop backend telemetry configs were inlined
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build and sign desktop bundle' step and the --define"
|
||||
echo "inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
for COMPONENT in cli backend; do
|
||||
if [ "$COMPONENT" = cli ]; then
|
||||
SELFCHECK=$(./src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
|
||||
else
|
||||
SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck)
|
||||
fi
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged $COMPONENT reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build and sign desktop bundle' step and the --define"
|
||||
echo "inlining in scripts/build-desktop-runtime.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged $COMPONENT reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Same release-time gate as the macOS job; see the comment there.
|
||||
- name: Smoke-test the packaged sidecar against an isolated Hub
|
||||
- name: Smoke-test the packaged desktop runtime against an isolated Hub
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe
|
||||
CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe
|
||||
CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js
|
||||
run: bun test scripts/desktop-startup.test.ts
|
||||
|
||||
- name: Collect artifacts
|
||||
@@ -860,11 +876,11 @@ jobs:
|
||||
shell: pwsh
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
# The Tauri bundler signs the sidecar in place, so check it here too;
|
||||
# a WDAC-locked machine blocks the app at runtime if the sidecar it
|
||||
# spawns is unsigned, even when the installer itself is fine.
|
||||
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
|
||||
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
|
||||
# The Tauri bundler signs the bundled Cline CLI in place, so check it
|
||||
# here too; a WDAC-locked machine blocks the app at runtime if the
|
||||
# CLI it spawns is unsigned, even when the installer itself is fine.
|
||||
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe)
|
||||
if ($files.Count -lt 2) { throw "expected at least the installer and the Cline CLI to verify" }
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file.FullName
|
||||
if ($sig.Status -ne "Valid") {
|
||||
@@ -900,7 +916,7 @@ jobs:
|
||||
rpm_arch: x86_64
|
||||
# Native arm64 runner rather than cross-compiling: WebKitGTK and the
|
||||
# other GTK build deps have no usable cross toolchain in apt, and a
|
||||
# native build lets the sidecar smoke test below run the real binary.
|
||||
# native build lets the runtime smoke test below run the real binary.
|
||||
- arch: arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
@@ -999,13 +1015,13 @@ jobs:
|
||||
- name: Build desktop bundles
|
||||
working-directory: apps/examples/desktop-app
|
||||
# deb and rpm only. The AppImage target is excluded because linuxdeploy
|
||||
# cannot process the Bun-compiled sidecar (ldd fails on it and patchelf
|
||||
# cannot process the Bun-compiled Cline CLI (ldd fails on it and patchelf
|
||||
# corrupts it), which aborts the bundle step. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --bundles deb,rpm $CONFIG_ARGS
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry inlined into the sidecar at compile time, same as macOS.
|
||||
# Telemetry inlined into the desktop backend at build time, same as macOS.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
@@ -1057,59 +1073,67 @@ jobs:
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT:-disabled}"
|
||||
|
||||
# Same guardrail as the other build jobs, run on the Linux sidecar.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
# Same guardrail as the other build jobs, run on the Linux build.
|
||||
- name: Verify CLI and desktop backend telemetry configs were inlined
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-${TARGET} --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build desktop bundles' step and the --define inlining in"
|
||||
echo "scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
for COMPONENT in cli backend; do
|
||||
if [ "$COMPONENT" = cli ]; then
|
||||
SELFCHECK=$(./src-tauri/bin/cline-cli-${TARGET} --telemetry-selfcheck)
|
||||
else
|
||||
SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-${TARGET} run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck)
|
||||
fi
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged $COMPONENT reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build desktop bundles' step and the --define inlining in"
|
||||
echo "scripts/build-desktop-runtime.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged $COMPONENT reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Same release-time gate as the macOS job; see the comment there.
|
||||
- name: Smoke-test the packaged sidecar against an isolated Hub
|
||||
- name: Smoke-test the packaged desktop runtime against an isolated Hub
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-${{ matrix.target }}
|
||||
CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-${{ matrix.target }}
|
||||
CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js
|
||||
run: bun test scripts/desktop-startup.test.ts
|
||||
|
||||
# Run the SSH remote helper this job bundles (same arch as the runner)
|
||||
# through the exact command the desktop sends over SSH, then stop the
|
||||
# Hub it started. Desktop 0.0.38 through 0.0.44 shipped helpers that
|
||||
# printed --help fine but crashed on every --remote-hub-ensure, because
|
||||
# UPX packing broke the Bun 1.4 runtime's lazy reads of its own payload
|
||||
# Run the Linux Cline CLI this job bundles for SSH remotes (same arch as
|
||||
# the runner) through the exact `hub ensure` / `hub stop` the desktop
|
||||
# sends over SSH. Desktop 0.0.38 through 0.0.44 shipped remote binaries
|
||||
# that printed --help fine but crashed on every ensure, because UPX
|
||||
# packing broke the Bun 1.4 runtime's lazy reads of its own payload
|
||||
# (#14815, #14781); nothing exercised that path before release. Isolated
|
||||
# CLINE_DIR and discovery record, as in the sidecar test above. Only the
|
||||
# CLINE_DIR and discovery record, as in the runtime test above. Only the
|
||||
# exit status is checked; the ensure output carries the Hub auth token.
|
||||
- name: Smoke-test the bundled SSH remote helper
|
||||
- name: Smoke-test the bundled SSH remote CLI
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
HELPER: src-tauri/bin/remote-helpers/cline-remote-helper-${{ matrix.target }}
|
||||
REMOTE_CLI: src-tauri/bin/remote-helpers/cline-${{ matrix.target }}
|
||||
run: |
|
||||
export CLINE_DIR="$(mktemp -d)"
|
||||
export CLINE_DATA_DIR="$CLINE_DIR/data"
|
||||
export CLINE_NO_AUTO_UPDATE=1
|
||||
DISCOVERY="$CLINE_DIR/remote-hub.json"
|
||||
trap 'timeout 30 "$HELPER" --remote-hub-stop --discovery-path "$DISCOVERY" >/dev/null 2>&1 || true' EXIT
|
||||
if ! timeout 60 "$HELPER" --remote-hub-ensure --cwd "$CLINE_DIR" --discovery-path "$DISCOVERY" >/dev/null; then
|
||||
echo "Bundled remote helper failed --remote-hub-ensure; SSH remotes"
|
||||
echo "would not start from this build. Check scripts/build-sidecar-bin.ts."
|
||||
trap 'timeout 30 "$REMOTE_CLI" hub stop --discovery-path "$DISCOVERY" >/dev/null 2>&1 || true' EXIT
|
||||
if ! timeout 60 "$REMOTE_CLI" hub ensure --json --allow-port-fallback --no-connectors --cwd "$CLINE_DIR" --host 127.0.0.1 --port 0 --pathname /hub --discovery-path "$DISCOVERY" >/dev/null; then
|
||||
echo "Bundled remote CLI failed hub ensure; SSH remotes would not"
|
||||
echo "start from this build. Check scripts/build-desktop-runtime.ts."
|
||||
exit 1
|
||||
fi
|
||||
timeout 30 "$HELPER" --remote-hub-stop --discovery-path "$DISCOVERY"
|
||||
echo "Remote helper started and stopped an isolated Hub."
|
||||
timeout 30 "$REMOTE_CLI" hub stop --discovery-path "$DISCOVERY"
|
||||
echo "Remote CLI started and stopped an isolated Hub."
|
||||
|
||||
- name: Collect artifacts
|
||||
working-directory: apps/examples/desktop-app
|
||||
@@ -1373,7 +1397,7 @@ jobs:
|
||||
# Nightlies have no changelog, so the announcement lists the commits
|
||||
# since the nearest desktop tag - the previous nightly or the last
|
||||
# release, whichever is closer - scoped to what ships in the app
|
||||
# (the sidecar bundles sdk/packages). Runs before this build is
|
||||
# (the app bundles sdk/packages and the Cline CLI). Runs before this build is
|
||||
# tagged below so it never compares against itself.
|
||||
STAMP=${VERSION#*-nightly.}
|
||||
TAG="desktop-nightly-${STAMP%%.*}"
|
||||
@@ -1385,7 +1409,7 @@ jobs:
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
BODY="No previous nightly or desktop release tag found to compare against."
|
||||
else
|
||||
COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges --format='%s' -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml)
|
||||
COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges --format='%s' -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml)
|
||||
TOTAL=$(printf "%s" "$COMMITS" | grep -c . || true)
|
||||
COMPARE="https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...$(git rev-parse --short HEAD)"
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
run: bun run test:dmg-background
|
||||
|
||||
windows-installer:
|
||||
name: Test x86 installer against x64 sidecars
|
||||
name: Test x86 installer against x64 app executables
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
no-cache: true
|
||||
|
||||
# Use the same NSIS distribution as the locked Tauri CLI. No workspace
|
||||
# install or full desktop build: the test compiles a disposable sidecar.
|
||||
# install or full desktop build: the test compiles a disposable fixture executable.
|
||||
# The test asserts makensis reports NSIS_VERSION and that bun.lock still
|
||||
# resolves TAURI_CLI_VERSION, so changing this download or bumping the
|
||||
# CLI without updating windows-installer.test.ts fails the job.
|
||||
|
||||
+12
-107
@@ -7,51 +7,27 @@ import {
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { $ } from "bun";
|
||||
import {
|
||||
parseBuildOptions,
|
||||
shouldInstallNativeVariants,
|
||||
validateBuildOptions,
|
||||
} from "./build-options";
|
||||
import {
|
||||
compileCliBinary,
|
||||
installOpenTuiNativeVariants,
|
||||
} from "./compile-binary";
|
||||
|
||||
const cliDir = resolve(import.meta.dir, "..");
|
||||
const rootDir = resolve(cliDir, "../..");
|
||||
process.chdir(cliDir);
|
||||
|
||||
// Telemetry / OTEL environment variables that should be baked into the
|
||||
// compiled binary at build time. Mirrors the list of secrets injected by the
|
||||
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
|
||||
// so the CLI ships with the production telemetry configuration without
|
||||
// requiring the end user to set any env vars.
|
||||
const BUILD_TIME_INLINED_ENV_VARS = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
"CLINE_TRACE_SAMPLE_PERCENT",
|
||||
"CLINE_TRACE_RECORD_CONTENT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
] as const;
|
||||
|
||||
function buildInlinedEnvDefines(): Record<string, string> {
|
||||
const defines: Record<string, string> = {};
|
||||
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
|
||||
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
|
||||
}
|
||||
return defines;
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
|
||||
const version: string = pkg.version;
|
||||
const repository: unknown = pkg.repository;
|
||||
@@ -95,10 +71,7 @@ await $`rm -rf dist`;
|
||||
// can resolve them. Without this, Bun only has the host platform's native
|
||||
// binary and cross-compiled builds fail to resolve @opentui/core's FFI layer.
|
||||
if (shouldInstallNativeVariants({ options: buildOptions, opentuiVersion })) {
|
||||
console.log(
|
||||
`Installing all platform variants of @opentui/core@${opentuiVersion}...`,
|
||||
);
|
||||
await $`bun install --os="*" --cpu="*" @opentui/core@${opentuiVersion}`;
|
||||
await installOpenTuiNativeVariants();
|
||||
}
|
||||
|
||||
// Build the SDK first (the CLI bundles workspace packages)
|
||||
@@ -154,19 +127,6 @@ if (shouldBuildHubWebview()) {
|
||||
|
||||
const binaries: Record<string, string> = {};
|
||||
|
||||
function findOpenTuiParserWorker(): string {
|
||||
const localPath = resolve(
|
||||
cliDir,
|
||||
"node_modules/@opentui/core/parser.worker.js",
|
||||
);
|
||||
const rootPath = resolve(
|
||||
rootDir,
|
||||
"node_modules/@opentui/core/parser.worker.js",
|
||||
);
|
||||
const parserWorkerPath = existsSync(localPath) ? localPath : rootPath;
|
||||
return realpathSync(parserWorkerPath);
|
||||
}
|
||||
|
||||
function findFreePort(): Promise<number> {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
@@ -185,66 +145,6 @@ function getBunTarget(
|
||||
return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget;
|
||||
}
|
||||
|
||||
async function buildCompiledBinary(input: {
|
||||
bunTarget: Bun.Build.CompileTarget;
|
||||
dirName: string;
|
||||
outfile: string;
|
||||
}): Promise<void> {
|
||||
const parserWorker = findOpenTuiParserWorker();
|
||||
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
|
||||
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
|
||||
const parserWorkerPath = relative(rootDir, parserWorker).replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
|
||||
// Build to /tmp first so Bun's temp-file rename stays on one filesystem
|
||||
// layer in containerized environments (virtiofs, overlayfs).
|
||||
const entrypoint = join(cliDir, "src/index.ts");
|
||||
const tmpDir = join("/tmp", `cline-build-${input.dirName}`);
|
||||
const tmpOutfile = join(
|
||||
tmpDir,
|
||||
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
|
||||
);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
process.chdir("/tmp");
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entrypoint, parserWorker],
|
||||
splitting: true,
|
||||
compile: {
|
||||
target: input.bunTarget,
|
||||
outfile: tmpOutfile,
|
||||
// Plugins import the SDK from the wrapper's node_modules at runtime.
|
||||
// Without package.json loading Bun cannot resolve bare imports from
|
||||
// files on disk, so jiti falls back to transpiling the whole SDK
|
||||
// graph, which can blow the plugin load timeout on a cold cache.
|
||||
autoloadPackageJson: true,
|
||||
},
|
||||
minify: true,
|
||||
external: ["@anthropic-ai/vertex-sdk"],
|
||||
define: {
|
||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
|
||||
// Inline telemetry/OTEL env vars at build time so the compiled
|
||||
// binary ships with production telemetry configuration baked in.
|
||||
...buildInlinedEnvDefines(),
|
||||
},
|
||||
throw: false,
|
||||
});
|
||||
process.chdir(cliDir);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`Build failed for ${input.dirName}:`);
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await $`cp ${tmpOutfile} ${input.outfile} && chmod 755 ${input.outfile}`;
|
||||
await $`rm -rf ${tmpDir}`;
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
// npm treats "win32" specially in os field, but for package naming use "windows"
|
||||
const displayOs = item.os === "win32" ? "windows" : item.os;
|
||||
@@ -259,7 +159,12 @@ for (const item of targets) {
|
||||
|
||||
const outfile = join(outDir, binaryName);
|
||||
|
||||
await buildCompiledBinary({ bunTarget, dirName, outfile });
|
||||
try {
|
||||
await compileCliBinary({ bunTarget, outfile });
|
||||
} catch (error) {
|
||||
console.error(`Build failed for ${dirName}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Smoke test: only run on current platform
|
||||
if (item.os === process.platform && item.arch === process.arch) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
const cliDir = resolve(import.meta.dir, "..");
|
||||
const rootDir = resolve(cliDir, "../..");
|
||||
|
||||
// Telemetry / OTEL environment variables that should be baked into the
|
||||
// compiled binary at build time. Mirrors the list of secrets injected by the
|
||||
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
|
||||
// so the CLI ships with the production telemetry configuration without
|
||||
// requiring the end user to set any env vars.
|
||||
const BUILD_TIME_INLINED_ENV_VARS = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
"CLINE_TRACE_SAMPLE_PERCENT",
|
||||
"CLINE_TRACE_RECORD_CONTENT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
] as const;
|
||||
|
||||
function buildInlinedEnvDefines(): Record<string, string> {
|
||||
const defines: Record<string, string> = {};
|
||||
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
|
||||
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
|
||||
}
|
||||
return defines;
|
||||
}
|
||||
|
||||
function findOpenTuiParserWorker(): string {
|
||||
const localPath = resolve(
|
||||
cliDir,
|
||||
"node_modules/@opentui/core/parser.worker.js",
|
||||
);
|
||||
const rootPath = resolve(
|
||||
rootDir,
|
||||
"node_modules/@opentui/core/parser.worker.js",
|
||||
);
|
||||
const parserWorkerPath = existsSync(localPath) ? localPath : rootPath;
|
||||
return realpathSync(parserWorkerPath);
|
||||
}
|
||||
|
||||
export function readOpenTuiVersion(): string | undefined {
|
||||
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
|
||||
return pkg.dependencies?.["@opentui/core"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bun only has the host platform's OpenTUI native package by default, and a
|
||||
* cross-compiled build cannot resolve the FFI layer without the target's.
|
||||
* Installs every platform variant of the pinned version.
|
||||
*/
|
||||
export async function installOpenTuiNativeVariants(): Promise<void> {
|
||||
const opentuiVersion = readOpenTuiVersion();
|
||||
if (!opentuiVersion) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Installing all platform variants of @opentui/core@${opentuiVersion}...`,
|
||||
);
|
||||
await $`bun install --no-save --os="*" --cpu="*" @opentui/core@${opentuiVersion}`.cwd(
|
||||
cliDir,
|
||||
);
|
||||
}
|
||||
|
||||
export interface CompileCliBinaryOptions {
|
||||
bunTarget: Bun.Build.CompileTarget;
|
||||
outfile: string;
|
||||
/** Runtime flags baked into the executable, e.g. `--use-system-ca`. */
|
||||
execArgv?: string[];
|
||||
/**
|
||||
* Read `.env` / `bunfig.toml` from the launch directory before the CLI
|
||||
* starts. Bun's default; binaries launched in directories the user did not
|
||||
* pick (an SSH home, a GUI app's cwd) should turn this off.
|
||||
*/
|
||||
autoloadLaunchDirectoryConfig?: boolean;
|
||||
}
|
||||
|
||||
/** Compiles the CLI entrypoint into one self-contained executable. */
|
||||
export async function compileCliBinary(
|
||||
input: CompileCliBinaryOptions,
|
||||
): Promise<void> {
|
||||
const parserWorker = findOpenTuiParserWorker();
|
||||
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
|
||||
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
|
||||
const parserWorkerPath = relative(rootDir, parserWorker).replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
|
||||
// Build to /tmp first so Bun's temp-file rename stays on one filesystem
|
||||
// layer in containerized environments (virtiofs, overlayfs).
|
||||
const entrypoint = join(cliDir, "src/index.ts");
|
||||
const scratchRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
const tmpDir = join(
|
||||
scratchRoot,
|
||||
`cline-build-${input.bunTarget}-${process.pid}`,
|
||||
);
|
||||
const tmpOutfile = join(
|
||||
tmpDir,
|
||||
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
|
||||
);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(scratchRoot);
|
||||
let result: Awaited<ReturnType<typeof Bun.build>>;
|
||||
try {
|
||||
const autoload = input.autoloadLaunchDirectoryConfig !== false;
|
||||
result = await Bun.build({
|
||||
entrypoints: [entrypoint, parserWorker],
|
||||
splitting: true,
|
||||
compile: {
|
||||
target: input.bunTarget,
|
||||
outfile: tmpOutfile,
|
||||
// Plugins import SDK packages from disk at runtime; let Bun resolve
|
||||
// their package exports without falling back to transpiling the SDK.
|
||||
autoloadPackageJson: true,
|
||||
...(input.execArgv?.length ? { execArgv: input.execArgv } : {}),
|
||||
...(autoload ? {} : { autoloadDotenv: false, autoloadBunfig: false }),
|
||||
},
|
||||
minify: true,
|
||||
external: ["@anthropic-ai/vertex-sdk"],
|
||||
define: {
|
||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
|
||||
// Inline telemetry/OTEL env vars at build time so the compiled
|
||||
// binary ships with production telemetry configuration baked in.
|
||||
...buildInlinedEnvDefines(),
|
||||
},
|
||||
throw: false,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw new Error(`CLI build failed for ${input.bunTarget}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(input.outfile), { recursive: true });
|
||||
copyFileSync(tmpOutfile, input.outfile);
|
||||
chmodSync(input.outfile, 0o755);
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -387,7 +387,16 @@ describe("runDoctorCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale code sidecar processes", async () => {
|
||||
it.each([
|
||||
[
|
||||
"/src-tauri/bin/code-sidecar",
|
||||
"60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
|
||||
],
|
||||
[
|
||||
"/bin/desktop-backend/index.js",
|
||||
"60123 /Applications/Cline.app/Contents/MacOS/cline-cli run --no-env-file /Applications/Cline.app/Contents/Resources/bin/desktop-backend/index.js\n",
|
||||
],
|
||||
])("doctor --fix kills stale desktop backend processes matching %s", async (pattern, pgrepOutput) => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
mockProbeHubServer.mockResolvedValue(undefined);
|
||||
@@ -397,13 +406,9 @@ describe("runDoctorCommand", () => {
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/src-tauri/bin/code-sidecar"
|
||||
args[2] === pattern
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout:
|
||||
"60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
|
||||
};
|
||||
return { status: 0, stdout: pgrepOutput };
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
@@ -259,10 +259,13 @@ function listStaleSidecarPids(): number[] {
|
||||
const patterns = [
|
||||
"/apps/examples/desktop-app/sidecar/index.ts",
|
||||
"/apps/examples/desktop-app/dist/sidecar/index.js",
|
||||
// The packaged desktop backend runs on the bundled Cline CLI.
|
||||
"/bin/desktop-backend/index.js",
|
||||
// Keep the pre-example-reorg paths so `doctor --fix` can still clean up
|
||||
// stale sidecars that were launched from older checkouts.
|
||||
"/apps/code/sidecar/index.ts",
|
||||
"/apps/code/dist/sidecar/index.js",
|
||||
// Backend executable of earlier desktop releases.
|
||||
"/src-tauri/bin/code-sidecar",
|
||||
"/Resources/code-sidecar",
|
||||
" code-sidecar",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockEnsureLoginShellPath,
|
||||
mockLocalHubHasNoActiveSessions,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
@@ -13,6 +14,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockEnsureLoginShellPath: vi.fn(async () => undefined),
|
||||
mockLocalHubHasNoActiveSessions: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
@@ -31,6 +33,7 @@ const {
|
||||
vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
ensureLoginShellPath: mockEnsureLoginShellPath,
|
||||
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
@@ -48,6 +51,7 @@ const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
describe("createHubCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
@@ -258,6 +262,28 @@ describe("createHubCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("un-drains and aborts upgrade when authenticated shutdown fails", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
const { cmd, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(errors).toEqual(["Hub shutdown failed; upgrade aborted."]);
|
||||
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
|
||||
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
@@ -307,4 +333,181 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
|
||||
it("does not signal a stale discovery PID when authenticated shutdown fails", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
pid: 50174,
|
||||
authToken: "expired-token",
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
const kill = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
expect(kill).toHaveBeenCalledExactlyOnceWith(50174, 0);
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: false });
|
||||
});
|
||||
|
||||
it("succeeds when the hub has already exited", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
||||
});
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
expect(mockClearHubDiscovery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
||||
new Error("Invalid JSON in Hub discovery record"),
|
||||
new Error("Invalid Hub discovery record"),
|
||||
])("fails stop when discovery cannot be read: %s", async (error) => {
|
||||
mockReadHubDiscovery.mockRejectedValue(error);
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(output).toEqual([]);
|
||||
expect(errors).toEqual([error.message]);
|
||||
expect(mockReadHubDiscovery).toHaveBeenCalledWith(expect.any(String), {
|
||||
onError: "throw",
|
||||
});
|
||||
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("succeeds when there is no discovery record", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mistake an inaccessible process for an exited hub", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw Object.assign(new Error("not permitted"), { code: "EPERM" });
|
||||
});
|
||||
const { cmd, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a hub that exits before upgrade can shut it down", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
pid: 50174,
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(false);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
});
|
||||
vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
||||
});
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockClearHubDiscovery).toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
upgraded: true,
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("prints the hub URL by default from ensure", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["ensure"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(output).toEqual(["ws://127.0.0.1:25463/hub"]);
|
||||
expect(mockEnsureLoginShellPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a dedicated hub for programmatic clients with ensure --json", async () => {
|
||||
const originalDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
mockEnsureDetachedHubServer.mockImplementation(async () => {
|
||||
expect(process.env.CLINE_HUB_DISCOVERY_PATH).toBe(
|
||||
"/home/dev/.cline/data/remote/owner.json",
|
||||
);
|
||||
return { url: "ws://127.0.0.1:41000/hub", authToken: "remote-token" };
|
||||
});
|
||||
|
||||
try {
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(
|
||||
[
|
||||
"ensure",
|
||||
"--cwd",
|
||||
"/home/dev",
|
||||
"--discovery-path",
|
||||
"/home/dev/.cline/data/remote/owner.json",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"0",
|
||||
"--pathname",
|
||||
"/hub",
|
||||
"--json",
|
||||
"--allow-port-fallback",
|
||||
"--no-connectors",
|
||||
"--login-shell-path",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockEnsureDetachedHubServer).toHaveBeenCalledWith("/home/dev", {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/hub",
|
||||
allowPortFallback: true,
|
||||
manageConnectors: false,
|
||||
beforeSpawn: expect.any(Function),
|
||||
});
|
||||
// PATH resolution is deferred until a new daemon is actually spawned.
|
||||
expect(mockEnsureLoginShellPath).not.toHaveBeenCalled();
|
||||
await mockEnsureDetachedHubServer.mock.calls[0]?.[1]?.beforeSpawn?.();
|
||||
expect(mockEnsureLoginShellPath).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
url: "ws://127.0.0.1:41000/hub",
|
||||
authToken: "remote-token",
|
||||
cwd: "/home/dev",
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
} finally {
|
||||
if (originalDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalDiscoveryPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+103
-39
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
ensureLoginShellPath,
|
||||
localHubHasNoActiveSessions,
|
||||
NodeHubClient,
|
||||
probeHubServer,
|
||||
@@ -29,33 +30,52 @@ interface HubOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
discoveryPath?: string;
|
||||
}
|
||||
|
||||
interface HubEnsureCommandOptions extends Partial<HubOptions> {
|
||||
json?: boolean;
|
||||
allowPortFallback?: boolean;
|
||||
connectors?: boolean;
|
||||
loginShellPath?: boolean;
|
||||
}
|
||||
|
||||
const HUB_DISCOVERY_PATH_ENV = "CLINE_HUB_DISCOVERY_PATH";
|
||||
|
||||
function addHubOptions(command: Command, defaultCwd?: string): Command {
|
||||
return command
|
||||
.option("--cwd <path>", "Workspace root", defaultCwd)
|
||||
.option("--host <host>", "Hub host")
|
||||
.option("--port <port>", "Hub port", (value) => Number.parseInt(value, 10))
|
||||
.option("--pathname <path>", "Hub websocket path");
|
||||
.option("--pathname <path>", "Hub websocket path")
|
||||
.option(
|
||||
"--discovery-path <path>",
|
||||
"Use a dedicated hub discovery record instead of the default one",
|
||||
);
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
async function stopHubServer(): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath, {
|
||||
onError: "throw",
|
||||
});
|
||||
if (!discovery) {
|
||||
return true; // Already stopped: cleanup is idempotent.
|
||||
}
|
||||
const pid = discovery?.pid;
|
||||
if (pid) {
|
||||
if (!(await stopLocalHubServerGracefully(owner))) {
|
||||
// A stale PID must never receive a termination signal. A zero-signal
|
||||
// existence check can only establish that the recorded process exited;
|
||||
// a live/reused PID or a permission error cannot prove shutdown.
|
||||
if (!discovery.pid) return false;
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// best effort
|
||||
process.kill(discovery.pid, 0);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false;
|
||||
}
|
||||
}
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return !!pid;
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatHubUptimeFromStartedAt(
|
||||
@@ -111,6 +131,19 @@ export function createHubCommand(
|
||||
.exitOverride()
|
||||
// Subcommands may use the same flag names for different services.
|
||||
.enablePositionalOptions()
|
||||
.hook("preAction", (_hub, actionCommand) => {
|
||||
// Every subcommand resolves its owner record from this env var, so a
|
||||
// dedicated record (SSH remote hubs) is honored by ensure, status,
|
||||
// and stop alike, and by the daemon the ensure spawns. Accepted
|
||||
// before or after the subcommand.
|
||||
const discoveryPath = (
|
||||
actionCommand.opts<{ discoveryPath?: string }>().discoveryPath ??
|
||||
hub.opts<{ discoveryPath?: string }>().discoveryPath
|
||||
)?.trim();
|
||||
if (discoveryPath) {
|
||||
process.env[HUB_DISCOVERY_PATH_ENV] = discoveryPath;
|
||||
}
|
||||
})
|
||||
.hook("postAction", () => {
|
||||
setExitCode(actionExitCode);
|
||||
});
|
||||
@@ -120,29 +153,58 @@ export function createHubCommand(
|
||||
...options,
|
||||
});
|
||||
|
||||
hub.command("ensure").action(
|
||||
action(async (options: Partial<HubOptions>) => {
|
||||
const opts = resolveOptions(options);
|
||||
const { url } = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
|
||||
hub.command("start").action(
|
||||
action(async (options: Partial<HubOptions>) => {
|
||||
const opts = resolveOptions(options);
|
||||
const { url } = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
const ensureAction = action(async (cmdOptions: HubEnsureCommandOptions) => {
|
||||
const opts = resolveOptions(cmdOptions);
|
||||
const result = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
...(cmdOptions.allowPortFallback ? { allowPortFallback: true } : {}),
|
||||
...(cmdOptions.connectors === false ? { manageConnectors: false } : {}),
|
||||
// Launchers without a login shell (GUI apps, non-interactive SSH)
|
||||
// would otherwise hand the daemon a minimal PATH, so agent tools
|
||||
// installed from shell profiles could not be found.
|
||||
...(cmdOptions.loginShellPath
|
||||
? {
|
||||
beforeSpawn: async () => {
|
||||
await ensureLoginShellPath();
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (cmdOptions.json) {
|
||||
io.writeln(
|
||||
JSON.stringify({
|
||||
url: result.url,
|
||||
authToken: result.authToken,
|
||||
cwd: opts.cwd,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
io.writeln(result.url);
|
||||
});
|
||||
for (const name of ["ensure", "start"]) {
|
||||
hub
|
||||
.command(name)
|
||||
.description("Start the hub daemon unless a compatible one is running")
|
||||
.option(
|
||||
"--json",
|
||||
"Print the hub URL and auth token as JSON for programmatic clients",
|
||||
)
|
||||
.option(
|
||||
"--allow-port-fallback",
|
||||
"Use an OS-assigned port when the requested one is unavailable",
|
||||
)
|
||||
.option("--no-connectors", "Do not supervise account-wide connectors")
|
||||
.option(
|
||||
"--login-shell-path",
|
||||
"Resolve PATH from the user's login shell before starting the hub",
|
||||
)
|
||||
.action(ensureAction);
|
||||
}
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
@@ -218,10 +280,10 @@ export function createHubCommand(
|
||||
);
|
||||
|
||||
hub.command("stop").action(
|
||||
action(async (options: Partial<HubOptions>) => {
|
||||
const opts = resolveOptions(options);
|
||||
const stopped = await stopHubServer(opts.cwd);
|
||||
action(async () => {
|
||||
const stopped = await stopHubServer();
|
||||
io.writeln(JSON.stringify({ stopped }));
|
||||
if (!stopped) fail();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -317,7 +379,9 @@ export function createHubCommand(
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
await stopHubServer(opts.cwd);
|
||||
if (!(await stopHubServer())) {
|
||||
throw new Error("Hub shutdown failed; upgrade aborted.");
|
||||
}
|
||||
} catch (error) {
|
||||
await undrain();
|
||||
throw error;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import {
|
||||
buildTelemetrySelfcheckReport,
|
||||
claimHubDaemonProcess,
|
||||
claimSupervisedConnectorProcess,
|
||||
createClineTelemetryServiceConfig,
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
disposeAll,
|
||||
ensureLoopbackProxyBypass,
|
||||
@@ -32,6 +34,12 @@ ensureLoopbackProxyBypass();
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} 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.
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(buildTelemetrySelfcheckReport(createClineTelemetryServiceConfig()))}\n`,
|
||||
);
|
||||
} else if (claimHubDaemonProcess()) {
|
||||
// Claim rather than read: the sentinel is consumed here so the processes a
|
||||
// daemon-hosted session spawns do not inherit it and try to become daemons.
|
||||
|
||||
@@ -406,6 +406,29 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
it("does not capture an activation event for hub commands", async () => {
|
||||
// The desktop app and the SSH remote flow run `cline hub ensure` on
|
||||
// every launch/connect; those must not count as CLI activations.
|
||||
process.argv = ["bun", "src/index.ts", "hub", "dashboard", "--no-open"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
await runCli();
|
||||
|
||||
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledTimes(1);
|
||||
expect(telemetryMocks.captureCliExtensionActivated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures an activation event for regular commands", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "history", "--json"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
await runCli();
|
||||
|
||||
expect(telemetryMocks.captureCliExtensionActivated).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"connect",
|
||||
"mcp",
|
||||
|
||||
@@ -166,7 +166,12 @@ export async function runCli(): Promise<void> {
|
||||
// has been applied, so the telemetry singleton's persisted distinct-id
|
||||
// (and any other storage it touches) lands under the user-selected
|
||||
// `--config <dir>` rather than the default home/config location.
|
||||
captureCliExtensionActivated();
|
||||
// `cline hub ...` is plumbing other clients invoke (the desktop app on
|
||||
// every launch, the SSH remote flow on every connect), not a user
|
||||
// activation, so it must not count as one.
|
||||
if (cliArgs[0] !== "hub") {
|
||||
captureCliExtensionActivated();
|
||||
}
|
||||
|
||||
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Desktop App Example
|
||||
|
||||
Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspecting Cline chat sessions.
|
||||
Tauri desktop shell + desktop backend (`sidecar/`) + Next.js UI for running and inspecting Cline chat sessions. The packaged app bundles a self-contained Cline CLI binary that starts the shared Hub and runs the backend, so no Node or Bun install is needed.
|
||||
|
||||
## Dev Commands
|
||||
|
||||
@@ -11,9 +11,9 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev` - Tauri desktop dev
|
||||
- `bun run build:web` - build production web assets only (includes the shared UI build)
|
||||
- `bun run build` - build web assets and the sidecar binary
|
||||
- `bun run build` - build web assets and the desktop runtime
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:runtime` - compile the bundled Cline CLI (host plus Linux SSH remote copies) and bundle the desktop backend into `src-tauri/bin/`
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
@@ -151,27 +151,27 @@ Desktop owns the settings UI and packaged helper resource lookup.
|
||||
The service stores host metadata at
|
||||
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
|
||||
identity-file path, never private-key contents. On first connect it uploads a
|
||||
content-addressed, branch-matched, self-contained Hub helper under
|
||||
`~/.cline/remote/`, binds the Hub to remote loopback, and forwards it to a
|
||||
random local loopback port. Linux x64 and arm64 helpers are bundled by
|
||||
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
|
||||
supported. A macOS desktop reaches macOS SSH hosts with its own signed
|
||||
universal sidecar, which runs the same helper entrypoint; Windows and Linux
|
||||
desktops need a locally built darwin helper passed through
|
||||
`CLINE_REMOTE_HELPER_BINARY`. The helper includes its own runtime (about 115 MB
|
||||
per helper) and must not be UPX-compressed; Bun reads its embedded code back
|
||||
from the executable at runtime, so a packed helper crashes with
|
||||
content-addressed, branch-matched, self-contained Cline CLI binary under
|
||||
`~/.cline/remote/` and runs `cline hub ensure --json --discovery-path ...`
|
||||
there, which reuses a compatible healthy remote Hub or starts one bound to
|
||||
remote loopback. The desktop forwards it to a random local loopback port.
|
||||
Linux x64 and arm64 CLI copies are bundled by `bun run build:runtime`; 32-bit
|
||||
Raspberry Pi operating systems are not supported. A macOS desktop reaches
|
||||
macOS SSH hosts with its own signed universal Cline CLI; Windows and Linux
|
||||
desktops need a locally built darwin CLI passed through
|
||||
`CLINE_REMOTE_HELPER_BINARY`. The remote CLI includes its own runtime (about
|
||||
115 MB per copy) and must not be UPX-compressed; Bun reads its embedded code
|
||||
back from the executable at runtime, so a packed binary crashes with
|
||||
`SyntaxError: Invalid character: '\0'`. It is copied once per matching desktop
|
||||
build and cached, with no
|
||||
`apt`, `npm`, root access,
|
||||
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
|
||||
remote Hub but leaves the helper cached for a faster reconnect. The helper
|
||||
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
|
||||
MCP executables remain visible.
|
||||
build and cached, with no `apt`, `npm`, root access, global CLI install, or
|
||||
public Hub port. Disconnecting runs `cline hub stop` for the desktop-owned
|
||||
remote Hub but leaves the CLI cached for a faster reconnect. The remote Hub
|
||||
imports the remote login-shell `PATH` (`--login-shell-path`), so
|
||||
user-installed Git, GitHub CLI, and MCP executables remain visible.
|
||||
|
||||
Each service instance uses its own discovery record, so an existing Cline CLI/Hub on the
|
||||
same account is neither replaced nor stopped. Both Hub processes can coexist
|
||||
while the desktop is connected; this isolation keeps the remote helper separate from the default CLI Hub.
|
||||
while the desktop is connected; this isolation keeps the desktop's remote Hub separate from the default CLI Hub.
|
||||
|
||||
The desktop currently leaves file attachments and opening a remote file in a local
|
||||
editor disabled. Text, images, file mentions/search, Git branch operations,
|
||||
@@ -239,11 +239,11 @@ sudo dnf install ./Cline_<version>_aarch64.rpm # Fedora / RHEL (arm64)
|
||||
```
|
||||
|
||||
The app installs as `/usr/bin/cline-app` with a `Cline` launcher entry; the
|
||||
sidecar is `/usr/bin/code-sidecar` and the bundled SSH remote helpers live in
|
||||
`/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and
|
||||
bundled Cline CLI is `/usr/bin/cline-cli`, and the desktop backend bundle and
|
||||
SSH remote CLIs live in `/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and
|
||||
GNOME with the AppIndicator extension); without one the app still runs but the
|
||||
tray menu is unavailable. There is no AppImage: linuxdeploy cannot process the
|
||||
Bun-compiled sidecar (`ldd` fails on it and `patchelf` corrupts it), so the
|
||||
Bun-compiled Cline CLI (`ldd` fails on it and `patchelf` corrupts it), so the
|
||||
AppImage target is excluded from `tauri build` on Linux. A Linux desktop
|
||||
cannot use a Mac as an SSH remote host (see the changelog).
|
||||
|
||||
@@ -293,16 +293,20 @@ bun run package:desktop:mac
|
||||
|
||||
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
|
||||
|
||||
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
|
||||
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled Cline CLI (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
Startup flow:
|
||||
|
||||
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
|
||||
2. The desktop backend starts the Bun sidecar, which discovers or starts the
|
||||
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
|
||||
for desktop commands, queries, and pushed events.
|
||||
In a packaged app the backend is `bin/desktop-backend/index.js`, executed by
|
||||
the bundled Cline CLI (`cline-cli run`); `bun run dev` runs `sidecar/index.ts`
|
||||
with Bun directly.
|
||||
2. The backend runs `cline-cli hub ensure`, which reuses a compatible healthy
|
||||
shared Cline Hub on loopback or starts one (the Hub daemon is the same CLI
|
||||
binary). The backend then attaches to the Hub and exposes one websocket
|
||||
transport (`/transport`) for desktop commands, queries, and pushed events.
|
||||
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
|
||||
4. Tool approval updates are pushed from the backend instead of polled from the UI.
|
||||
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
|
||||
@@ -323,7 +327,8 @@ Desktop transport envelope:
|
||||
## Key Files
|
||||
|
||||
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent desktop backend entrypoint
|
||||
- [`sidecar/cli-runtime.ts`](./sidecar/cli-runtime.ts) - bundled Cline CLI runtime adoption and `hub ensure`
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
|
||||
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
|
||||
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
|
||||
@@ -364,7 +369,7 @@ credentials, request headers, recorded audio, or transcript contents.
|
||||
## Troubleshooting
|
||||
|
||||
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
|
||||
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
|
||||
- Tauri restarts the desktop backend if its process exits and stops it on app teardown. The shared Hub keeps running for other clients.
|
||||
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
|
||||
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
|
||||
- Linux with the proprietary NVIDIA driver: WebKitGTK's DMA-BUF renderer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun";
|
||||
|
||||
const main = async () => {
|
||||
await $`bun run build:sidecar:bin`;
|
||||
await $`bun run build:runtime`;
|
||||
await $`next build`.cwd("webview");
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:runtime": "bun run scripts/build-desktop-runtime.ts",
|
||||
"build:binary": "tauri build",
|
||||
"dmg:background": "bun run scripts/dmg-background.ts",
|
||||
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { $ } from "bun";
|
||||
import {
|
||||
compileCliBinary,
|
||||
installOpenTuiNativeVariants,
|
||||
} from "../../../cli/script/compile-binary";
|
||||
import { telemetryDefineArgs } from "./telemetry-define-args";
|
||||
|
||||
// The desktop app ships two runtime artifacts and no app-specific executable:
|
||||
//
|
||||
// - The Cline CLI, compiled self-contained for this target (Tauri externalBin
|
||||
// `bin/cline-cli`). `cline-cli hub ensure` starts or reuses the CLI-managed
|
||||
// Hub, and the Hub daemon is that same binary.
|
||||
// - The desktop backend (sidecar/ sources: the UI command router plus its
|
||||
// HTTP/WebSocket server) as one JS bundle, executed on the CLI's embedded
|
||||
// runtime. It attaches to the Hub as an ordinary client.
|
||||
//
|
||||
// Linux CLI copies are bundled as resources so SSH remotes run a CLI-managed
|
||||
// Hub too; macOS remotes reuse the signed universal CLI itself.
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const BIN_DIR = "src-tauri/bin";
|
||||
export const DESKTOP_CLI_NAME = "cline-cli";
|
||||
export const DESKTOP_BACKEND_BUNDLE = `${BIN_DIR}/desktop-backend/index.js`;
|
||||
export const REMOTE_CLI_DIR = `${BIN_DIR}/remote-helpers`;
|
||||
|
||||
const resolveTargetTriple = async (): Promise<string> => {
|
||||
const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET;
|
||||
if (fromEnv?.trim()) {
|
||||
return fromEnv.trim();
|
||||
}
|
||||
|
||||
const rustcVersion = await $`rustc -vV`.text();
|
||||
const hostLine = rustcVersion
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("host: "));
|
||||
const host = hostLine?.slice("host: ".length).trim();
|
||||
if (!host) {
|
||||
throw new Error("failed to resolve Rust host target triple");
|
||||
}
|
||||
return host;
|
||||
};
|
||||
|
||||
// Bun cross-compiles --compile binaries, so a CI runner can produce the CLI
|
||||
// for a different architecture than its own (e.g. the x86_64 macOS bundle
|
||||
// from an arm64 runner).
|
||||
const resolveBunCompileTarget = (
|
||||
targetTriple: string,
|
||||
): Bun.Build.CompileTarget => {
|
||||
if (targetTriple.startsWith("aarch64-apple-darwin"))
|
||||
return "bun-darwin-arm64";
|
||||
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
|
||||
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
|
||||
if (targetTriple.startsWith("aarch64-pc-windows")) return "bun-windows-arm64";
|
||||
// SSH hosts may predate AVX2 (e.g. Ivy Bridge Xeons). The default Bun
|
||||
// x64 runtime can SIGILL before our entrypoint runs on those CPUs.
|
||||
if (targetTriple.startsWith("x86_64-unknown-linux"))
|
||||
return "bun-linux-x64-baseline";
|
||||
if (targetTriple.startsWith("aarch64-unknown-linux"))
|
||||
return "bun-linux-arm64";
|
||||
throw new Error(`unsupported desktop target triple: ${targetTriple}`);
|
||||
};
|
||||
|
||||
const desktopCliOutfile = (targetTriple: string): string => {
|
||||
const extension = targetTriple.includes("windows") ? ".exe" : "";
|
||||
return `${BIN_DIR}/${DESKTOP_CLI_NAME}-${targetTriple}${extension}`;
|
||||
};
|
||||
|
||||
const compileDesktopCli = async (
|
||||
targetTriple: string,
|
||||
outfile = desktopCliOutfile(targetTriple),
|
||||
): Promise<string> => {
|
||||
await compileCliBinary({
|
||||
bunTarget: resolveBunCompileTarget(targetTriple),
|
||||
outfile: fileURLToPath(new URL(`../${outfile}`, import.meta.url)),
|
||||
// A packaged app and an SSH session launch the CLI from directories the
|
||||
// user did not choose (/, an SSH home). Reading .env or bunfig.toml
|
||||
// from there could fail startup or leak credentials into the Hub.
|
||||
autoloadLaunchDirectoryConfig: false,
|
||||
// Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to
|
||||
// intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM
|
||||
// firewalls) fails with "unable to get local issuer certificate". Bake
|
||||
// --use-system-ca in so the Hub and the backend running on this runtime
|
||||
// also trust the OS Keychain/cert store, as the npm wrapper does.
|
||||
execArgv: ["--use-system-ca"],
|
||||
});
|
||||
return outfile;
|
||||
};
|
||||
|
||||
const bundleDesktopBackend = async (): Promise<void> => {
|
||||
// Telemetry config must be inlined into the bundle: a packaged app launched
|
||||
// from Finder/the Dock has no OTEL_* env at runtime, so without this the
|
||||
// backend silently ships with telemetry disabled. Verify with
|
||||
// `BUN_BE_BUN=1 cline-cli run <bundle> --telemetry-selfcheck`.
|
||||
const defines = telemetryDefineArgs();
|
||||
await $`bun build ./sidecar/index.ts --target bun --minify ${defines} --outfile ${DESKTOP_BACKEND_BUNDLE}`;
|
||||
};
|
||||
|
||||
// Each compiled CLI embeds a full Bun runtime (~100 MB), and both Linux copies
|
||||
// ship inside every desktop bundle. They must not be UPX-packed: the Bun 1.4
|
||||
// runtime reads its embedded module payload back from its own executable at
|
||||
// runtime, and on a packed binary those reads return NUL padding, so every
|
||||
// `hub ensure` fails with "SyntaxError: Invalid character: '\0'" (#14815).
|
||||
// `strip` is not an option either; it discards Bun's appended module payload.
|
||||
|
||||
// Linux x64 and arm64 cover common SSH hosts. macOS copies are deliberately
|
||||
// not bundled: they would be Mach-O files under Contents/Resources, which
|
||||
// Tauri does not codesign, and any unsigned Mach-O in the bundle fails
|
||||
// notarization. Mac remotes are served by the signed desktop CLI instead
|
||||
// (sidecar/remote-helper.ts).
|
||||
//
|
||||
// On a Windows host, Bun fails to extract the downloaded Linux runtime these
|
||||
// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'").
|
||||
// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun-<target>-v<version>`
|
||||
// already exists, so seed those two files from the @oven/bun-<target> npm
|
||||
// packages first; desktop-publish.yml does exactly that in its Windows job.
|
||||
const buildRemoteClis = async (): Promise<void> => {
|
||||
for (const targetTriple of [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
]) {
|
||||
await compileDesktopCli(
|
||||
targetTriple,
|
||||
`${REMOTE_CLI_DIR}/cline-${targetTriple}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
|
||||
// but expects externalBin binaries to already be fat binaries named
|
||||
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
|
||||
const buildUniversalMacCli = async (): Promise<void> => {
|
||||
const arm64 = await compileDesktopCli("aarch64-apple-darwin");
|
||||
const x64 = await compileDesktopCli("x86_64-apple-darwin");
|
||||
const outfile = desktopCliOutfile("universal-apple-darwin");
|
||||
await $`lipo -create -output ${outfile} ${arm64} ${x64}`;
|
||||
await $`chmod +x ${outfile}`;
|
||||
await $`lipo -info ${outfile}`;
|
||||
};
|
||||
|
||||
// Plugin installs drop their @cline/* dependencies because the host provides
|
||||
// them, and the plugin worker resolves those from node_modules beside its
|
||||
// bootstrap. Nothing like that exists next to the compiled CLI, so ship
|
||||
// self-contained builds there. Splitting keeps one shared copy of the SDK,
|
||||
// placed in core so every chunk sits inside a `"type": "module"` package.
|
||||
const bundlePluginSdkPackages = async (): Promise<void> => {
|
||||
const outdir = `${BIN_DIR}/extensions/node_modules/@cline`;
|
||||
const packagesRoot = "../../../sdk/packages";
|
||||
const packages = await Promise.all(
|
||||
["sdk", "core", "agents", "llms", "shared"].map(async (name) => {
|
||||
const root = `${packagesRoot}/${name}`;
|
||||
const manifest = (await Bun.file(`${root}/package.json`).json()) as {
|
||||
exports: Record<string, { import: string }>;
|
||||
};
|
||||
// Type-only exports (e.g. @cline/shared/types) have no JS to ship.
|
||||
const exports = Object.fromEntries(
|
||||
Object.entries(manifest.exports)
|
||||
.map(([path, entry]) => [path, entry.import] as const)
|
||||
.filter(([, file]) => existsSync(`${root}/${file}`)),
|
||||
);
|
||||
return { root, name, exports };
|
||||
}),
|
||||
);
|
||||
await $`rm -rf ${outdir}`;
|
||||
const result = await Bun.build({
|
||||
entrypoints: packages.flatMap(({ root, exports }) =>
|
||||
Object.values(exports).map((file) => `${root}/${file}`),
|
||||
),
|
||||
root: packagesRoot,
|
||||
outdir,
|
||||
naming: { chunk: "core/dist/[name]-[hash].[ext]" },
|
||||
target: "node",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "plugin SDK bundle failed");
|
||||
}
|
||||
for (const { name, exports } of packages) {
|
||||
await Bun.write(
|
||||
`${outdir}/${name}/package.json`,
|
||||
JSON.stringify({ name: `@cline/${name}`, type: "module", exports }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasOpenTuiNativeVariant = (os: string, arch: string): boolean =>
|
||||
existsSync(`${REPO_ROOT}/node_modules/.bun`) &&
|
||||
[
|
||||
...new Bun.Glob(`@opentui+core-${os}-${arch}@*`).scanSync({
|
||||
cwd: `${REPO_ROOT}/node_modules/.bun`,
|
||||
onlyFiles: false,
|
||||
}),
|
||||
].length > 0;
|
||||
|
||||
const main = async () => {
|
||||
// The CLI, its Hub daemon, and the backend bundle must all carry the same
|
||||
// SDK build identity, so build the SDK packages they bundle once, first.
|
||||
await $`bun run build:sdk`.cwd(REPO_ROOT);
|
||||
process.chdir(APP_ROOT);
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR} ${BIN_DIR}/extensions`;
|
||||
// Plugin workers load this bootstrap from disk in the backend and Hub.
|
||||
await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js ${BIN_DIR}/extensions/plugin-sandbox-bootstrap.js`;
|
||||
await bundlePluginSdkPackages();
|
||||
// Every build cross-compiles the Linux SSH copies (and macOS builds both
|
||||
// architectures), which needs the target platforms' OpenTUI native
|
||||
// packages in addition to the host's.
|
||||
const needed: Array<[string, string]> = [
|
||||
["linux", "x64"],
|
||||
["linux", "arm64"],
|
||||
...(targetTriple === "universal-apple-darwin"
|
||||
? ([
|
||||
["darwin", "x64"],
|
||||
["darwin", "arm64"],
|
||||
] as Array<[string, string]>)
|
||||
: []),
|
||||
];
|
||||
if (needed.some(([os, arch]) => !hasOpenTuiNativeVariant(os, arch))) {
|
||||
await installOpenTuiNativeVariants();
|
||||
}
|
||||
await bundleDesktopBackend();
|
||||
if (targetTriple === "universal-apple-darwin") {
|
||||
await buildUniversalMacCli();
|
||||
} else {
|
||||
await compileDesktopCli(targetTriple);
|
||||
}
|
||||
await buildRemoteClis();
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { $ } from "bun";
|
||||
import { telemetryDefineArgs } from "./telemetry-define-args";
|
||||
|
||||
const resolveTargetTriple = async (): Promise<string> => {
|
||||
const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET;
|
||||
if (fromEnv?.trim()) {
|
||||
return fromEnv.trim();
|
||||
}
|
||||
|
||||
const rustcVersion = await $`rustc -vV`.text();
|
||||
const hostLine = rustcVersion
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("host: "));
|
||||
const host = hostLine?.slice("host: ".length).trim();
|
||||
if (!host) {
|
||||
throw new Error("failed to resolve Rust host target triple");
|
||||
}
|
||||
return host;
|
||||
};
|
||||
|
||||
// Bun cross-compiles --compile binaries, so a CI runner can produce the
|
||||
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
|
||||
// bundle from an arm64 runner). Without an explicit --target, bun always
|
||||
// emits a host-arch binary even when Tauri is building for another triple.
|
||||
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
|
||||
if (targetTriple.startsWith("aarch64-apple-darwin"))
|
||||
return "bun-darwin-arm64";
|
||||
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
|
||||
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
|
||||
// SSH hosts may predate AVX2 (e.g. Ivy Bridge Xeons). The default Bun
|
||||
// x64 runtime can SIGILL before our entrypoint runs on those CPUs.
|
||||
if (targetTriple.startsWith("x86_64-unknown-linux"))
|
||||
return "bun-linux-x64-baseline";
|
||||
if (targetTriple.startsWith("aarch64-unknown-linux"))
|
||||
return "bun-linux-arm64";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const sidecarOutfile = (targetTriple: string): string => {
|
||||
const extension = targetTriple.includes("windows") ? ".exe" : "";
|
||||
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
};
|
||||
|
||||
const buildSidecar = async (
|
||||
targetTriple: string,
|
||||
outfile = sidecarOutfile(targetTriple),
|
||||
entrypoint = "./sidecar/index.ts",
|
||||
minify = false,
|
||||
): Promise<string> => {
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
// Telemetry config must be inlined into the compiled binary: a packaged
|
||||
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
|
||||
// without this the sidecar silently ships with telemetry disabled.
|
||||
// Verify with `<binary> --telemetry-selfcheck` after building.
|
||||
const defines = telemetryDefineArgs();
|
||||
const optimizationArgs = minify ? ["--minify"] : [];
|
||||
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
|
||||
// launch directory before our entrypoint runs. Remote helpers are launched
|
||||
// from an SSH user's home directory, so that behavior can both make the
|
||||
// helper fail on an unrelated dotenv file and leak workspace credentials
|
||||
// into the Hub process. Packaged binaries must depend only on their explicit
|
||||
// process environment and compiled configuration.
|
||||
const runtimeIsolationArgs = [
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
// Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to
|
||||
// intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM
|
||||
// firewalls) fails with "unable to get local issuer certificate". Bake
|
||||
// --use-system-ca into the runtime so the sidecar and the Hub daemon it
|
||||
// re-executes from this binary also trust the OS Keychain/cert store,
|
||||
// matching the CLI wrapper's OS trust-anchor harvesting.
|
||||
"--compile-exec-argv=--use-system-ca",
|
||||
];
|
||||
if (bunTarget) {
|
||||
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
}
|
||||
return outfile;
|
||||
};
|
||||
|
||||
// Each compiled helper embeds a full Bun runtime (~100 MB) around ~14 MB of
|
||||
// our code, and both helpers ship uncompressed inside every desktop bundle.
|
||||
// Do not UPX-pack or `strip` them: since Bun 1.4 the runtime lazily reads its
|
||||
// appended module payload back from its own executable file, so a packed
|
||||
// helper starts but crashes with "SyntaxError: Invalid character: '\0'" on the
|
||||
// first lazy read (cline/cline#14815, #14781). `strip` discards the payload.
|
||||
//
|
||||
// SSH environments run the same Hub build as the desktop in a dedicated
|
||||
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
|
||||
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
|
||||
// macOS helpers are deliberately not bundled: they are Mach-O files under
|
||||
// Contents/Resources, which Tauri does not codesign, and any unsigned Mach-O
|
||||
// in the bundle fails notarization. Mac remotes are served from a macOS
|
||||
// desktop by its own signed sidecar instead (sidecar/remote-helper.ts).
|
||||
//
|
||||
// On a Windows host, Bun fails to extract the downloaded Linux runtime these
|
||||
// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'").
|
||||
// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun-<target>-v<version>`
|
||||
// already exists, so seed those two files from the @oven/bun-<target> npm
|
||||
// packages first; desktop-publish.yml does exactly that in its Windows job.
|
||||
const buildRemoteHelpers = async (): Promise<void> => {
|
||||
for (const targetTriple of [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
]) {
|
||||
await buildSidecar(
|
||||
targetTriple,
|
||||
`./src-tauri/bin/remote-helpers/cline-remote-helper-${targetTriple}`,
|
||||
"../../../sdk/packages/core/dist/remote/remote-helper-entry.js",
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
|
||||
// but expects sidecars (externalBin) to already be fat binaries named
|
||||
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
|
||||
const buildUniversalMacSidecar = async (): Promise<void> => {
|
||||
const arm64 = await buildSidecar("aarch64-apple-darwin");
|
||||
const x64 = await buildSidecar("x86_64-apple-darwin");
|
||||
const outfile = sidecarOutfile("universal-apple-darwin");
|
||||
await $`lipo -create -output ${outfile} ${arm64} ${x64}`;
|
||||
await $`chmod +x ${outfile}`;
|
||||
await $`lipo -info ${outfile}`;
|
||||
};
|
||||
|
||||
// Plugin installs drop their @cline/* dependencies because the host provides
|
||||
// them, and the plugin worker resolves those from node_modules beside its
|
||||
// bootstrap. Nothing like that exists next to the compiled sidecar, so ship
|
||||
// self-contained builds there. Splitting keeps one shared copy of the SDK,
|
||||
// placed in core so every chunk sits inside a `"type": "module"` package.
|
||||
const bundlePluginSdkPackages = async (): Promise<void> => {
|
||||
const outdir = "src-tauri/bin/extensions/node_modules/@cline";
|
||||
const packagesRoot = "../../../sdk/packages";
|
||||
const packages = await Promise.all(
|
||||
["sdk", "core", "agents", "llms", "shared"].map(async (name) => {
|
||||
const root = `${packagesRoot}/${name}`;
|
||||
const manifest = (await Bun.file(`${root}/package.json`).json()) as {
|
||||
exports: Record<string, { import: string }>;
|
||||
};
|
||||
// Type-only exports (e.g. @cline/shared/types) have no JS to ship.
|
||||
const exports = Object.fromEntries(
|
||||
Object.entries(manifest.exports)
|
||||
.map(([path, entry]) => [path, entry.import] as const)
|
||||
.filter(([, file]) => existsSync(`${root}/${file}`)),
|
||||
);
|
||||
return { root, name, exports };
|
||||
}),
|
||||
);
|
||||
await $`rm -rf ${outdir}`;
|
||||
const result = await Bun.build({
|
||||
entrypoints: packages.flatMap(({ root, exports }) =>
|
||||
Object.values(exports).map((file) => `${root}/${file}`),
|
||||
),
|
||||
root: packagesRoot,
|
||||
outdir,
|
||||
naming: { chunk: "core/dist/[name]-[hash].[ext]" },
|
||||
target: "node",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "plugin SDK bundle failed");
|
||||
}
|
||||
for (const { name, exports } of packages) {
|
||||
await Bun.write(
|
||||
`${outdir}/${name}/package.json`,
|
||||
JSON.stringify({ name: `@cline/${name}`, type: "module", exports }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
// All compiled helpers and the sidecar depend on fresh SDK package exports.
|
||||
await $`bun run build:sdk`.cwd(
|
||||
fileURLToPath(new URL("../../../../", import.meta.url)),
|
||||
);
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers src-tauri/bin/extensions`;
|
||||
// Plugins run in worker threads that load this self-contained bootstrap
|
||||
// from disk; the app ships it as a resource (see plugin-bootstrap.ts).
|
||||
await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js src-tauri/bin/extensions/plugin-sandbox-bootstrap.js`;
|
||||
await bundlePluginSdkPackages();
|
||||
if (targetTriple === "universal-apple-darwin") {
|
||||
await buildUniversalMacSidecar();
|
||||
} else {
|
||||
await buildSidecar(targetTriple);
|
||||
}
|
||||
await buildRemoteHelpers();
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -6,52 +6,77 @@ import {
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { compileCliBinary } from "../../../cli/script/compile-binary";
|
||||
|
||||
// Exercise the actual compiled entrypoint: source-only tests miss mixed SDK
|
||||
// build identities between the desktop client and its embedded Hub daemon.
|
||||
// Exercise the shipped runtime artifacts: the compiled Cline CLI that starts
|
||||
// the Hub and the backend bundle that runs on its embedded runtime. Source-only
|
||||
// tests miss mixed SDK build identities between the desktop client and the
|
||||
// CLI-managed Hub it attaches to.
|
||||
|
||||
let compiledBinaryDir: string | undefined;
|
||||
let compiledBinary: string | undefined;
|
||||
let buildDir: string | undefined;
|
||||
|
||||
function resolveSidecarBinary(): string {
|
||||
if (process.env.CLINE_TEST_SIDECAR_BIN) {
|
||||
return resolve(process.env.CLINE_TEST_SIDECAR_BIN);
|
||||
function ensureBuildDir(): string {
|
||||
buildDir ??= mkdtempSync(join(tmpdir(), "cline-desktop-startup-bin-"));
|
||||
return buildDir;
|
||||
}
|
||||
|
||||
let compiledCli: string | undefined;
|
||||
async function resolveCliBinary(): Promise<string> {
|
||||
if (process.env.CLINE_TEST_DESKTOP_CLI_BIN) {
|
||||
return resolve(process.env.CLINE_TEST_DESKTOP_CLI_BIN);
|
||||
}
|
||||
if (compiledBinary) {
|
||||
return compiledBinary;
|
||||
if (!compiledCli) {
|
||||
const outfile = join(
|
||||
ensureBuildDir(),
|
||||
process.platform === "win32" ? "cline-cli.exe" : "cline-cli",
|
||||
);
|
||||
await compileCliBinary({
|
||||
bunTarget:
|
||||
`bun-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` as Bun.Build.CompileTarget,
|
||||
outfile,
|
||||
autoloadLaunchDirectoryConfig: false,
|
||||
execArgv: ["--use-system-ca"],
|
||||
});
|
||||
compiledCli = outfile;
|
||||
}
|
||||
compiledBinaryDir = mkdtempSync(join(tmpdir(), "cline-desktop-startup-bin-"));
|
||||
const binary = join(
|
||||
compiledBinaryDir,
|
||||
process.platform === "win32" ? "sidecar.exe" : "sidecar",
|
||||
);
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"build",
|
||||
fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)),
|
||||
"--compile",
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
"--compile-exec-argv=--use-system-ca",
|
||||
"--outfile",
|
||||
binary,
|
||||
],
|
||||
{ cwd: compiledBinaryDir, encoding: "utf8", timeout: 60_000 },
|
||||
);
|
||||
expect(build.status, build.stderr || String(build.error)).toBe(0);
|
||||
compiledBinary = binary;
|
||||
return binary;
|
||||
return compiledCli;
|
||||
}
|
||||
|
||||
let bundledBackend: string | undefined;
|
||||
function resolveBackendBundle(): string {
|
||||
if (process.env.CLINE_TEST_DESKTOP_BACKEND_BUNDLE) {
|
||||
return resolve(process.env.CLINE_TEST_DESKTOP_BACKEND_BUNDLE);
|
||||
}
|
||||
if (!bundledBackend) {
|
||||
const outdir = join(ensureBuildDir(), "desktop-backend");
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"build",
|
||||
fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)),
|
||||
"--target",
|
||||
"bun",
|
||||
"--outdir",
|
||||
outdir,
|
||||
],
|
||||
{ encoding: "utf8", timeout: 120_000 },
|
||||
);
|
||||
expect(build.status, build.stderr || String(build.error)).toBe(0);
|
||||
bundledBackend = join(outdir, "index.js");
|
||||
}
|
||||
return bundledBackend;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
if (compiledBinaryDir) {
|
||||
if (buildDir) {
|
||||
try {
|
||||
rmSync(compiledBinaryDir, {
|
||||
rmSync(buildDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
@@ -63,8 +88,51 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
test("the shipped CLI and backend report telemetry without starting a hub", async () => {
|
||||
const cli = await resolveCliBinary();
|
||||
const bundle = resolveBackendBundle();
|
||||
for (const args of [
|
||||
["--telemetry-selfcheck"],
|
||||
["run", "--no-env-file", bundle, "--telemetry-selfcheck"],
|
||||
]) {
|
||||
const result = spawnSync(cli, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
env: { ...process.env, BUN_BE_BUN: args[0] === "run" ? "1" : "" },
|
||||
});
|
||||
expect(result.status, result.stderr || String(result.error)).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
telemetry_selfcheck: true,
|
||||
enabled: expect.any(Boolean),
|
||||
otlp_endpoint_host: expect.any(String),
|
||||
logs_exporter: expect.any(String),
|
||||
metrics_exporter: expect.any(String),
|
||||
});
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
async function reserveLoopbackPort(): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
|
||||
const address = server.address();
|
||||
await new Promise<void>((done) => server.close(() => done()));
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("could not reserve a loopback port");
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
function processCommandLine(pid: number): string | undefined {
|
||||
if (process.platform === "win32") return undefined;
|
||||
const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return ps.status === 0 ? ps.stdout.trim() : undefined;
|
||||
}
|
||||
|
||||
async function runStartupScenario(
|
||||
extraEnv: Record<string, string>,
|
||||
options: { hubAlreadyRunning: boolean; discoveryContents?: string },
|
||||
): Promise<void> {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-desktop-startup-"));
|
||||
const discoveryPath = join(root, "hub.json");
|
||||
@@ -75,12 +143,16 @@ async function runStartupScenario(
|
||||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||||
let hubPid: number | undefined;
|
||||
try {
|
||||
const binary = resolveSidecarBinary();
|
||||
if (options.discoveryContents !== undefined) {
|
||||
writeFileSync(discoveryPath, options.discoveryContents);
|
||||
}
|
||||
const cli = await resolveCliBinary();
|
||||
const bundle = resolveBackendBundle();
|
||||
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key]) =>
|
||||
!/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_)/.test(key) &&
|
||||
!/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_|BUN_BE_BUN)/.test(key) &&
|
||||
!/^(https?_proxy|all_proxy|no_proxy)$/i.test(key),
|
||||
),
|
||||
);
|
||||
@@ -88,31 +160,60 @@ async function runStartupScenario(
|
||||
CLINE_DIR: root,
|
||||
CLINE_DATA_DIR: join(root, "data"),
|
||||
CLINE_HUB_DISCOVERY_PATH: discoveryPath,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
...extraEnv,
|
||||
});
|
||||
// Bootstrap on an OS-assigned port using the same executable. Pin the
|
||||
// desktop to that port so this test cannot touch a developer's real Hub,
|
||||
// even if a regression makes it reject its own discovery record.
|
||||
const bootstrap = spawnSync(
|
||||
binary,
|
||||
["--remote-hub-ensure", "--discovery-path", discoveryPath, "--cwd", root],
|
||||
{ cwd: root, env, encoding: "utf8", timeout: 20_000 },
|
||||
);
|
||||
expect(bootstrap.status, bootstrap.stderr || String(bootstrap.error)).toBe(
|
||||
0,
|
||||
);
|
||||
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
|
||||
hubPid = hub.pid;
|
||||
env.CLINE_HUB_PORT = String(hub.port);
|
||||
child = Bun.spawn([binary], {
|
||||
cwd: root,
|
||||
env,
|
||||
// Pin the Hub to a free port so this test cannot touch a developer's
|
||||
// real Hub even if a regression makes the backend reject the CLI's
|
||||
// discovery record.
|
||||
let existingHub: { pid: number; url: string } | undefined;
|
||||
if (options.hubAlreadyRunning) {
|
||||
// Another client (e.g. a terminal) already started the shared Hub.
|
||||
const ensure = spawnSync(
|
||||
cli,
|
||||
[
|
||||
"hub",
|
||||
"ensure",
|
||||
"--json",
|
||||
"--cwd",
|
||||
root,
|
||||
"--port",
|
||||
"0",
|
||||
"--allow-port-fallback",
|
||||
"--no-connectors",
|
||||
],
|
||||
{ cwd: root, env, encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
expect(ensure.status, ensure.stderr || String(ensure.error)).toBe(0);
|
||||
const ensured = JSON.parse(ensure.stdout.trim().split("\n").at(-1) ?? "");
|
||||
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
|
||||
hubPid = hub.pid;
|
||||
expect(ensured).toMatchObject({
|
||||
url: hub.url,
|
||||
authToken: hub.authToken,
|
||||
});
|
||||
env.CLINE_HUB_PORT = String(hub.port);
|
||||
existingHub = { pid: hub.pid, url: hub.url };
|
||||
} else {
|
||||
env.CLINE_HUB_PORT = String(await reserveLoopbackPort());
|
||||
}
|
||||
|
||||
// The backend bundle on the CLI's runtime, started outside the
|
||||
// workspace like the packaged app does.
|
||||
child = Bun.spawn([cli, "run", "--no-env-file", bundle], {
|
||||
cwd: dirname(bundle),
|
||||
env: {
|
||||
...env,
|
||||
BUN_BE_BUN: "1",
|
||||
CLINE_DESKTOP_CLI_PATH: cli,
|
||||
CLINE_DESKTOP_WORKSPACE_ROOT: root,
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
let endpoint: string | undefined;
|
||||
const deadline = Date.now() + 15_000;
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
|
||||
try {
|
||||
@@ -134,6 +235,21 @@ async function runStartupScenario(
|
||||
});
|
||||
expect(health.ok).toBe(true);
|
||||
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
|
||||
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
|
||||
hubPid = hub.pid;
|
||||
if (existingHub) {
|
||||
// The backend attached to the running Hub instead of replacing it.
|
||||
expect(hub).toMatchObject(existingHub);
|
||||
} else {
|
||||
expect(hub.port).toBe(Number(env.CLINE_HUB_PORT));
|
||||
}
|
||||
// Either way the Hub daemon is the bundled CLI, not the backend.
|
||||
expect(hub.pid).not.toBe(child.pid);
|
||||
const hubCommand = processCommandLine(hub.pid);
|
||||
if (hubCommand !== undefined) {
|
||||
expect(hubCommand).toContain(cli);
|
||||
expect(hubCommand).toContain("--cline-hub-daemon");
|
||||
}
|
||||
} finally {
|
||||
if (child && child.exitCode === null) {
|
||||
child.kill();
|
||||
@@ -141,7 +257,7 @@ async function runStartupScenario(
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
await child.exited;
|
||||
}
|
||||
// A failed bootstrap may still have published a discovery record.
|
||||
// A failed start may still have published a discovery record.
|
||||
try {
|
||||
hubPid ??= JSON.parse(readFileSync(discoveryPath, "utf8")).pid;
|
||||
if (hubPid) process.kill(hubPid, "SIGTERM");
|
||||
@@ -149,10 +265,10 @@ async function runStartupScenario(
|
||||
/* The isolated Hub may already have exited. */
|
||||
}
|
||||
// Windows refuses to remove a directory that is any live process's cwd,
|
||||
// and both the Hub and the backend run with `root` as theirs. The kill
|
||||
// above only requests termination, so wait for the pid to actually go
|
||||
// away; on POSIX the remove would have succeeded regardless, which is
|
||||
// why this only ever failed on the Windows runner.
|
||||
// and the Hub runs with `root` as its cwd. The kill above only requests
|
||||
// termination, so wait for the pid to actually go away; on POSIX the
|
||||
// remove would have succeeded regardless, which is why this only ever
|
||||
// failed on the Windows runner.
|
||||
if (hubPid) {
|
||||
const gone = Date.now() + 10_000;
|
||||
while (Date.now() < gone) {
|
||||
@@ -182,17 +298,33 @@ async function runStartupScenario(
|
||||
}
|
||||
}
|
||||
|
||||
test("compiled desktop backend publishes its endpoint with its own Hub", async () => {
|
||||
await runStartupScenario({});
|
||||
}, 100_000);
|
||||
test("desktop backend has the bundled CLI start the Hub when none is running", async () => {
|
||||
await runStartupScenario({}, { hubAlreadyRunning: false });
|
||||
}, 180_000);
|
||||
|
||||
test("desktop backend reuses a compatible Hub the CLI already started", async () => {
|
||||
await runStartupScenario({}, { hubAlreadyRunning: true });
|
||||
}, 180_000);
|
||||
|
||||
// Bun's fetch has no localhost proxy bypass, so a system proxy used to swallow
|
||||
// the hub discovery probes (cline/cline#14265, #14292).
|
||||
test("compiled desktop backend starts behind a dead HTTP(S) proxy", async () => {
|
||||
await runStartupScenario({
|
||||
HTTP_PROXY: "http://127.0.0.1:9",
|
||||
HTTPS_PROXY: "http://127.0.0.1:9",
|
||||
http_proxy: "http://127.0.0.1:9",
|
||||
https_proxy: "http://127.0.0.1:9",
|
||||
});
|
||||
}, 100_000);
|
||||
test("desktop startup works behind a dead HTTP(S) proxy", async () => {
|
||||
await runStartupScenario(
|
||||
{
|
||||
HTTP_PROXY: "http://127.0.0.1:9",
|
||||
HTTPS_PROXY: "http://127.0.0.1:9",
|
||||
http_proxy: "http://127.0.0.1:9",
|
||||
https_proxy: "http://127.0.0.1:9",
|
||||
},
|
||||
{ hubAlreadyRunning: false },
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
for (const discoveryContents of ["{ invalid json", "{}"]) {
|
||||
test(`desktop startup recovers from an invalid discovery record: ${discoveryContents}`, async () => {
|
||||
await runStartupScenario(
|
||||
{},
|
||||
{ hubAlreadyRunning: false, discoveryContents },
|
||||
);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
|
||||
throw new Error(
|
||||
[
|
||||
`cannot build ${platform} desktop bundles from ${host}.`,
|
||||
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
|
||||
"Tauri desktop bundles are produced on the target OS because the native bundle tools and bundled Cline CLI binary are platform-specific.",
|
||||
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -285,7 +285,7 @@ const main = async () => {
|
||||
|
||||
if (!skipBuild) {
|
||||
// Linux ships deb and rpm only. The AppImage target is skipped because
|
||||
// linuxdeploy cannot process the Bun-compiled sidecar (ldd fails on it
|
||||
// linuxdeploy cannot process the Bun-compiled Cline CLI (ldd fails on it
|
||||
// and patchelf corrupts it), which aborts the whole bundle step.
|
||||
if (platform === "linux") {
|
||||
await $`bun run build:binary --bundles deb,rpm`;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# Invoked by the Tauri bundler through `bundle > windows > signCommand` (the
|
||||
# desktop-publish workflow generates a config overlay pointing here), once per
|
||||
# binary it stages: the main app exe, the code-sidecar external binary, the
|
||||
# binary it stages: the main app exe, the bundled cline-cli external binary, the
|
||||
# NSIS uninstaller, and the NSIS installer itself.
|
||||
#
|
||||
# Requirements (all provided by the desktop-publish Windows job):
|
||||
|
||||
@@ -158,7 +158,7 @@ Section
|
||||
Quit
|
||||
CreateDirectory "$INSTDIR"
|
||||
ClearErrors
|
||||
FileOpen $0 "$INSTDIR\\code-sidecar.exe" w
|
||||
FileOpen $0 "$INSTDIR\\cline-cli.exe" w
|
||||
IfErrors 0 +3
|
||||
SetErrorLevel 4
|
||||
Quit
|
||||
@@ -170,7 +170,7 @@ SectionEnd
|
||||
Section "Uninstall"
|
||||
!insertmacro NSIS_HOOK_PREUNINSTALL
|
||||
ClearErrors
|
||||
Delete "$INSTDIR\\code-sidecar.exe"
|
||||
Delete "$INSTDIR\\cline-cli.exe"
|
||||
Delete "$INSTDIR\\cline-app.exe"
|
||||
IfErrors 0 +2
|
||||
SetErrorLevel 5
|
||||
@@ -201,28 +201,28 @@ SectionEnd
|
||||
const dir = path.join(root, "fresh install");
|
||||
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
|
||||
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
|
||||
expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe(
|
||||
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
|
||||
"replaced",
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
test("stops all target x64 processes, releases the file, and preserves another install", async () => {
|
||||
const dir = path.join(root, "Cline 'quoted' $ space (测试)");
|
||||
const beta = await start(`${dir} Beta`, "code-sidecar.exe");
|
||||
const beta = await start(`${dir} Beta`, "cline-cli.exe");
|
||||
const targets = [
|
||||
await start(dir, "cline-app.exe"),
|
||||
await start(dir, "code-sidecar.exe"),
|
||||
await start(dir, "code-sidecar.exe"),
|
||||
await start(dir, "cline-cli.exe"),
|
||||
await start(dir, "cline-cli.exe"),
|
||||
];
|
||||
// Windows must actually have the executable locked before the test.
|
||||
expect(() =>
|
||||
writeFileSync(path.join(dir, "code-sidecar.exe"), "locked"),
|
||||
writeFileSync(path.join(dir, "cline-cli.exe"), "locked"),
|
||||
).toThrow();
|
||||
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
|
||||
await waitFor(() =>
|
||||
targets.every((p) => p.exitCode !== null || p.signalCode !== null),
|
||||
);
|
||||
expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe(
|
||||
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
|
||||
"replaced",
|
||||
);
|
||||
expect(beta.exitCode).toBeNull();
|
||||
@@ -232,14 +232,25 @@ SectionEnd
|
||||
test("uninstaller releases the same files before deleting them", async () => {
|
||||
const dir = path.join(root, "uninstall");
|
||||
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
|
||||
copyFileSync(fixture, path.join(dir, "code-sidecar.exe"));
|
||||
const target = await start(dir, "code-sidecar.exe");
|
||||
copyFileSync(fixture, path.join(dir, "cline-cli.exe"));
|
||||
const target = await start(dir, "cline-cli.exe");
|
||||
// _?= disables NSIS's temp-copy trampoline so we wait for the real exit.
|
||||
expect(
|
||||
await run(path.join(dir, "uninstall.exe"), ["/S", `_?=${dir}`]),
|
||||
).toBe(0);
|
||||
await waitFor(() => target.exitCode !== null || target.signalCode !== null);
|
||||
expect(existsSync(path.join(dir, "cline-cli.exe"))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test("upgrade stops and removes the earlier backend executable", async () => {
|
||||
const dir = path.join(root, "upgrade");
|
||||
const legacy = await start(dir, "code-sidecar.exe");
|
||||
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
|
||||
await waitFor(() => legacy.exitCode !== null || legacy.signalCode !== null);
|
||||
expect(existsSync(path.join(dir, "code-sidecar.exe"))).toBe(false);
|
||||
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
|
||||
"replaced",
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
// Only silent mode is automatable: passive (/P, what the Tauri updater
|
||||
@@ -249,9 +260,9 @@ SectionEnd
|
||||
// RmRegisterResources rejects directories with ERROR_ACCESS_DENIED.
|
||||
// This exercises a real native error, not an injected API mock.
|
||||
mkdirSync(path.join(dir, "cline-app.exe"), { recursive: true });
|
||||
writeFileSync(path.join(dir, "code-sidecar.exe"), "untouched");
|
||||
writeFileSync(path.join(dir, "cline-cli.exe"), "untouched");
|
||||
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(2);
|
||||
expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe(
|
||||
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
|
||||
"untouched",
|
||||
);
|
||||
expect(existsSync(path.join(dir, "uninstall.exe"))).toBe(false);
|
||||
|
||||
@@ -82,9 +82,11 @@ sessionManager.subscribe((event) => {
|
||||
});
|
||||
```
|
||||
|
||||
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
The packaged backend runs on the bundled Cline CLI and has it start the Hub:
|
||||
`cline-cli hub ensure` reuses a compatible healthy Hub or starts one, and the
|
||||
Hub daemon is that same CLI binary. Any Hub the backend itself has to start
|
||||
later goes through the same binary (`setHubDaemonLauncher`). Startup discovery
|
||||
and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
Every create, restart, fork, and restore also attaches the serializable Desktop
|
||||
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime";
|
||||
|
||||
describe("ensureHubWithDesktopCli", () => {
|
||||
it("asks the bundled CLI to start or reuse the Hub and returns only its URL", async () => {
|
||||
const run = vi.fn(
|
||||
async () =>
|
||||
'warming up\n{"url":"ws://127.0.0.1:25463/hub","authToken":"secret","cwd":"/work"}\n',
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureHubWithDesktopCli("/opt/cline/cline-cli", "/work", {
|
||||
env: { PATH: "/usr/bin" },
|
||||
run,
|
||||
}),
|
||||
).resolves.toEqual({ url: "ws://127.0.0.1:25463/hub" });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
"/opt/cline/cline-cli",
|
||||
["hub", "ensure", "--json", "--cwd", "/work"],
|
||||
{
|
||||
cwd: "/work",
|
||||
env: { PATH: "/usr/bin", CLINE_NO_AUTO_UPDATE: "1" },
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when the CLI does not report a Hub URL", async () => {
|
||||
await expect(
|
||||
ensureHubWithDesktopCli("/opt/cline/cline-cli", "/work", {
|
||||
env: {},
|
||||
run: async () => "",
|
||||
}),
|
||||
).rejects.toThrow("did not report a Hub URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoptDesktopCliRuntime", () => {
|
||||
it("routes Hub spawns through the bundled CLI and restores the workspace cwd", () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
BUN_BE_BUN: "1",
|
||||
CLINE_DESKTOP_CLI_PATH:
|
||||
"/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
CLINE_DESKTOP_WORKSPACE_ROOT: "/Users/dev/project",
|
||||
};
|
||||
const chdir = vi.fn();
|
||||
const setLauncher = vi.fn();
|
||||
|
||||
expect(adoptDesktopCliRuntime({ env, chdir, setLauncher })).toEqual({
|
||||
cliPath: "/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
});
|
||||
|
||||
expect(env.BUN_BE_BUN).toBeUndefined();
|
||||
expect(env.CLINE_DESKTOP_WORKSPACE_ROOT).toBeUndefined();
|
||||
expect(env.CLINE_DESKTOP_CLI_PATH).toBe(
|
||||
"/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
);
|
||||
expect(chdir).toHaveBeenCalledWith("/Users/dev/project");
|
||||
expect(setLauncher).toHaveBeenCalledWith({
|
||||
command: "/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the default Hub launcher when no bundled CLI is provided", () => {
|
||||
const chdir = vi.fn();
|
||||
const setLauncher = vi.fn();
|
||||
|
||||
expect(adoptDesktopCliRuntime({ env: {}, chdir, setLauncher })).toEqual({
|
||||
cliPath: undefined,
|
||||
});
|
||||
expect(chdir).not.toHaveBeenCalled();
|
||||
expect(setLauncher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { setHubDaemonLauncher } from "@cline/core";
|
||||
|
||||
/** Absolute path of the Cline CLI binary the desktop shell bundles and launched us with. */
|
||||
export const DESKTOP_CLI_PATH_ENV = "CLINE_DESKTOP_CLI_PATH";
|
||||
/** Workspace root the shell resolved; the backend starts in its own resource directory. */
|
||||
export const DESKTOP_WORKSPACE_ROOT_ENV = "CLINE_DESKTOP_WORKSPACE_ROOT";
|
||||
/** Makes a compiled Bun executable run a script instead of its embedded entrypoint. */
|
||||
export const BUN_BE_BUN_ENV = "BUN_BE_BUN";
|
||||
|
||||
export function resolveDesktopCliPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
return env[DESKTOP_CLI_PATH_ENV]?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The packaged backend is a script running on the bundled Cline CLI's embedded
|
||||
* runtime. Adopt that host before anything spawns: every child (the Hub
|
||||
* daemon, connectors, agent tools) must see the CLI binary as the CLI, not as
|
||||
* a script runner, and Hubs this process starts must be CLI-managed Hubs.
|
||||
*/
|
||||
export function adoptDesktopCliRuntime(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
chdir?: (directory: string) => void;
|
||||
setLauncher?: typeof setHubDaemonLauncher;
|
||||
} = {},
|
||||
): { cliPath?: string } {
|
||||
const env = options.env ?? process.env;
|
||||
const chdir = options.chdir ?? ((directory) => process.chdir(directory));
|
||||
const setLauncher = options.setLauncher ?? setHubDaemonLauncher;
|
||||
delete env[BUN_BE_BUN_ENV];
|
||||
// The shell starts the runtime outside the workspace so it cannot pick up a
|
||||
// workspace .env or bunfig.toml; workspace-relative code expects that cwd.
|
||||
const workspaceRoot = env[DESKTOP_WORKSPACE_ROOT_ENV]?.trim();
|
||||
delete env[DESKTOP_WORKSPACE_ROOT_ENV];
|
||||
if (workspaceRoot) {
|
||||
chdir(workspaceRoot);
|
||||
}
|
||||
const cliPath = resolveDesktopCliPath(env);
|
||||
if (cliPath) {
|
||||
setLauncher({ command: cliPath });
|
||||
}
|
||||
return { cliPath };
|
||||
}
|
||||
|
||||
const HUB_ENSURE_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type RunCliCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
|
||||
) => Promise<string>;
|
||||
|
||||
const runCliCommand: RunCliCommand = (command, args, options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
timeout: options.timeoutMs,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const detail = String(stderr).trim();
|
||||
reject(
|
||||
new Error(detail ? `${error.message}: ${detail}` : error.message),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(String(stdout));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Has the bundled CLI start the shared Hub, or reuse a compatible healthy
|
||||
* one, before the session manager connects. Returns the Hub URL (never the
|
||||
* auth token, which callers may log).
|
||||
*/
|
||||
export async function ensureHubWithDesktopCli(
|
||||
cliPath: string,
|
||||
workspaceRoot: string,
|
||||
options: { env?: NodeJS.ProcessEnv; run?: RunCliCommand } = {},
|
||||
): Promise<{ url: string }> {
|
||||
const run = options.run ?? runCliCommand;
|
||||
const stdout = await run(
|
||||
cliPath,
|
||||
["hub", "ensure", "--json", "--cwd", workspaceRoot],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env: { ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
|
||||
timeoutMs: HUB_ENSURE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
const line = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.startsWith("{"))
|
||||
.at(-1);
|
||||
const parsed = line ? (JSON.parse(line) as { url?: unknown }) : undefined;
|
||||
if (typeof parsed?.url !== "string" || !parsed.url) {
|
||||
throw new Error("cline hub ensure did not report a Hub URL");
|
||||
}
|
||||
return { url: parsed.url };
|
||||
}
|
||||
@@ -29,6 +29,18 @@ describe("desktop connector lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("runs connector commands through the bundled CLI when the shell provides one", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand("/repo", ["telegram"], {
|
||||
desktopCliPath: "C:\\Program Files\\Cline\\cline-cli.exe",
|
||||
execPath: "C:\\Program Files\\Cline\\cline-cli.exe",
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "C:\\Program Files\\Cline\\cline-cli.exe",
|
||||
childArgs: ["connect", "telegram"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the atomic restart command for an active channel", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setConnectorCliLaunchSpec,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { resolveDesktopCliPath } from "./cli-runtime";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
@@ -113,9 +114,14 @@ function buildCliConnectCommand(
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
desktopCliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const desktopCliPath = options.desktopCliPath ?? resolveDesktopCliPath();
|
||||
if (desktopCliPath) {
|
||||
return { launcher: desktopCliPath, childArgs: ["connect", ...args] };
|
||||
}
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath =
|
||||
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
setModelToolEnabledGlobally,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper";
|
||||
import {
|
||||
buildTelemetrySelfcheckReport,
|
||||
captureSdkError,
|
||||
claimHubDaemonProcess,
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
ensureLoopbackProxyBypass,
|
||||
setClineClientIdentity,
|
||||
} from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime";
|
||||
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
@@ -29,7 +29,6 @@ import { createDesktopObservability } from "./observability";
|
||||
import { configurePluginBootstrap } from "./plugin-bootstrap";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
@@ -54,7 +53,7 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
async function main(desktopCliPath?: string) {
|
||||
if (!BunRuntime) {
|
||||
throw new Error("sidecar must be run with Bun");
|
||||
}
|
||||
@@ -96,6 +95,18 @@ async function main() {
|
||||
"Login shell PATH resolution",
|
||||
await shellPathPromise,
|
||||
);
|
||||
// The packaged app hands Hub startup to its bundled CLI: it reuses a
|
||||
// compatible healthy Hub or starts one, and the session manager attaches.
|
||||
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
|
||||
// the session manager still starts the Hub through the same CLI launcher.
|
||||
if (desktopCliPath) {
|
||||
try {
|
||||
const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot);
|
||||
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
|
||||
} catch (error) {
|
||||
observability.logger.error?.("Cline CLI hub ensure failed", { error });
|
||||
}
|
||||
}
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -221,8 +232,8 @@ async function main() {
|
||||
|
||||
/**
|
||||
* Prints whether the telemetry configuration that was inlined at build time
|
||||
* (see scripts/telemetry-define-args.ts) actually made it into this binary,
|
||||
* then exits. CI runs this against the packaged sidecar and fails the
|
||||
* (see scripts/telemetry-define-args.ts) actually made it into this bundle,
|
||||
* then exits. CI runs this against the packaged backend and fails the
|
||||
* publish when a release-grade build reports `"enabled":false` or an
|
||||
* unusable OTLP endpoint, so a regression in the build-time inlining can
|
||||
* never ship silently again.
|
||||
@@ -235,31 +246,20 @@ function runTelemetrySelfcheck(): void {
|
||||
}
|
||||
|
||||
async function runEntrypoint(): Promise<void> {
|
||||
// Before the daemon-sentinel claim: the selfcheck only inspects build-time
|
||||
// config and must not consume the sentinel or start anything.
|
||||
// The selfcheck only inspects build-time config and must not start anything.
|
||||
if (process.argv.includes("--telemetry-selfcheck")) {
|
||||
runTelemetrySelfcheck();
|
||||
return;
|
||||
}
|
||||
const { cliPath } = adoptDesktopCliRuntime();
|
||||
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
|
||||
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
// Before the Hub daemon (this binary, re-executed) loads any plugin.
|
||||
// Before the bundled CLI starts the Hub daemon and loads any plugin.
|
||||
configurePluginBootstrap();
|
||||
// Before the Hub daemon and agent-spawned processes inherit this env.
|
||||
ensureLoopbackProxyBypass();
|
||||
// Claim the Hub daemon sentinel here, not in the shared remote helper: its
|
||||
// daemon import resolves to the dist build of @cline/core while this bundle
|
||||
// resolves the source build, and a daemon from the other copy publishes a
|
||||
// different build id, so the sidecar would retire its own Hub on launch.
|
||||
if (claimHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
if (await runRemoteHelperEntrypoint()) {
|
||||
return;
|
||||
}
|
||||
await main();
|
||||
await main(cliPath);
|
||||
}
|
||||
|
||||
runEntrypoint().catch(async (error) => {
|
||||
|
||||
@@ -28,7 +28,7 @@ function withLayout(
|
||||
it("finds the bootstrap in the macOS app bundle resources", () => {
|
||||
withLayout(
|
||||
(root) => join(root, "Cline.app", "Contents", "Resources"),
|
||||
(root) => join(root, "Cline.app", "Contents", "MacOS", "code-sidecar"),
|
||||
(root) => join(root, "Cline.app", "Contents", "MacOS", "cline-cli"),
|
||||
(bootstrap, execPath) => {
|
||||
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
|
||||
bootstrap,
|
||||
@@ -40,7 +40,7 @@ it("finds the bootstrap in the macOS app bundle resources", () => {
|
||||
it("finds the bootstrap in the Linux and Windows resource layouts", () => {
|
||||
withLayout(
|
||||
(root) => join(root, "usr", "lib", "Cline"),
|
||||
(root) => join(root, "usr", "bin", "code-sidecar"),
|
||||
(root) => join(root, "usr", "bin", "cline-cli"),
|
||||
(bootstrap, execPath) => {
|
||||
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
|
||||
bootstrap,
|
||||
@@ -49,7 +49,7 @@ it("finds the bootstrap in the Linux and Windows resource layouts", () => {
|
||||
);
|
||||
withLayout(
|
||||
(root) => root,
|
||||
(root) => join(root, "code-sidecar.exe"),
|
||||
(root) => join(root, "cline-cli.exe"),
|
||||
(bootstrap, execPath) => {
|
||||
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
|
||||
bootstrap,
|
||||
|
||||
@@ -7,9 +7,8 @@ const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js
|
||||
|
||||
/**
|
||||
* Finds the plugin worker bootstrap the app bundle ships as a Tauri resource.
|
||||
* The compiled sidecar cannot locate it from its own module path (that points
|
||||
* inside the binary), so the Hub daemon it runs would otherwise fall back to
|
||||
* loading plugins in-process.
|
||||
* The backend and CLI-managed Hub need an on-disk bootstrap to run plugins
|
||||
* in workers; without it they would fall back to loading plugins in-process.
|
||||
*/
|
||||
export function resolveDesktopPluginBootstrap(
|
||||
options: { execPath?: string; cwd?: string } = {},
|
||||
|
||||
@@ -15,7 +15,7 @@ it("finds SSH helpers in the installed Windows resource layout", () => {
|
||||
writeFileSync(helper, "helper");
|
||||
expect(
|
||||
resolveDesktopRemoteHelper(target, {
|
||||
execPath: join(root, "code-sidecar.exe"),
|
||||
execPath: join(root, "cline-cli.exe"),
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
}),
|
||||
@@ -29,25 +29,35 @@ it("finds SSH helpers in the installed Windows resource layout", () => {
|
||||
const FAT_MACHO_HEADER = Buffer.from("cafebabe00000002", "hex");
|
||||
const THIN_ARM64_MACHO_HEADER = Buffer.from("cffaedfe0c000001", "hex");
|
||||
|
||||
it("uses the packaged universal macOS sidecar as the helper for Mac remotes", () => {
|
||||
it("uses the bundled universal macOS CLI for Mac remotes", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
|
||||
try {
|
||||
const macOS = join(root, "Cline.app", "Contents", "MacOS");
|
||||
mkdirSync(macOS, { recursive: true });
|
||||
const sidecar = join(macOS, "code-sidecar");
|
||||
writeFileSync(sidecar, FAT_MACHO_HEADER);
|
||||
const cli = join(macOS, "cline-cli");
|
||||
writeFileSync(cli, FAT_MACHO_HEADER);
|
||||
for (const arch of ["arm64", "x64"] as const) {
|
||||
expect(
|
||||
resolveDesktopRemoteHelper(
|
||||
{ platform: "darwin", arch },
|
||||
{ execPath: sidecar, cwd: tmpdir(), env: {}, platform: "darwin" },
|
||||
{
|
||||
execPath: cli,
|
||||
cwd: tmpdir(),
|
||||
env: { CLINE_DESKTOP_CLI_PATH: cli },
|
||||
platform: "darwin",
|
||||
},
|
||||
),
|
||||
).toBe(sidecar);
|
||||
).toBe(cli);
|
||||
}
|
||||
expect(
|
||||
resolveDesktopRemoteHelper(
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
{ execPath: sidecar, cwd: tmpdir(), env: {}, platform: "darwin" },
|
||||
{
|
||||
execPath: cli,
|
||||
cwd: tmpdir(),
|
||||
env: { CLINE_DESKTOP_CLI_PATH: cli },
|
||||
platform: "darwin",
|
||||
},
|
||||
),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
@@ -55,17 +65,17 @@ it("uses the packaged universal macOS sidecar as the helper for Mac remotes", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("only offers a thin packaged macOS sidecar to Mac remotes of its own architecture", () => {
|
||||
it("only offers a thin bundled macOS CLI to Mac remotes of its own architecture", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
|
||||
try {
|
||||
const macOS = join(root, "Cline.app", "Contents", "MacOS");
|
||||
mkdirSync(macOS, { recursive: true });
|
||||
const sidecar = join(macOS, "code-sidecar");
|
||||
writeFileSync(sidecar, THIN_ARM64_MACHO_HEADER);
|
||||
const cli = join(macOS, "cline-cli");
|
||||
writeFileSync(cli, THIN_ARM64_MACHO_HEADER);
|
||||
const options = {
|
||||
execPath: sidecar,
|
||||
execPath: cli,
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
env: { CLINE_DESKTOP_CLI_PATH: cli },
|
||||
platform: "darwin" as const,
|
||||
};
|
||||
expect(
|
||||
@@ -73,7 +83,7 @@ it("only offers a thin packaged macOS sidecar to Mac remotes of its own architec
|
||||
{ platform: "darwin", arch: "arm64" },
|
||||
options,
|
||||
),
|
||||
).toBe(sidecar);
|
||||
).toBe(cli);
|
||||
expect(
|
||||
resolveDesktopRemoteHelper({ platform: "darwin", arch: "x64" }, options),
|
||||
).toBeUndefined();
|
||||
@@ -82,13 +92,13 @@ it("only offers a thin packaged macOS sidecar to Mac remotes of its own architec
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => {
|
||||
it("uses the compiled CLI for Mac remotes under tauri dev on macOS", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-dev-helpers-"));
|
||||
try {
|
||||
const bin = join(root, "src-tauri", "bin");
|
||||
mkdirSync(bin, { recursive: true });
|
||||
const sidecar = join(bin, "code-sidecar-aarch64-apple-darwin");
|
||||
writeFileSync(sidecar, "sidecar");
|
||||
const cli = join(bin, "cline-cli-aarch64-apple-darwin");
|
||||
writeFileSync(cli, "cli");
|
||||
const options = {
|
||||
execPath: "/usr/local/bin/bun",
|
||||
cwd: root,
|
||||
@@ -100,7 +110,7 @@ it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => {
|
||||
{ platform: "darwin", arch: "arm64" },
|
||||
options,
|
||||
),
|
||||
).toBe(sidecar);
|
||||
).toBe(cli);
|
||||
expect(
|
||||
resolveDesktopRemoteHelper({ platform: "darwin", arch: "x64" }, options),
|
||||
).toBeUndefined();
|
||||
@@ -109,7 +119,7 @@ it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("finds SSH helpers in the installed Linux resource layout", () => {
|
||||
it("selects only the running Linux channels SSH helpers", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
|
||||
try {
|
||||
const target = { platform: "linux", arch: "arm64" } as const;
|
||||
@@ -124,13 +134,35 @@ it("finds SSH helpers in the installed Linux resource layout", () => {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const helper = join(directory, remoteHelperBinaryFilename(target));
|
||||
writeFileSync(helper, "helper");
|
||||
const otherDirectory = join(
|
||||
root,
|
||||
"usr",
|
||||
"lib",
|
||||
"Cline",
|
||||
"bin",
|
||||
"remote-helpers",
|
||||
);
|
||||
mkdirSync(otherDirectory, { recursive: true });
|
||||
writeFileSync(
|
||||
join(otherDirectory, remoteHelperBinaryFilename(target)),
|
||||
"other channel",
|
||||
);
|
||||
expect(
|
||||
resolveDesktopRemoteHelper(target, {
|
||||
execPath: join(root, "usr", "bin", "code-sidecar"),
|
||||
execPath: join(root, "usr", "bin", "cline-cli"),
|
||||
cwd: tmpdir(),
|
||||
env: {},
|
||||
env: { CLINE_REMOTE_HELPER_DIRECTORY: directory },
|
||||
}),
|
||||
).toBe(helper);
|
||||
// A missing binary in this channel must not fall back to another app.
|
||||
rmSync(helper);
|
||||
expect(
|
||||
resolveDesktopRemoteHelper(target, {
|
||||
execPath: join(root, "usr", "bin", "cline-cli"),
|
||||
cwd: tmpdir(),
|
||||
env: { CLINE_REMOTE_HELPER_DIRECTORY: directory },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { closeSync, existsSync, openSync, readdirSync, readSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
type RemoteHelperTarget,
|
||||
remoteHelperBinaryFilename,
|
||||
} from "@cline/core";
|
||||
import { resolveDesktopCliPath } from "./cli-runtime";
|
||||
|
||||
/** Base name of the Cline CLI binary the desktop app bundles (Tauri externalBin). */
|
||||
export const DESKTOP_CLI_BINARY_NAME = "cline-cli";
|
||||
|
||||
// Tauri's Linux bundles (deb, rpm, AppImage) install binaries under `usr/bin`
|
||||
// and resources under `usr/lib/<productName>`. The product name differs per
|
||||
@@ -36,8 +34,8 @@ const MACHO_CPU_TYPE: Record<RemoteHelperTarget["arch"], number> = {
|
||||
};
|
||||
|
||||
// Published desktop builds are universal, but `package:desktop:mac` and local
|
||||
// `tauri build` produce a thin host-arch sidecar that cannot run on a Mac of
|
||||
// the other architecture. A fat binary starts with a big-endian magic; a thin
|
||||
// `tauri build` produce a thin host-arch CLI that cannot run on a Mac of the
|
||||
// other architecture. A fat binary starts with a big-endian magic; a thin
|
||||
// 64-bit Mach-O starts with its little-endian magic followed by the cputype.
|
||||
function machoRunsOn(path: string, arch: RemoteHelperTarget["arch"]): boolean {
|
||||
const header = Buffer.alloc(8);
|
||||
@@ -58,25 +56,23 @@ function machoRunsOn(path: string, arch: RemoteHelperTarget["arch"]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// macOS bundles carry no dedicated darwin helper: Tauri signs only externalBin
|
||||
// and the main binary, so a Mach-O under Contents/Resources would ship
|
||||
// unsigned and fail notarization. The sidecar already runs the shared helper
|
||||
// entrypoint (see index.ts) and is the signed, notarized, universal Mach-O in
|
||||
// the bundle, so it serves both x64 and arm64 Mac remotes from a Mac. Under
|
||||
// `tauri dev` the sidecar runs as a script, so use the compiled sidecar that
|
||||
// beforeDevCommand builds for the matching architecture instead.
|
||||
function macSidecarCandidates(
|
||||
// macOS bundles carry no separate darwin copies of the CLI: Tauri signs only
|
||||
// externalBin and the main binary, so a Mach-O under Contents/Resources would
|
||||
// ship unsigned and fail notarization. The bundled CLI is the signed,
|
||||
// notarized, universal Mach-O in the bundle, so it serves both x64 and arm64
|
||||
// Mac remotes from a Mac. Under `tauri dev` the backend may run on a system
|
||||
// Bun, so fall back to the CLI that beforeDevCommand compiles.
|
||||
function macCliCandidates(
|
||||
target: RemoteHelperTarget,
|
||||
execPath: string,
|
||||
desktopCliPath: string | undefined,
|
||||
cwd: string,
|
||||
): string[] {
|
||||
const compiledSidecar = `code-sidecar-${target.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
|
||||
const compiledCli = `${DESKTOP_CLI_BINARY_NAME}-${target.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
|
||||
return [
|
||||
...(basename(execPath).startsWith("code-sidecar") &&
|
||||
machoRunsOn(execPath, target.arch)
|
||||
? [execPath]
|
||||
...(desktopCliPath && machoRunsOn(desktopCliPath, target.arch)
|
||||
? [desktopCliPath]
|
||||
: []),
|
||||
join(cwd, "src-tauri", "bin", compiledSidecar),
|
||||
join(cwd, "src-tauri", "bin", compiledCli),
|
||||
join(
|
||||
cwd,
|
||||
"apps",
|
||||
@@ -84,7 +80,7 @@ function macSidecarCandidates(
|
||||
"desktop-app",
|
||||
"src-tauri",
|
||||
"bin",
|
||||
compiledSidecar,
|
||||
compiledCli,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -111,12 +107,11 @@ export function resolveDesktopRemoteHelper(
|
||||
? [join(env.CLINE_REMOTE_HELPER_DIRECTORY, filename)]
|
||||
: []),
|
||||
...(target.platform === "darwin" && platform === "darwin"
|
||||
? macSidecarCandidates(target, execPath, cwd)
|
||||
? macCliCandidates(target, resolveDesktopCliPath(env), cwd)
|
||||
: []),
|
||||
join(executableDirectory, "remote-helpers", filename),
|
||||
join(executableDirectory, bundledPath),
|
||||
join(executableDirectory, "..", "Resources", bundledPath),
|
||||
...linuxResourceCandidates(executableDirectory, bundledPath),
|
||||
join(cwd, "src-tauri", bundledPath),
|
||||
join(cwd, "apps", "examples", "desktop-app", "src-tauri", bundledPath),
|
||||
].find(existsSync);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
!include "Win\RestartManager.nsh"
|
||||
|
||||
; The Hub re-executes code-sidecar.exe as a detached daemon and outlives the
|
||||
; desktop. Stop users of both installed executables before replacing either:
|
||||
; stopping only the sidecar first leaves the desktop able to spawn it again.
|
||||
; The bundled cline-cli.exe runs the desktop backend and, as a detached
|
||||
; daemon, the Hub, which outlives the desktop. Stop users of every installed
|
||||
; executable before replacing any: stopping only the CLI first leaves the
|
||||
; desktop able to spawn it again. code-sidecar.exe is the backend and Hub
|
||||
; executable of earlier releases; its Hub may still be running on upgrade.
|
||||
;
|
||||
; Restart Manager identifies users of the exact files across architectures.
|
||||
; Do not use Get-Process.Path: NSIS is x86 even for an x64 bundle, and x86
|
||||
@@ -26,6 +28,11 @@
|
||||
Goto end_session
|
||||
${EndIf}
|
||||
|
||||
!insertmacro RestartManager_RegisterFile $1 "$INSTDIR\cline-cli.exe"
|
||||
${If} $0 != 0
|
||||
Goto end_session
|
||||
${EndIf}
|
||||
|
||||
!insertmacro RestartManager_RegisterFile $1 "$INSTDIR\code-sidecar.exe"
|
||||
${If} $0 != 0
|
||||
Goto end_session
|
||||
@@ -64,6 +71,8 @@
|
||||
Abort
|
||||
|
||||
done:
|
||||
; Nothing installs the earlier backend executable any more.
|
||||
Delete "$INSTDIR\code-sidecar.exe"
|
||||
Pop $4
|
||||
Pop $3
|
||||
Pop $2
|
||||
|
||||
@@ -94,6 +94,7 @@ struct TrayMenuState {
|
||||
struct AppContext {
|
||||
launch_cwd: String,
|
||||
workspace_root: String,
|
||||
resource_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -361,7 +362,7 @@ impl DesktopBackendState {
|
||||
let _ = Command::new("kill").arg(child.id().to_string()).status();
|
||||
// Windows has no SIGTERM equivalent, so terminate outright.
|
||||
// Reap the child too: TerminateProcess is quick, and the
|
||||
// update-restart path needs the sidecar exe's file lock
|
||||
// update-restart path needs the bundled CLI's file lock
|
||||
// released before the NSIS installer replaces it.
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -396,7 +397,7 @@ struct DesktopBackendReadyLine {
|
||||
}
|
||||
|
||||
/// The release binary is a GUI-subsystem app (no console), so on Windows
|
||||
/// every console-subsystem child (git, cmd, the sidecar) would otherwise
|
||||
/// every console-subsystem child (git, cmd, the bundled CLI) would otherwise
|
||||
/// allocate its own visible console window. Piped stdio does not prevent
|
||||
/// that; only CREATE_NO_WINDOW does.
|
||||
#[cfg(windows)]
|
||||
@@ -456,9 +457,12 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn desktop_backend_binary_names() -> Vec<String> {
|
||||
/// The bundled Cline CLI (a Tauri `externalBin`). It starts or reuses the
|
||||
/// shared Hub, runs as the Hub daemon, and is the runtime the packaged
|
||||
/// desktop backend script executes on, so the app needs no Node or Bun.
|
||||
fn desktop_cli_binary_names() -> Vec<String> {
|
||||
let extension = if cfg!(windows) { ".exe" } else { "" };
|
||||
let bundled_name = format!("code-sidecar{extension}");
|
||||
let bundled_name = format!("cline-cli{extension}");
|
||||
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
|
||||
if target_triple.is_empty() {
|
||||
return vec![bundled_name];
|
||||
@@ -466,72 +470,108 @@ fn desktop_backend_binary_names() -> Vec<String> {
|
||||
|
||||
vec![
|
||||
bundled_name,
|
||||
format!("code-sidecar-{target_triple}{extension}"),
|
||||
format!("cline-cli-{target_triple}{extension}"),
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
|
||||
if cfg!(debug_assertions) {
|
||||
return None;
|
||||
}
|
||||
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
fn non_empty_env_path(name: &str) -> Option<PathBuf> {
|
||||
non_empty_env(name).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn desktop_app_bin_dir(context: &AppContext) -> PathBuf {
|
||||
PathBuf::from(&context.workspace_root)
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
.join("desktop-app")
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
}
|
||||
|
||||
fn resolve_desktop_cli_path(context: &AppContext) -> Option<PathBuf> {
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = explicit {
|
||||
if let Some(path) = non_empty_env_path("CLINE_DESKTOP_CLI_BIN") {
|
||||
candidates.push(path);
|
||||
}
|
||||
|
||||
for binary_name in desktop_backend_binary_names() {
|
||||
candidates.push(
|
||||
PathBuf::from(&context.workspace_root)
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
.join("desktop-app")
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
.join(&binary_name),
|
||||
);
|
||||
for binary_name in desktop_cli_binary_names() {
|
||||
if let Some(path) = current_exe
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
|
||||
{
|
||||
candidates.push(path);
|
||||
}
|
||||
if let Some(path) = current_exe.as_ref().and_then(|path| {
|
||||
path.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(|parent| parent.join("Resources").join(&binary_name))
|
||||
}) {
|
||||
candidates.push(path);
|
||||
}
|
||||
candidates.push(desktop_app_bin_dir(context).join(&binary_name));
|
||||
}
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
fn desktop_backend_bundle_candidates(resource_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![resource_dir
|
||||
.join("bin")
|
||||
.join("desktop-backend")
|
||||
.join("index.js")]
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_bundle_path(context: &AppContext) -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = non_empty_env_path("CLINE_DESKTOP_BACKEND_BUNDLE") {
|
||||
candidates.push(path);
|
||||
}
|
||||
candidates.extend(desktop_backend_bundle_candidates(&context.resource_dir));
|
||||
candidates.push(
|
||||
desktop_app_bin_dir(context)
|
||||
.join("desktop-backend")
|
||||
.join("index.js"),
|
||||
);
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
}
|
||||
|
||||
fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String> {
|
||||
let mut command = if let Some(binary_path) = resolve_desktop_backend_binary_path(context) {
|
||||
let mut command = Command::new(binary_path);
|
||||
command.current_dir(&context.workspace_root);
|
||||
command
|
||||
} else if let Some(script_path) = resolve_desktop_backend_script_path(context) {
|
||||
let cli_path = resolve_desktop_cli_path(context);
|
||||
let script_path = if cfg!(debug_assertions) {
|
||||
resolve_desktop_backend_script_path(context)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut command = if let Some(script_path) = script_path {
|
||||
let mut command = Command::new("bun");
|
||||
command
|
||||
.arg("run")
|
||||
.arg(script_path.to_string_lossy().to_string())
|
||||
.current_dir(&context.workspace_root);
|
||||
command
|
||||
} else if let (Some(cli_path), Some(bundle_path)) = (
|
||||
cli_path.as_ref(),
|
||||
resolve_desktop_backend_bundle_path(context),
|
||||
) {
|
||||
let mut command = Command::new(cli_path);
|
||||
command.arg("run").arg("--no-env-file").arg(&bundle_path);
|
||||
// Run from the bundle's own directory so the runtime cannot pick up
|
||||
// a workspace .env or bunfig.toml; the backend moves to the
|
||||
// workspace root itself (CLINE_DESKTOP_WORKSPACE_ROOT).
|
||||
if let Some(bundle_dir) = bundle_path.parent() {
|
||||
command.current_dir(bundle_dir);
|
||||
}
|
||||
command
|
||||
.env("BUN_BE_BUN", "1")
|
||||
.env("CLINE_DESKTOP_WORKSPACE_ROOT", &context.workspace_root)
|
||||
.env(
|
||||
"CLINE_REMOTE_HELPER_DIRECTORY",
|
||||
context.resource_dir.join("bin").join("remote-helpers"),
|
||||
);
|
||||
command
|
||||
} else {
|
||||
return Err(format!(
|
||||
"desktop backend sidecar not found. checked binary/script under workspace_root={} and launch_cwd={}",
|
||||
context.workspace_root, context.launch_cwd
|
||||
"desktop backend not found: needs the bundled Cline CLI ({}) and backend bundle. checked under workspace_root={} and launch_cwd={}",
|
||||
if cli_path.is_some() { "found" } else { "missing" },
|
||||
context.workspace_root,
|
||||
context.launch_cwd
|
||||
));
|
||||
};
|
||||
|
||||
if let Some(cli_path) = cli_path.as_ref() {
|
||||
command.env("CLINE_DESKTOP_CLI_PATH", cli_path);
|
||||
}
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -539,7 +579,7 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
|
||||
hide_console_window(&mut command);
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
|
||||
.map_err(|e| format!("failed to start desktop backend: {e}"))
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started(
|
||||
@@ -803,7 +843,7 @@ fn wait_for_desktop_backend_endpoint(
|
||||
.unwrap_or(true)
|
||||
{
|
||||
last_respawn = Some(Instant::now());
|
||||
// A spawn error (sidecar binary missing) is permanent for this
|
||||
// A spawn error (bundled CLI or backend missing) is permanent for this
|
||||
// wait; report it instead of burning the rest of the window.
|
||||
respawn()?;
|
||||
}
|
||||
@@ -899,7 +939,7 @@ fn apply_staged_update(app: &tauri::AppHandle) {
|
||||
let update_state = app.state::<Arc<UpdateState>>();
|
||||
// Neither restart() nor install() returns, so the run-loop Exit handler
|
||||
// does not get a chance to stop the sidecar; shut it down explicitly
|
||||
// first. On Windows this also releases the sidecar exe's file lock,
|
||||
// first. On Windows this also releases the bundled CLI's file lock,
|
||||
// which the NSIS installer needs in order to replace it.
|
||||
backend_state.stop();
|
||||
// Windows and Linux: install the bytes staged by the background cycle.
|
||||
@@ -1489,10 +1529,6 @@ fn main() {
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
let workspace_root = resolve_workspace_root(&launch_cwd);
|
||||
let app_context = AppContext {
|
||||
launch_cwd,
|
||||
workspace_root,
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
// Closing the window only hides it, so on Windows a second launch from
|
||||
@@ -1506,10 +1542,14 @@ fn main() {
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(desktop_backend)
|
||||
.manage(app_context)
|
||||
.manage(Arc::new(UpdateState::default()))
|
||||
.manage(DesktopActionState::default())
|
||||
.setup(|app| {
|
||||
.setup(move |app| {
|
||||
app.manage(AppContext {
|
||||
launch_cwd: launch_cwd.clone(),
|
||||
workspace_root: workspace_root.clone(),
|
||||
resource_dir: app.path().resource_dir()?,
|
||||
});
|
||||
if tauri::is_dev() {
|
||||
if let (Some(window), Some(product_name)) = (
|
||||
app.get_webview_window(MAIN_WINDOW_LABEL),
|
||||
@@ -1626,6 +1666,23 @@ mod tests {
|
||||
assert!(entitlements.contains("<key>com.apple.security.device.audio-input</key>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_bundle_uses_only_the_running_apps_resource_directory() {
|
||||
let relative = Path::new("bin").join("desktop-backend").join("index.js");
|
||||
for resource_dir in [
|
||||
Path::new("/Applications/Cline.app/Contents/Resources"),
|
||||
Path::new("C:/Program Files/Cline"),
|
||||
Path::new("/usr/lib/Cline"),
|
||||
Path::new("/usr/lib/Cline Beta"),
|
||||
Path::new("/usr/lib/Cline Nightly"),
|
||||
] {
|
||||
assert_eq!(
|
||||
desktop_backend_bundle_candidates(resource_dir),
|
||||
vec![resource_dir.join(&relative)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_window_is_clamped_to_the_work_area() {
|
||||
// 1920x1080 at 150% scaling: 1280x720 logical work area (minus a
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.46",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
"beforeDevCommand": "bun run build:runtime && bun run dev:web",
|
||||
"devUrl": "http://localhost:3125",
|
||||
"beforeBuildCommand": "bun run dmg:background && bun run build",
|
||||
"frontendDist": "../webview/out"
|
||||
@@ -41,11 +41,12 @@
|
||||
"shortDescription": "Cline AI coding agent",
|
||||
"longDescription": "Cline is an AI coding agent. It reads your code, edits files, runs commands, and works through tasks with you in any project on your machine.",
|
||||
"homepage": "https://cline.bot",
|
||||
"externalBin": ["bin/code-sidecar"],
|
||||
"externalBin": ["bin/cline-cli"],
|
||||
"resources": [
|
||||
"icons/app/*.png",
|
||||
"icons/app/macos/*.png",
|
||||
"bin/remote-helpers/*",
|
||||
"bin/desktop-backend/*",
|
||||
"bin/extensions/**/*"
|
||||
],
|
||||
"icon": [
|
||||
|
||||
+14
-9
@@ -163,15 +163,20 @@ configuration variables are `CLINE_REMOTE_HELPER_BINARY`,
|
||||
`CLINE_REMOTE_HELPER_DIRECTORY`, `CLINE_SSH_PATH`, and
|
||||
`CLINE_SSH_KNOWN_HOSTS_FILE`.
|
||||
|
||||
Clients package a matching self-contained helper using the
|
||||
`@cline/core/remote/helper-entry` executable entrypoint, compiled with Bun for the remote OS and
|
||||
architecture. Use `remoteHelperBinaryFilename({ platform, arch })` for the
|
||||
filename (`cline-remote-helper-<target-triple>`). Linux and macOS on x64/arm64
|
||||
are supported. Helpers must include the same SDK build as the client; missing
|
||||
helpers produce an explicit error, without installing a runtime from the network.
|
||||
The helper implements `--remote-hub-ensure --cwd <path> --discovery-path <path>`
|
||||
and the core detached-daemon sentinel. Agent tools and persistence run remotely;
|
||||
the host only manages SSH and forwards the authenticated hub connection.
|
||||
Remote Hubs are CLI-managed. Clients package the self-contained Cline CLI
|
||||
binary (`apps/cli`, compiled with Bun) for each remote OS and architecture; the
|
||||
service stages it on the host and runs `cline hub ensure --json --discovery-path
|
||||
<path> ...` to start or reuse a loopback Hub, and `cline hub stop
|
||||
--discovery-path <path>` to stop it. Use `remoteHelperBinaryFilename({ platform,
|
||||
arch })` for the bundled filename (`cline-<target-triple>`). Linux and macOS on
|
||||
x64/arm64 are supported. The CLI must be built from the same SDK build as the
|
||||
client; missing binaries produce an explicit error, without installing a
|
||||
runtime from the network. Agent tools and persistence run remotely; the host
|
||||
only manages SSH and forwards the authenticated hub connection.
|
||||
|
||||
Clients whose own process cannot host the Hub daemon can call
|
||||
`setHubDaemonLauncher({ command: "/path/to/cline" })` so detached Hubs they
|
||||
start are launched through the Cline CLI.
|
||||
|
||||
### Provider authentication metadata for host UIs
|
||||
|
||||
|
||||
@@ -59,14 +59,6 @@ const builds: Parameters<typeof Bun.build>[0][] = [
|
||||
outdir: "./dist/cloud",
|
||||
...buildConfig,
|
||||
},
|
||||
{
|
||||
entrypoints: [
|
||||
"./src/remote/remote-helper.ts",
|
||||
"./src/remote/remote-helper-entry.ts",
|
||||
],
|
||||
outdir: "./dist/remote",
|
||||
...buildConfig,
|
||||
},
|
||||
// Build main exports separately to avoid Bun bundler output path conflicts
|
||||
{
|
||||
entrypoints: ["./src/index.ts"],
|
||||
|
||||
@@ -35,17 +35,9 @@
|
||||
"types": "./dist/services/feature-flags/posthog.d.ts",
|
||||
"import": "./dist/services/feature-flags/posthog.js"
|
||||
},
|
||||
"./remote/helper-entry": {
|
||||
"types": "./dist/remote/remote-helper-entry.d.ts",
|
||||
"import": "./dist/remote/remote-helper-entry.js"
|
||||
},
|
||||
"./cloud": {
|
||||
"types": "./dist/cloud/index.d.ts",
|
||||
"import": "./dist/cloud/index.js"
|
||||
},
|
||||
"./remote/helper": {
|
||||
"types": "./dist/remote/remote-helper.d.ts",
|
||||
"import": "./dist/remote/remote-helper.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1452,7 +1452,9 @@ export async function requestHubShutdown(
|
||||
export async function stopLocalHubServerGracefully(
|
||||
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
|
||||
): Promise<boolean> {
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath, {
|
||||
onError: "throw",
|
||||
});
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -227,10 +227,15 @@ describe("ensureDetachedHubServer", () => {
|
||||
authToken: "new-token",
|
||||
});
|
||||
|
||||
const beforeSpawn = vi.fn(async () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace", {
|
||||
manageConnectors,
|
||||
beforeSpawn,
|
||||
});
|
||||
expect(beforeSpawn).toHaveBeenCalledOnce();
|
||||
const spawnCalls = (spawn as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls;
|
||||
const spawnArgs = spawnCalls[0]?.[1] as string[] | undefined;
|
||||
@@ -712,13 +717,17 @@ describe("ensureDetachedHubServer", () => {
|
||||
});
|
||||
verifyHubConnection.mockResolvedValue(true);
|
||||
|
||||
const beforeSpawn = vi.fn(async () => undefined);
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
await expect(ensureDetachedHubServer("/workspace")).resolves.toEqual({
|
||||
await expect(
|
||||
ensureDetachedHubServer("/workspace", { beforeSpawn }),
|
||||
).resolves.toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "known-token",
|
||||
});
|
||||
expect(writeHubDiscovery).toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(beforeSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses matching discovery pid and token when retiring an incompatible expected-url hub", async () => {
|
||||
@@ -1291,3 +1300,38 @@ describe("resolveDaemonEntryArgs", () => {
|
||||
).toEqual(["/repo/dist/hub/daemon/entry.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setHubDaemonLauncher", () => {
|
||||
afterEach(async () => {
|
||||
const { setHubDaemonLauncher } = await import(".");
|
||||
setHubDaemonLauncher(undefined);
|
||||
});
|
||||
|
||||
it("launches the daemon through the configured CLI binary", async () => {
|
||||
const { __test__, setHubDaemonLauncher } = await import(".");
|
||||
setHubDaemonLauncher({ command: "/Applications/Cline.app/cline-cli" });
|
||||
const command = __test__.resolveLaunchCommand("/work", {
|
||||
port: 0,
|
||||
manageConnectors: false,
|
||||
});
|
||||
expect(command.launcher).toBe("/Applications/Cline.app/cline-cli");
|
||||
expect(command.args).toEqual([
|
||||
"--cline-hub-daemon",
|
||||
"--cwd",
|
||||
"/work",
|
||||
"--port",
|
||||
"0",
|
||||
"--no-connectors",
|
||||
]);
|
||||
expect(command.env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
|
||||
});
|
||||
|
||||
it("restores the current runtime when cleared", async () => {
|
||||
const { __test__, setHubDaemonLauncher } = await import(".");
|
||||
setHubDaemonLauncher({ command: "/opt/cline-cli", args: ["--x"] });
|
||||
setHubDaemonLauncher(undefined);
|
||||
expect(__test__.resolveLaunchCommand("/work").launcher).toBe(
|
||||
process.execPath,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface DetachedHubOptions extends HubEndpointOverrides {
|
||||
allowPortFallback?: boolean;
|
||||
/** Disable account-wide connector supervision for session-only Hubs. Defaults to true. */
|
||||
manageConnectors?: boolean;
|
||||
/**
|
||||
* Runs only when a new daemon is about to be spawned (never when a running
|
||||
* Hub is reused), e.g. to prepare the environment the daemon inherits.
|
||||
*/
|
||||
beforeSpawn?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +87,10 @@ export const __test__ = {
|
||||
retireAttemptsByUrl.clear();
|
||||
},
|
||||
resolveDaemonEntryArgs,
|
||||
resolveLaunchCommand: (
|
||||
workspaceRoot: string,
|
||||
endpoint: DetachedHubOptions = {},
|
||||
) => resolveLaunchCommand(workspaceRoot, endpoint),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -398,6 +407,28 @@ function resolveDaemonEntryArgs(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* An executable that hosts the detached Hub daemon in place of the current
|
||||
* process. Clients that are not themselves a Hub-capable binary (for example
|
||||
* the desktop backend, a script running on the bundled CLI's runtime) point
|
||||
* this at the Cline CLI so every Hub they start is a CLI-managed Hub.
|
||||
*/
|
||||
export interface HubDaemonLauncher {
|
||||
command: string;
|
||||
/** Arguments before the daemon's `--cwd`/endpoint arguments. Defaults to the daemon marker flag. */
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
let configuredHubDaemonLauncher: HubDaemonLauncher | undefined;
|
||||
|
||||
/** Route detached Hub spawns through `launcher`; pass `undefined` to restore the default. */
|
||||
export function setHubDaemonLauncher(launcher?: HubDaemonLauncher): void {
|
||||
const command = launcher?.command.trim();
|
||||
configuredHubDaemonLauncher = command
|
||||
? { command, args: launcher?.args ? [...launcher.args] : undefined }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveLaunchCommand(
|
||||
workspaceRoot: string,
|
||||
endpoint: DetachedHubOptions,
|
||||
@@ -407,15 +438,24 @@ function resolveLaunchCommand(
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
} {
|
||||
const daemonEntryPath = resolveDaemonEntryPath();
|
||||
const execPath = process.execPath?.trim();
|
||||
if (!execPath) {
|
||||
throw new Error("unable to resolve runtime executable for hub daemon");
|
||||
let launcher: string;
|
||||
let entryArgs: string[];
|
||||
if (configuredHubDaemonLauncher) {
|
||||
launcher = configuredHubDaemonLauncher.command;
|
||||
entryArgs = configuredHubDaemonLauncher.args ?? [
|
||||
COMPILED_BUN_HUB_DAEMON_ARG,
|
||||
];
|
||||
} else {
|
||||
const execPath = process.execPath?.trim();
|
||||
if (!execPath) {
|
||||
throw new Error("unable to resolve runtime executable for hub daemon");
|
||||
}
|
||||
const isBunRuntime = basename(execPath).toLowerCase().includes("bun");
|
||||
launcher = execPath;
|
||||
entryArgs = resolveDaemonEntryArgs(resolveDaemonEntryPath(), isBunRuntime);
|
||||
}
|
||||
const isBunRuntime = basename(execPath).toLowerCase().includes("bun");
|
||||
const entryArgs = resolveDaemonEntryArgs(daemonEntryPath, isBunRuntime);
|
||||
return {
|
||||
launcher: execPath,
|
||||
launcher,
|
||||
args: [
|
||||
...entryArgs,
|
||||
"--cwd",
|
||||
@@ -687,6 +727,7 @@ async function ensureDetachedHubServerLocked(
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...endpoint, port: 0 }
|
||||
: endpoint;
|
||||
await endpointOverrides.beforeSpawn?.();
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, {
|
||||
...spawnEndpoint,
|
||||
manageConnectors: endpointOverrides.manageConnectors,
|
||||
|
||||
@@ -246,7 +246,43 @@ describe("hub discovery", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readHubDiscovery(discoveryPath, { onError: "throw" }),
|
||||
).rejects.toThrow("Invalid Hub discovery record");
|
||||
});
|
||||
|
||||
it("ignores invalid records for recovery but rejects them for shutdown", async () => {
|
||||
snapshot = captureEnv();
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
const discoveryPath =
|
||||
resolveHubOwnerContext("invalid-record").discoveryPath;
|
||||
await mkdir(dirname(discoveryPath), { recursive: true });
|
||||
for (const raw of ["{ invalid json", "null", "{}", "[]"]) {
|
||||
await writeFile(discoveryPath, raw, "utf8");
|
||||
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readHubDiscovery(discoveryPath, { onError: "throw" }),
|
||||
).rejects.toThrow("Hub discovery record");
|
||||
}
|
||||
await clearHubDiscovery(discoveryPath);
|
||||
});
|
||||
|
||||
it("propagates discovery read errors instead of reporting absence", async () => {
|
||||
snapshot = captureEnv();
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
const discoveryPath = resolveHubOwnerContext(
|
||||
"directory-read-error",
|
||||
).discoveryPath;
|
||||
await mkdir(dirname(discoveryPath), { recursive: true });
|
||||
// Opening the parent directory as a file fails on every supported OS.
|
||||
await expect(
|
||||
readHubDiscovery(dirname(discoveryPath)),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readHubDiscovery(dirname(discoveryPath), { onError: "throw" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("serializes generation cleanup with replacement publication", async () => {
|
||||
|
||||
@@ -344,14 +344,32 @@ export function createInMemoryHubOwnerContext(
|
||||
return resolveHubOwnerContext(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a usable discovery record. Startup and connection discovery ignore
|
||||
* unreadable/invalid records so endpoint probing and recovery can continue.
|
||||
* Shutdown must use onError: "throw": only a missing record proves absence.
|
||||
*/
|
||||
export async function readHubDiscovery(
|
||||
discoveryPath: string,
|
||||
options: { onError?: "ignore" | "throw" } = {},
|
||||
): Promise<HubServerDiscoveryRecord | undefined> {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(discoveryPath, "utf8"),
|
||||
) as Partial<HubServerDiscoveryRecord>;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(discoveryPath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
let parsed: Partial<HubServerDiscoveryRecord>;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Partial<HubServerDiscoveryRecord>;
|
||||
} catch {
|
||||
// JSON parser errors may quote record contents, including the auth token.
|
||||
throw new Error(`Invalid JSON in Hub discovery record: ${discoveryPath}`);
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.hubId !== "string" ||
|
||||
typeof parsed.protocolVersion !== "string" ||
|
||||
typeof parsed.authToken !== "string" ||
|
||||
@@ -361,7 +379,7 @@ export async function readHubDiscovery(
|
||||
typeof parsed.startedAt !== "string" ||
|
||||
typeof parsed.updatedAt !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
throw new Error(`Invalid Hub discovery record: ${discoveryPath}`);
|
||||
}
|
||||
return {
|
||||
hubId: parsed.hubId,
|
||||
@@ -395,7 +413,8 @@ export async function readHubDiscovery(
|
||||
startedAt: parsed.startedAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (options.onError === "throw") throw error;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import * as childProcess from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
@@ -210,14 +218,14 @@ describe("RemoteEnvironmentService", () => {
|
||||
}
|
||||
if (helperMissing && command.includes("'test' '-x'"))
|
||||
return { stdout: "", stderr: "", exitCode: 1 };
|
||||
if (helperMissing && command.includes("--remote-hub-stop"))
|
||||
if (helperMissing && command.includes("'hub' 'stop'"))
|
||||
throw new Error("Helper missing");
|
||||
if (requiredIdentity && !args.includes(requiredIdentity)) {
|
||||
throw new Error("Obsolete SSH identity");
|
||||
}
|
||||
if (command.includes("uname -s"))
|
||||
return inspection("Linux", "x86_64", "/home/dev");
|
||||
if (command.includes("--remote-hub-ensure"))
|
||||
if (command.includes("'hub' 'ensure'"))
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}',
|
||||
);
|
||||
@@ -243,7 +251,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
await service.connect(first.id);
|
||||
await service.connect(second.id);
|
||||
const ensures = commands.filter((command) =>
|
||||
command.includes("--remote-hub-ensure"),
|
||||
command.includes("'hub' 'ensure'"),
|
||||
);
|
||||
expect(ensures).toHaveLength(2);
|
||||
expect(ensures[0]).not.toBe(ensures[1]);
|
||||
@@ -262,13 +270,39 @@ describe("RemoteEnvironmentService", () => {
|
||||
await restarted.dispose();
|
||||
expect(await readdir(`${profilesPath}.cleanup`)).toEqual([]);
|
||||
const stop = commands
|
||||
.filter((command) => command.includes("--remote-hub-stop"))
|
||||
.filter((command) => command.includes("'hub' 'stop'"))
|
||||
.at(-1);
|
||||
expect(stop?.split("'--discovery-path' ")[1]).toBe(
|
||||
ensures[0]?.split("'--discovery-path' ")[1],
|
||||
);
|
||||
});
|
||||
|
||||
it("stops a Hub recorded by an earlier dedicated helper with its own flag", async () => {
|
||||
const commands: string[] = [];
|
||||
const service = createService({
|
||||
runProcess: async (_executable, args) => {
|
||||
commands.push(args.at(-1) ?? "");
|
||||
return success();
|
||||
},
|
||||
});
|
||||
const profile = await service.upsert({ name: "Old", host: "old-host" });
|
||||
const legacyHelper =
|
||||
"/home/dev/.cline/remote/cline-remote-helper-linux-x64-0123456789abcdef";
|
||||
const discoveryPath = "/home/dev/.cline/data/remote/owner.json";
|
||||
await mkdir(`${profilesPath}.cleanup`, { recursive: true });
|
||||
await writeFile(
|
||||
join(`${profilesPath}.cleanup`, "legacy.json"),
|
||||
JSON.stringify({ profile, helper: legacyHelper, discoveryPath }),
|
||||
);
|
||||
|
||||
await expect(service.disconnect(profile.id)).resolves.toBe(false);
|
||||
|
||||
expect(commands).toContain(
|
||||
`exec '${legacyHelper}' '--remote-hub-stop' '--discovery-path' '${discoveryPath}'`,
|
||||
);
|
||||
expect(await readdir(`${profilesPath}.cleanup`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists profiles atomically with private permissions and updates in place", async () => {
|
||||
const service = createService();
|
||||
const created = await service.upsert({
|
||||
@@ -421,7 +455,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("'test' '-x'")) {
|
||||
return { stdout: "", stderr: "", exitCode: 1 };
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"remote-secret"}\n',
|
||||
);
|
||||
@@ -478,12 +512,15 @@ describe("RemoteEnvironmentService", () => {
|
||||
});
|
||||
expect(upload?.args.at(-1)).toContain("umask 077; cat >");
|
||||
const ensure = invocations.find((invocation) =>
|
||||
invocation.args.at(-1)?.includes("--remote-hub-ensure"),
|
||||
invocation.args.at(-1)?.includes("'hub' 'ensure'"),
|
||||
);
|
||||
expect(ensure?.args.at(-1)).toContain("'/home/dev'");
|
||||
expect(ensure?.args.at(-1)).toMatch(
|
||||
/\/home\/dev\/\.cline\/data\/remote\/[a-f0-9-]+\.json/,
|
||||
);
|
||||
expect(ensure?.args.at(-1)).toContain(
|
||||
"exec 'env' 'CLINE_NO_AUTO_UPDATE=1' '/home/dev/.cline/remote/cline-linux-arm64-abcdef0123456789' 'hub' 'ensure' '--json' '--allow-port-fallback' '--no-connectors' '--login-shell-path' '--cwd' '/home/dev' '--host' '127.0.0.1' '--port' '0' '--pathname' '/hub' '--discovery-path' ",
|
||||
);
|
||||
expect(spawnTunnel).toHaveBeenCalledWith(
|
||||
"ssh",
|
||||
expect.arrayContaining([
|
||||
@@ -521,7 +558,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
commands.push(command);
|
||||
if (command.includes("uname -s"))
|
||||
return inspection("Linux", "aarch64", "/home/dev");
|
||||
if (command.includes("--remote-hub-ensure"))
|
||||
if (command.includes("'hub' 'ensure'"))
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"secret"}',
|
||||
);
|
||||
@@ -541,11 +578,9 @@ describe("RemoteEnvironmentService", () => {
|
||||
"tunnel setup failed",
|
||||
);
|
||||
const ensure = commands.find((command) =>
|
||||
command.includes("--remote-hub-ensure"),
|
||||
);
|
||||
const stop = commands.find((command) =>
|
||||
command.includes("--remote-hub-stop"),
|
||||
command.includes("'hub' 'ensure'"),
|
||||
);
|
||||
const stop = commands.find((command) => command.includes("'hub' 'stop'"));
|
||||
expect(stop).toBeDefined();
|
||||
expect(stop?.split("'--discovery-path' ")[1]).toBe(
|
||||
ensure?.split("'--discovery-path' ")[1],
|
||||
@@ -566,7 +601,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/pi");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
@@ -600,7 +635,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Darwin", "arm64", "/Users/dev");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return success(
|
||||
'{"url":"ws://localhost:29000/hub","authToken":"token"}\n',
|
||||
);
|
||||
@@ -634,7 +669,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/pi");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
@@ -716,7 +751,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
});
|
||||
|
||||
await expect(service.connect(profile.id)).rejects.toThrow(
|
||||
"unsupported in SSH: no compatible remote helper binary",
|
||||
"unsupported in SSH: no compatible Cline CLI binary",
|
||||
);
|
||||
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
|
||||
false,
|
||||
@@ -733,7 +768,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/dev");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return destination === "host-b"
|
||||
? { stdout: "", stderr: "bootstrap failed", exitCode: 1 }
|
||||
: success(
|
||||
@@ -781,7 +816,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", `/home/${destination}`);
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return success(
|
||||
`{"url":"ws://127.0.0.1:25463/hub","authToken":"${destination}-token"}\n`,
|
||||
);
|
||||
@@ -840,7 +875,7 @@ describe("RemoteEnvironmentService", () => {
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "x86_64", "/home/dev");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
if (command.includes("'hub' 'ensure'")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { createConnection, createServer } from "node:net";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
import { requestHubShutdown } from "../hub/client";
|
||||
|
||||
@@ -343,16 +343,10 @@ export class RemoteEnvironmentService {
|
||||
`${REMOTE_DISCOVERY_DIRECTORY}/${this.ownerId}-${createHash("sha256").update(profile.id).digest("hex").slice(0, 16)}.json`,
|
||||
);
|
||||
bootstrap = { helper: remoteHelper, discoveryPath };
|
||||
const ensureResult = await this.execRemote(profile, {
|
||||
command: remoteHelper,
|
||||
args: [
|
||||
"--remote-hub-ensure",
|
||||
"--cwd",
|
||||
inspection.home,
|
||||
"--discovery-path",
|
||||
discoveryPath,
|
||||
],
|
||||
});
|
||||
const ensureResult = await this.execRemote(
|
||||
profile,
|
||||
remoteHubEnsureCommand(remoteHelper, inspection.home, discoveryPath),
|
||||
);
|
||||
const hub = parseRemoteHubResult(ensureResult.stdout);
|
||||
const localPort = await this.dependencies.reservePort();
|
||||
const tunnel = this.dependencies.spawnTunnel(
|
||||
@@ -414,14 +408,10 @@ export class RemoteEnvironmentService {
|
||||
if (bootstrap) {
|
||||
try {
|
||||
// An independent SSH command works even when forwarding never opened.
|
||||
await this.execRemote(profile, {
|
||||
command: bootstrap.helper,
|
||||
args: [
|
||||
"--remote-hub-stop",
|
||||
"--discovery-path",
|
||||
bootstrap.discoveryPath,
|
||||
],
|
||||
});
|
||||
await this.execRemote(
|
||||
profile,
|
||||
remoteHubStopCommand(bootstrap.helper, bootstrap.discoveryPath),
|
||||
);
|
||||
} catch (failure) {
|
||||
cleanupError = failure;
|
||||
await this.persistPendingCleanup({ profile, ...bootstrap });
|
||||
@@ -537,7 +527,7 @@ export class RemoteEnvironmentService {
|
||||
if (!localHelper || !(await this.dependencies.fileReadable(localHelper))) {
|
||||
throw new Error(
|
||||
`Remote target ${inspection.platform}/${inspection.arch} is unsupported in SSH: ` +
|
||||
"no compatible remote helper binary is available. Build the matching core remote helper and set " +
|
||||
"no compatible Cline CLI binary is available. Build the Cline CLI for that target and set " +
|
||||
"CLINE_REMOTE_HELPER_BINARY; network installers are intentionally not used.",
|
||||
);
|
||||
}
|
||||
@@ -549,7 +539,7 @@ export class RemoteEnvironmentService {
|
||||
);
|
||||
const remoteHelper = joinRemote(
|
||||
remoteDirectory,
|
||||
`cline-remote-helper-${inspection.platform}-${inspection.arch}-${hash.slice(0, 16)}`,
|
||||
`cline-${inspection.platform}-${inspection.arch}-${hash.slice(0, 16)}`,
|
||||
);
|
||||
await this.installHelper(
|
||||
profile,
|
||||
@@ -866,10 +856,10 @@ export class RemoteEnvironmentService {
|
||||
cleanupProfile,
|
||||
await this.inspectRemote(cleanupProfile),
|
||||
);
|
||||
await this.execRemote(cleanupProfile, {
|
||||
command: helper,
|
||||
args: ["--remote-hub-stop", "--discovery-path", cleanup.discoveryPath],
|
||||
});
|
||||
await this.execRemote(
|
||||
cleanupProfile,
|
||||
remoteHubStopCommand(helper, cleanup.discoveryPath),
|
||||
);
|
||||
await rm(path, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -985,6 +975,7 @@ function createDefaultDependencies(
|
||||
};
|
||||
}
|
||||
|
||||
/** File name of the Cline CLI binary built for `target`, as clients bundle it. */
|
||||
export function remoteHelperBinaryFilename(target: RemoteHelperTarget): string {
|
||||
const triple =
|
||||
target.platform === "darwin"
|
||||
@@ -994,7 +985,70 @@ export function remoteHelperBinaryFilename(target: RemoteHelperTarget): string {
|
||||
: target.arch === "arm64"
|
||||
? "aarch64-unknown-linux-gnu"
|
||||
: "x86_64-unknown-linux-gnu";
|
||||
return `cline-remote-helper-${triple}`;
|
||||
return `cline-${triple}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote Hubs are CLI-managed: the staged Cline CLI starts (or reuses) a
|
||||
* loopback Hub tracked by a discovery record that this client owns, so the
|
||||
* user's own CLI Hub on that host is never read or stopped. The discovery
|
||||
* path comes last so a stop command can be matched to the ensure it undoes.
|
||||
*/
|
||||
function remoteHubEnsureCommand(
|
||||
cli: string,
|
||||
cwd: string,
|
||||
discoveryPath: string,
|
||||
): RemoteCommandInput {
|
||||
return {
|
||||
command: "env",
|
||||
args: [
|
||||
"CLINE_NO_AUTO_UPDATE=1",
|
||||
cli,
|
||||
"hub",
|
||||
"ensure",
|
||||
"--json",
|
||||
"--allow-port-fallback",
|
||||
"--no-connectors",
|
||||
"--login-shell-path",
|
||||
"--cwd",
|
||||
cwd,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"0",
|
||||
"--pathname",
|
||||
"/hub",
|
||||
"--discovery-path",
|
||||
discoveryPath,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const LEGACY_REMOTE_HELPER_PREFIX = "cline-remote-helper-";
|
||||
|
||||
function remoteHubStopCommand(
|
||||
cli: string,
|
||||
discoveryPath: string,
|
||||
): RemoteCommandInput {
|
||||
// Cleanup records persisted by earlier releases name the dedicated helper
|
||||
// binary they staged, which only understands its own stop flag.
|
||||
if (basename(cli).startsWith(LEGACY_REMOTE_HELPER_PREFIX)) {
|
||||
return {
|
||||
command: cli,
|
||||
args: ["--remote-hub-stop", "--discovery-path", discoveryPath],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: "env",
|
||||
args: [
|
||||
"CLINE_NO_AUTO_UPDATE=1",
|
||||
cli,
|
||||
"hub",
|
||||
"stop",
|
||||
"--discovery-path",
|
||||
discoveryPath,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRemoteProcess(
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { runRemoteHelperEntrypoint } from "./remote-helper";
|
||||
|
||||
// Executable entrypoint; importing remote/helper never runs the CLI.
|
||||
void (async () => {
|
||||
if (!(await runRemoteHelperEntrypoint()))
|
||||
throw new Error("A remote helper command is required");
|
||||
})().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type RemoteHelperDependencies,
|
||||
runRemoteHelperEntrypoint,
|
||||
} from "./remote-helper";
|
||||
|
||||
function createDependencies(
|
||||
overrides: Partial<RemoteHelperDependencies> = {},
|
||||
): {
|
||||
dependencies: RemoteHelperDependencies;
|
||||
output: string[];
|
||||
} {
|
||||
const output: string[] = [];
|
||||
return {
|
||||
output,
|
||||
dependencies: {
|
||||
readHubDiscovery: vi.fn(async () => undefined),
|
||||
clearHubDiscoveryIfOwned: vi.fn(async () => true),
|
||||
probeProcess: vi.fn(),
|
||||
requestHubShutdown: vi.fn(async () => true),
|
||||
ensureDetachedHubServer: vi.fn(async () => ({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "desktop-owner-token",
|
||||
})),
|
||||
claimHubDaemonProcess: vi.fn(() => false),
|
||||
loadHubDaemon: vi.fn(async () => undefined),
|
||||
ensureLoginShellPath: vi.fn(async () => ({
|
||||
status: "skipped" as const,
|
||||
reason: "test",
|
||||
})),
|
||||
setHomeDirIfUnset: vi.fn(),
|
||||
homeDir: () => "/home/pi",
|
||||
cwd: () => "/home/pi",
|
||||
env: {},
|
||||
writeOutput: (value) => output.push(value),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("remote helper entrypoint", () => {
|
||||
it("imports the built public helper without executing its CLI", () => {
|
||||
const result = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`const before = process.exitCode; const helper = await import('@cline/core/remote/helper'); if (process.exitCode !== before || typeof helper.runRemoteHelperEntrypoint !== 'function') throw new Error('Import side effect'); console.log('imported');`,
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../../", import.meta.url)),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.trim()).toBe("imported");
|
||||
});
|
||||
|
||||
it("starts only the explicitly owned desktop Hub discovery record", async () => {
|
||||
const { dependencies, output } = createDependencies();
|
||||
const discoveryPath = "/home/pi/.cline/data/remote/desktop-hub.json";
|
||||
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(
|
||||
[
|
||||
"code-sidecar",
|
||||
"--remote-hub-ensure",
|
||||
"--cwd",
|
||||
"/home/pi",
|
||||
"--discovery-path",
|
||||
discoveryPath,
|
||||
],
|
||||
dependencies,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(dependencies.env.CLINE_HUB_DISCOVERY_PATH).toBe(discoveryPath);
|
||||
expect(dependencies.ensureDetachedHubServer).toHaveBeenCalledWith(
|
||||
"/home/pi",
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/hub",
|
||||
allowPortFallback: true,
|
||||
manageConnectors: false,
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output.join(""))).toMatchObject({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "desktop-owner-token",
|
||||
cwd: "/home/pi",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses bootstrap without an explicit discovery owner", async () => {
|
||||
const { dependencies } = createDependencies();
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(
|
||||
["code-sidecar", "--remote-hub-ensure"],
|
||||
dependencies,
|
||||
),
|
||||
).rejects.toThrow("--discovery-path is required");
|
||||
});
|
||||
|
||||
it("stops only the explicitly owned Hub using its authentication token", async () => {
|
||||
const discoveryPath = "/home/pi/.cline/data/remote/owned.json";
|
||||
const { dependencies } = createDependencies({
|
||||
readHubDiscovery: vi.fn(
|
||||
async () =>
|
||||
({
|
||||
url: "ws://127.0.0.1:1234/hub",
|
||||
authToken: "owner-token",
|
||||
}) as Awaited<
|
||||
ReturnType<RemoteHelperDependencies["readHubDiscovery"]>
|
||||
>,
|
||||
),
|
||||
});
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(
|
||||
["helper", "--remote-hub-stop", "--discovery-path", discoveryPath],
|
||||
dependencies,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(dependencies.readHubDiscovery).toHaveBeenCalledWith(discoveryPath);
|
||||
expect(dependencies.requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:1234/hub",
|
||||
"owner-token",
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
"dead",
|
||||
"alive",
|
||||
"denied",
|
||||
])("handles %s Hub processes during cleanup", async (state) => {
|
||||
const discoveryPath = "/home/pi/.cline/data/remote/owned.json";
|
||||
const { dependencies } = createDependencies({
|
||||
readHubDiscovery: vi.fn(
|
||||
async () =>
|
||||
({
|
||||
hubId: "owned-hub",
|
||||
pid: 1234,
|
||||
url: "ws://127.0.0.1:1234/hub",
|
||||
authToken: "owner-token",
|
||||
}) as Awaited<
|
||||
ReturnType<RemoteHelperDependencies["readHubDiscovery"]>
|
||||
>,
|
||||
),
|
||||
probeProcess: vi.fn(() => {
|
||||
if (state !== "alive")
|
||||
throw Object.assign(new Error(state), {
|
||||
code: state === "dead" ? "ESRCH" : "EPERM",
|
||||
});
|
||||
}),
|
||||
requestHubShutdown: vi.fn(async () => {
|
||||
throw new Error("Connection refused");
|
||||
}),
|
||||
});
|
||||
const result = runRemoteHelperEntrypoint(
|
||||
["helper", "--remote-hub-stop", "--discovery-path", discoveryPath],
|
||||
dependencies,
|
||||
);
|
||||
if (state === "dead") {
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(dependencies.clearHubDiscoveryIfOwned).toHaveBeenCalledWith(
|
||||
discoveryPath,
|
||||
"owned-hub",
|
||||
);
|
||||
expect(dependencies.requestHubShutdown).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await expect(result).rejects.toThrow("Connection refused");
|
||||
expect(dependencies.clearHubDiscoveryIfOwned).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses shutdown without an explicit discovery owner", async () => {
|
||||
const { dependencies } = createDependencies();
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(["helper", "--remote-hub-stop"], dependencies),
|
||||
).rejects.toThrow("--discovery-path is required");
|
||||
expect(dependencies.readHubDiscovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hosts the detached daemon when the one-shot sentinel is claimed", async () => {
|
||||
const loadHubDaemon = vi.fn(async () => undefined);
|
||||
const { dependencies } = createDependencies({
|
||||
claimHubDaemonProcess: () => true,
|
||||
loadHubDaemon,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(["code-sidecar"], dependencies),
|
||||
).resolves.toBe(true);
|
||||
expect(loadHubDaemon).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { homedir } from "node:os";
|
||||
import { claimHubDaemonProcess } from "@cline/shared";
|
||||
import { setHomeDirIfUnset } from "@cline/shared/storage";
|
||||
import { requestHubShutdown } from "../hub/client";
|
||||
import { ensureDetachedHubServer } from "../hub/daemon";
|
||||
import { clearHubDiscoveryIfOwned, readHubDiscovery } from "../hub/discovery";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
|
||||
export type RemoteHelperDependencies = {
|
||||
readHubDiscovery: typeof readHubDiscovery;
|
||||
clearHubDiscoveryIfOwned: typeof clearHubDiscoveryIfOwned;
|
||||
probeProcess: (pid: number) => void;
|
||||
requestHubShutdown: typeof requestHubShutdown;
|
||||
ensureDetachedHubServer: typeof ensureDetachedHubServer;
|
||||
claimHubDaemonProcess: typeof claimHubDaemonProcess;
|
||||
loadHubDaemon: () => Promise<unknown>;
|
||||
ensureLoginShellPath: typeof ensureLoginShellPath;
|
||||
setHomeDirIfUnset: typeof setHomeDirIfUnset;
|
||||
homeDir: () => string;
|
||||
cwd: () => string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
writeOutput: (output: string) => void;
|
||||
};
|
||||
|
||||
const defaultDependencies: RemoteHelperDependencies = {
|
||||
readHubDiscovery,
|
||||
clearHubDiscoveryIfOwned,
|
||||
probeProcess: (pid) => {
|
||||
process.kill(pid, 0);
|
||||
},
|
||||
requestHubShutdown,
|
||||
ensureDetachedHubServer,
|
||||
claimHubDaemonProcess,
|
||||
loadHubDaemon: () => import("@cline/core/hub/daemon-entry"),
|
||||
ensureLoginShellPath,
|
||||
setHomeDirIfUnset,
|
||||
homeDir: homedir,
|
||||
cwd: () => process.cwd(),
|
||||
env: process.env,
|
||||
writeOutput: (output) => process.stdout.write(output),
|
||||
};
|
||||
|
||||
function readArgument(argv: string[], name: string): string | undefined {
|
||||
const index = argv.indexOf(name);
|
||||
const value = index >= 0 ? argv[index + 1] : undefined;
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
function configureDedicatedDiscovery(
|
||||
argv: string[],
|
||||
dependencies: RemoteHelperDependencies,
|
||||
): string {
|
||||
const discoveryPath = readArgument(argv, "--discovery-path");
|
||||
if (!discoveryPath) {
|
||||
throw new Error("--discovery-path is required for remote Hub management");
|
||||
}
|
||||
// This explicit owner record is the safety boundary: the remote helper never
|
||||
// reads or shuts down the user's default CLI-owned Hub discovery record.
|
||||
dependencies.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
|
||||
return discoveryPath;
|
||||
}
|
||||
|
||||
export async function runRemoteHubEnsure(
|
||||
argv = process.argv,
|
||||
dependencies: RemoteHelperDependencies = defaultDependencies,
|
||||
): Promise<void> {
|
||||
dependencies.setHomeDirIfUnset(dependencies.homeDir());
|
||||
await dependencies.ensureLoginShellPath();
|
||||
const cwd = readArgument(argv, "--cwd") ?? dependencies.cwd();
|
||||
configureDedicatedDiscovery(argv, dependencies);
|
||||
const result = await dependencies.ensureDetachedHubServer(cwd, {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/hub",
|
||||
allowPortFallback: true,
|
||||
manageConnectors: false,
|
||||
});
|
||||
dependencies.writeOutput(
|
||||
`${JSON.stringify({
|
||||
...result,
|
||||
cwd,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the SSH bootstrap command and the detached-daemon sentinel. The
|
||||
* standalone helper is compiled for the target host and contains no client UI
|
||||
* server or command router. Client executables may also use this entrypoint
|
||||
* to support the daemon sentinel.
|
||||
*/
|
||||
export async function runRemoteHelperEntrypoint(
|
||||
argv = process.argv,
|
||||
dependencies: RemoteHelperDependencies = defaultDependencies,
|
||||
): Promise<boolean> {
|
||||
if (argv.includes("--remote-hub-stop")) {
|
||||
const discoveryPath = configureDedicatedDiscovery(argv, dependencies);
|
||||
const hub = await dependencies.readHubDiscovery(discoveryPath);
|
||||
// A crash or reboot can leave discovery pointing at a dead Hub. Only
|
||||
// ESRCH proves the process is gone; permission/probe errors and live
|
||||
// processes must still go through authenticated shutdown.
|
||||
if (
|
||||
hub &&
|
||||
typeof hub.pid === "number" &&
|
||||
Number.isInteger(hub.pid) &&
|
||||
hub.pid > 0
|
||||
) {
|
||||
try {
|
||||
dependencies.probeProcess(hub.pid);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ESRCH") {
|
||||
await dependencies.clearHubDiscoveryIfOwned(discoveryPath, hub.hubId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
hub &&
|
||||
!(await dependencies.requestHubShutdown(hub.url, hub.authToken))
|
||||
) {
|
||||
throw new Error("Remote Hub shutdown failed");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (argv.includes("--remote-hub-ensure")) {
|
||||
await runRemoteHubEnsure(argv, dependencies);
|
||||
return true;
|
||||
}
|
||||
// Claim rather than read: consuming the sentinel keeps daemon-hosted
|
||||
// sessions from handing it to every process they spawn.
|
||||
if (dependencies.claimHubDaemonProcess()) {
|
||||
await dependencies.loadHubDaemon();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -11,8 +11,6 @@
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/remote/remote-helper.ts",
|
||||
"src/remote/remote-helper-entry.ts",
|
||||
"src/hub/index.ts",
|
||||
"src/cloud/index.ts",
|
||||
"src/hub/daemon/entry.ts",
|
||||
|
||||
@@ -486,6 +486,11 @@ export {
|
||||
createClineTelemetryServiceConfig,
|
||||
createClineTelemetryServiceMetadata,
|
||||
} from "./services/telemetry-config";
|
||||
export type {
|
||||
TelemetrySelfcheckConfig,
|
||||
TelemetrySelfcheckReport,
|
||||
} from "./services/telemetry-selfcheck";
|
||||
export { buildTelemetrySelfcheckReport } from "./services/telemetry-selfcheck";
|
||||
export type {
|
||||
HookSessionContext,
|
||||
HookSessionContextLookup,
|
||||
|
||||
@@ -581,6 +581,11 @@ export {
|
||||
createClineTelemetryServiceConfig,
|
||||
createClineTelemetryServiceMetadata,
|
||||
} from "./services/telemetry-config";
|
||||
export type {
|
||||
TelemetrySelfcheckConfig,
|
||||
TelemetrySelfcheckReport,
|
||||
} from "./services/telemetry-selfcheck";
|
||||
export { buildTelemetrySelfcheckReport } from "./services/telemetry-selfcheck";
|
||||
export type {
|
||||
HookSessionContext,
|
||||
HookSessionContextLookup,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Builds the `--telemetry-selfcheck` report (see index.ts). CI runs the
|
||||
* packaged sidecar with that flag and fails the publish unless the report
|
||||
* packaged CLI and desktop backend with that flag and fails the publish unless the report
|
||||
* shows telemetry enabled with a usable OTLP endpoint, so the checks here
|
||||
* define what a release-grade build must prove.
|
||||
*/
|
||||
Reference in New Issue
Block a user