diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a10af6e..048e6e0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,9 @@ on: release: types: [published] +permissions: + contents: write + jobs: publish: runs-on: ubuntu-latest @@ -36,12 +39,31 @@ jobs: - name: Release smoke (npm pack completeness) — blocks publish if a shipped artifact is missing run: bun run smoke:pack + - name: Build native installer release asset + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + OUT_DIR="$RUNNER_TEMP/agentbridge-release" + mkdir -p "$OUT_DIR" + PACKED="$(npm pack --pack-destination "$OUT_DIR" | tail -n 1)" + mv "$OUT_DIR/$PACKED" "$OUT_DIR/agentbridge.tgz" + (cd "$OUT_DIR" && sha256sum agentbridge.tgz > agentbridge.tgz.sha256) + gh release upload "$RELEASE_TAG" \ + install.sh \ + "$OUT_DIR/agentbridge.tgz" \ + "$OUT_DIR/agentbridge.tgz.sha256" \ + --clobber + - uses: actions/setup-node@v4 + if: github.repository == 'raysonmeng/agent-bridge' with: node-version: "22" registry-url: "https://registry.npmjs.org" - name: Publish + if: github.repository == 'raysonmeng/agent-bridge' run: npm publish --access=public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -56,6 +78,7 @@ jobs: # workflow; the package staying published is expected, not a regression. - name: Verify published to npm registry + if: github.repository == 'raysonmeng/agent-bridge' # Confirm the just-published version is actually queryable from the OFFICIAL # npm registry (never a mirror). Guards against the "publish reported success # but the version never propagated" failure mode. Retries because registry @@ -124,6 +147,7 @@ jobs: exit 1 - name: Install published from npm and verify + if: github.repository == 'raysonmeng/agent-bridge' # Install the freshly published package from the OFFICIAL registry (never a # mirror) and assert that the installed CLI reports the SAME version we just # published. This is the direct probe for the real incident: a dist bundle diff --git a/README.md b/README.md index 58305ee..a5035b2 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,23 @@ Net effect: each side receives a curated stream of meaningful messages, so conte ## Quick Start +### One-command installer (macOS / Linux / WSL) + +For released versions, the installer downloads and verifies the GitHub Release +package, installs a versioned copy, and activates `agentbridge`/`abg` in +`~/.local/bin`. It does not require Node.js or npm; Bun is detected or (with +confirmation) installed using Bun's official installer. Claude Code and Codex +CLI are checked but remain user-managed. + +```bash +curl -fsSL https://github.com/raysonmeng/agent-bridge/releases/latest/download/install.sh | bash +``` + +Use `--yes` for unattended Bun installation, `--version X.Y.Z` to pin a +release, `--dry-run` to preview actions, or `--force` when replacing an active +pair. The installer never runs `abg init`; initialize each project explicitly +after installation. + Five steps from nothing to a running pair: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 0a92bd6..8645f8c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,6 +82,21 @@ English version: [README.md](README.md) ## Quick Start +### 一键安装(macOS / Linux / WSL) + +发布版本可直接使用官方安装脚本。脚本会下载并校验 GitHub Release +资源,将 AgentBridge 安装到版本化目录,并在 `~/.local/bin` 激活 +`agentbridge`/`abg`。安装过程不需要 Node.js 或 npm;如果缺少 Bun,脚本会在 +确认后调用 Bun 官方安装器。Claude Code 和 Codex CLI 只做检查,仍由用户自行管理。 + +```bash +curl -fsSL https://github.com/raysonmeng/agent-bridge/releases/latest/download/install.sh | bash +``` + +可通过 `--yes` 允许无人值守安装 Bun,`--version X.Y.Z` 固定版本, +`--dry-run` 预览动作,或在替换活动 pair 时使用 `--force`。安装器不会运行 +`abg init`;安装后请在每个项目中显式初始化。 + 从零到一对跑起来,五步: ```bash diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6264bde --- /dev/null +++ b/install.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +set -euo pipefail + +# AgentBridge native installer for macOS, Linux, and WSL. +# The installer consumes a package attached to a GitHub Release. It does not +# require Node/npm and never runs `abg init`. + +REPOSITORY="${AGENTBRIDGE_INSTALL_REPOSITORY:-raysonmeng/agent-bridge}" +BASE_URL_OVERRIDE="${AGENTBRIDGE_RELEASE_BASE_URL:-}" +INSTALL_ROOT="${AGENTBRIDGE_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/agentbridge}" +BIN_DIR="${AGENTBRIDGE_BIN_DIR:-${XDG_BIN_HOME:-$HOME/.local/bin}}" +ASSET_NAME="${AGENTBRIDGE_RELEASE_ASSET:-agentbridge.tgz}" +CHECKSUM_NAME="${AGENTBRIDGE_RELEASE_CHECKSUM:-${ASSET_NAME}.sha256}" + +VERSION="" +YES=false +DRY_RUN=false +FORCE=false + +usage() { + cat <<'EOF' +Usage: install.sh [options] + +Install AgentBridge from a GitHub Release without requiring Node/npm. + +Options: + --version VERSION Install a specific release instead of latest + --yes Allow installation of Bun in non-interactive mode + --dry-run Print planned actions without changing the machine + --force Allow replacement while an AgentBridge pair is active + -h, --help Show this help +EOF +} + +die() { + printf 'AgentBridge installer: error: %s\n' "$*" >&2 + exit 1 +} + +warn() { + printf 'AgentBridge installer: warning: %s\n' "$*" >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || die "--version requires a value" + VERSION="$2" + shift 2 + ;; + --yes) + YES=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --force) + FORCE=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1 (use --help for usage)" + ;; + esac +done + +OS="$(uname -s)" +case "$OS" in + Darwin|Linux) ;; + *) die "unsupported operating system: $OS; use macOS, Linux, or WSL" ;; +esac + +case "$(uname -m)" in + arm64|aarch64|x86_64|amd64) ;; + *) die "unsupported CPU architecture: $(uname -m)" ;; +esac + +for command_name in curl tar; do + command -v "$command_name" >/dev/null 2>&1 || die "required command not found: $command_name" +done + +hash_command="" +if command -v shasum >/dev/null 2>&1; then + hash_command="shasum" +elif command -v sha256sum >/dev/null 2>&1; then + hash_command="sha256sum" +else + die "required checksum command not found: shasum or sha256sum" +fi + +version_at_least() { + local actual="$1" required="$2" + local actual_major actual_minor actual_patch required_major required_minor required_patch + [[ "$actual" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 + [[ "$required" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 + IFS=. read -r actual_major actual_minor actual_patch </dev/null 2>&1; then + local bun_version + bun_version="$(bun --version 2>/dev/null || true)" + version_at_least "$bun_version" "1.3.11" || die "Bun $bun_version is too old; AgentBridge requires Bun >= 1.3.11" + printf 'Bun %s detected.\n' "$bun_version" + return + fi + + if [ "$DRY_RUN" = true ]; then + printf 'Bun is missing; would run the official Bun installer.\n' + return + fi + + if [ "$YES" != true ]; then + if [ ! -t 0 ] || [ ! -t 2 ]; then + die "Bun is missing; rerun interactively and approve installation, or pass --yes" + fi + printf 'Bun is required. Run the official Bun installer now? [y/N] ' >&2 + local answer + read -r answer + case "$answer" in + y|Y|yes|YES) ;; + *) die "Bun installation was declined" ;; + esac + fi + + printf 'Installing Bun from https://bun.sh/install ...\n' + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + command -v bun >/dev/null 2>&1 || die "Bun installed but is not on PATH; restart your shell and rerun the installer" + local installed_version + installed_version="$(bun --version)" + version_at_least "$installed_version" "1.3.11" || die "installed Bun $installed_version is too old" +} + +active_pair_detected() { + local process_list + process_list="$(ps -axo pid=,command= 2>/dev/null || true)" + printf '%s\n' "$process_list" | grep -Eq 'bridge-server\.js|codex[[:space:]].*--enable[[:space:]]+tui_app_server' +} + +check_active_pair() { + if ! active_pair_detected; then + return + fi + if [ "$FORCE" = true ]; then + warn "an active AgentBridge pair was detected; --force allows replacement" + return + fi + if [ "$DRY_RUN" = true ]; then + die "an active AgentBridge pair was detected; pass --force to simulate replacement" + fi + if [ ! -t 0 ] || [ ! -t 2 ]; then + die "an active AgentBridge pair was detected; stop it first or rerun with --force" + fi + printf 'An active AgentBridge pair was detected. Replace it? [y/N] ' >&2 + local answer + read -r answer + case "$answer" in + y|Y|yes|YES) warn "replacing the active pair" ;; + *) die "upgrade cancelled; active pair was left untouched" ;; + esac +} + +release_base_url() { + if [ -n "$BASE_URL_OVERRIDE" ]; then + printf '%s\n' "${BASE_URL_OVERRIDE%/}" + elif [ -n "$VERSION" ]; then + printf '%s\n' "https://github.com/$REPOSITORY/releases/download/v$VERSION" + else + printf '%s\n' "https://github.com/$REPOSITORY/releases/latest/download" + fi +} + +sha256_of() { + if [ "$hash_command" = shasum ]; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +register_plugin() { + local package_root="$1" + if ! command -v claude >/dev/null 2>&1; then + warn "Claude Code not found; plugin registration skipped" + printf 'Install Claude Code with: curl -fsSL https://claude.ai/install.sh | bash\n' + return + fi + if claude plugin marketplace add "$package_root" >/dev/null 2>&1 && \ + claude plugin install agentbridge@agentbridge >/dev/null 2>&1; then + printf 'Claude Code plugin registered.\n' + else + warn 'Claude Code plugin registration failed; rerun "abg init" after checking Claude' + fi +} + +check_external_agents() { + if ! command -v claude >/dev/null 2>&1; then + warn 'Claude Code not found; install it with: curl -fsSL https://claude.ai/install.sh | bash' + fi + if ! command -v codex >/dev/null 2>&1; then + warn 'Codex CLI not found; install it from https://github.com/openai/codex' + fi +} + +check_active_pair +ensure_bun + +BASE_URL="$(release_base_url)" +if [ "$DRY_RUN" = true ]; then + printf 'Would download %s/%s and %s/%s\n' "$BASE_URL" "$ASSET_NAME" "$BASE_URL" "$CHECKSUM_NAME" + printf 'Would stage under %s/versions and activate %s/agentbridge + %s/abg\n' "$INSTALL_ROOT" "$BIN_DIR" "$BIN_DIR" + printf 'Would register the Claude Code plugin if claude is available.\n' + exit 0 +fi + +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/agentbridge-install.XXXXXX")" +STAGE="$TEMP_ROOT/package" +cleanup() { rm -rf "$TEMP_ROOT"; } +trap cleanup EXIT +mkdir -p "$STAGE" + +ARCHIVE="$TEMP_ROOT/$ASSET_NAME" +CHECKSUM="$TEMP_ROOT/$CHECKSUM_NAME" +printf 'Downloading AgentBridge release package...\n' +curl -fsSL "$BASE_URL/$ASSET_NAME" -o "$ARCHIVE" +curl -fsSL "$BASE_URL/$CHECKSUM_NAME" -o "$CHECKSUM" + +EXPECTED="$(awk 'NF {print tolower($1); exit}' "$CHECKSUM")" +printf '%s' "$EXPECTED" | grep -Eq '^[0-9a-fA-F]{64}$' || die "invalid SHA-256 checksum file" +ACTUAL="$(sha256_of "$ARCHIVE" | tr '[:upper:]' '[:lower:]')" +[ "$EXPECTED" = "$ACTUAL" ] || die "checksum mismatch for $ASSET_NAME" +printf 'Checksum verified.\n' + +tar -xzf "$ARCHIVE" -C "$STAGE" +PAYLOAD="$STAGE/package" +[ -f "$PAYLOAD/package.json" ] || die "release package is missing package.json" +[ -x "$PAYLOAD/dist/cli.js" ] || die "release package is missing executable dist/cli.js" +[ -f "$PAYLOAD/dist/daemon.js" ] || die "release package is missing dist/daemon.js" +[ -f "$PAYLOAD/plugins/agentbridge/server/bridge-server.js" ] || die "release package is missing the Claude plugin bundle" + +PACKAGE_VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([0-9][0-9A-Za-z.-]*\)".*/\1/p' "$PAYLOAD/package.json" | head -n 1)" +[ -n "$PACKAGE_VERSION" ] || die "could not determine package version" +if [ -n "$VERSION" ] && [ "$PACKAGE_VERSION" != "$VERSION" ]; then + die "requested version $VERSION but package contains $PACKAGE_VERSION" +fi + +VERSION_DIR="$INSTALL_ROOT/versions/$PACKAGE_VERSION" +mkdir -p "$INSTALL_ROOT/versions" "$BIN_DIR" +STAGED_VERSION="$INSTALL_ROOT/versions/.staging-$PACKAGE_VERSION-$$" +rm -rf "$STAGED_VERSION" +mv "$PAYLOAD" "$STAGED_VERSION" +PREVIOUS_VERSION="$INSTALL_ROOT/versions/.previous-$PACKAGE_VERSION-$$" +if [ -e "$VERSION_DIR" ] || [ -L "$VERSION_DIR" ]; then + rm -rf "$PREVIOUS_VERSION" + mv "$VERSION_DIR" "$PREVIOUS_VERSION" +fi +if ! mv "$STAGED_VERSION" "$VERSION_DIR"; then + if [ -e "$PREVIOUS_VERSION" ] || [ -L "$PREVIOUS_VERSION" ]; then + mv "$PREVIOUS_VERSION" "$VERSION_DIR" + fi + die "could not activate version $PACKAGE_VERSION" +fi +rm -rf "$PREVIOUS_VERSION" + +for name in agentbridge abg; do + link="$BIN_DIR/$name" + temporary_link="$BIN_DIR/.$name.$$" + rm -f "$temporary_link" + ln -s "$VERSION_DIR/dist/cli.js" "$temporary_link" + mv -f "$temporary_link" "$link" +done + +printf 'Installed AgentBridge %s under %s.\n' "$PACKAGE_VERSION" "$VERSION_DIR" +check_external_agents +register_plugin "$VERSION_DIR" + +if "$BIN_DIR/abg" doctor >/dev/null 2>&1; then + printf 'AgentBridge doctor completed.\n' +else + warn 'AgentBridge doctor reported an incomplete environment; run: abg doctor' +fi + +printf '\nNext steps:\n' +printf ' cd /path/to/your/project\n' +printf ' abg init\n' +printf ' abg claude\n' +printf ' # in another terminal: abg codex\n' diff --git a/src/unit-test/native-installer.test.ts b/src/unit-test/native-installer.test.ts new file mode 100644 index 0000000..3b255e7 --- /dev/null +++ b/src/unit-test/native-installer.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, "../.."); +const INSTALLER = join(ROOT, "install.sh"); + +function makeFixture({ active = false, badChecksum = false } = {}) { + const root = mkdtempSync(join(tmpdir(), "agentbridge-installer-test-")); + const release = join(root, "release"); + const packageRoot = join(release, "package"); + const bin = join(root, "bin"); + const data = join(root, "data"); + const stubs = join(root, "stubs"); + mkdirSync(join(packageRoot, "dist"), { recursive: true }); + mkdirSync(join(packageRoot, "plugins", "agentbridge", "server"), { recursive: true }); + mkdirSync(stubs); + + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "@raysonmeng/agentbridge", version: "1.2.3" })); + writeFileSync(join(packageRoot, "dist", "cli.js"), "#!/usr/bin/env bun\n"); + writeFileSync(join(packageRoot, "dist", "daemon.js"), "daemon\n"); + writeFileSync(join(packageRoot, "plugins", "agentbridge", "server", "bridge-server.js"), "bridge\n"); + writeFileSync(join(packageRoot, "plugins", "agentbridge", "server", "daemon.js"), "daemon\n"); + chmodSync(join(packageRoot, "dist", "cli.js"), 0o755); + + const archive = join(release, "agentbridge.tgz"); + execFileSync("tar", ["-czf", archive, "-C", release, "package"]); + const checksum = createHash("sha256").update(readFileSync(archive)).digest("hex"); + writeFileSync(join(release, "agentbridge.tgz.sha256"), `${badChecksum ? "0".repeat(64) : checksum} agentbridge.tgz\n`); + + const writeStub = (name: string, body: string) => { + const path = join(stubs, name); + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); + }; + writeStub("bun", 'if [ "$1" = "--version" ]; then echo "1.4.0"; else exit 0; fi'); + writeStub("claude", `printf '%s\\n' "$*" >> "${join(root, "claude.log")}"`); + writeStub("codex", "exit 0"); + writeStub("ps", active ? 'echo "123 agentbridge daemon bridge-server.js"' : "exit 0"); + + return { + root, + release, + bin, + data, + env: { + ...process.env, + HOME: join(root, "home"), + PATH: `${stubs}:${process.env.PATH ?? ""}`, + AGENTBRIDGE_RELEASE_BASE_URL: `file://${release}`, + AGENTBRIDGE_INSTALL_ROOT: data, + AGENTBRIDGE_BIN_DIR: bin, + }, + claudeLog: join(root, "claude.log"), + }; +} + +function runInstaller(fixture: ReturnType, args: string[] = ["--yes"]) { + return spawnSync("bash", [INSTALLER, ...args], { + cwd: ROOT, + env: fixture.env, + encoding: "utf-8", + }); +} + +describe("native installer", () => { + test("downloads, verifies, stages and activates a release package", () => { + const fixture = makeFixture(); + const result = runInstaller(fixture); + + expect(result.status).toBe(0); + expect(existsSync(join(fixture.data, "versions", "1.2.3", "dist", "cli.js"))).toBe(true); + expect(readFileSync(join(fixture.bin, "agentbridge"), "utf-8")).toContain("#!/usr/bin/env bun"); + expect(readFileSync(fixture.claudeLog, "utf-8")).toContain("plugin marketplace add"); + }); + + test("rejects a release package with a checksum mismatch", () => { + const fixture = makeFixture({ badChecksum: true }); + const result = runInstaller(fixture); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("checksum"); + expect(existsSync(join(fixture.data, "versions"))).toBe(false); + }); + + test("refuses to replace an active pair unless --force is explicit", () => { + const fixture = makeFixture({ active: true }); + const result = runInstaller(fixture); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("--force"); + expect(existsSync(join(fixture.data, "versions"))).toBe(false); + }); + + test("dry-run reports actions without touching the filesystem", () => { + const fixture = makeFixture(); + const result = runInstaller(fixture, ["--yes", "--dry-run"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Would download"); + expect(existsSync(fixture.data)).toBe(false); + expect(existsSync(fixture.bin)).toBe(false); + }); +});