#!/bin/sh # CodeConnect installer — https://codeconnect.sh # # Usage: # curl -fsSL https://codeconnect.sh | sh # # **Why every action lives inside a function.** # # `curl | sh` hands bytes to the shell as they arrive, so the shell can begin # executing line 1 while line 200 is still in flight. A dropped connection # halfway through an ordinary streaming script leaves the first half already # executed — and the first half is where the deleting happens. `set -e` cannot # help: the mutation already ran. # # So: this file defines functions and does nothing else, and the only # invocation is the literal last line. Truncate anywhere inside a function and # `sh` fails to parse the file at all, before running a single command. # Truncate after a function's closing brace and you get a defined function # nobody calls. Neither outcome touches the disk. # # The consequence for maintainers: **no top-level command may be added above # that final line.** Not an `echo`, not a variable assignment that shells out, # not a "quick" trap. The test suite enforces this. # # The other consequence: success must be *stated*, never inferred from silence. # A truncated run exits 0 having done nothing, so "no error" is not evidence of # an install. Look for the final summary block. CODECONNECT_INSTALLER_VERSION="1.1.0" CODECONNECT_REPO="faisalmumtaz89/CodeConnect" CODECONNECT_REPO_URL="https://github.com/faisalmumtaz89/CodeConnect.git" CODECONNECT_API="https://api.github.com/repos/faisalmumtaz89/CodeConnect/releases/latest" # The Apple Team ID whose signature is accepted on release binaries. # # Empty means the release path is not configured, and the installer refuses it # rather than falling back to an unverified install. Fail closed: an installer # that quietly stops checking signatures is worse than one that does not run. CODECONNECT_TEAM_ID="2XL2264MC8" # **Copied verbatim from `signature_requirement()` in # `mac/codeconnect/src/update_install.rs`, and it must stay that way.** The # installer and `codeconnect update` are the two doors into the same # directory; a machine that accepts a binary at install time which the updater # would later reject — or the reverse — is worse than either rule alone. # # Three clauses, and dropping any one makes it meaningless: # # * `anchor apple generic` — the chain ends at Apple. Without it the whole # expression is satisfiable by a certificate anybody can mint. # * the two OIDs — the issuing CA is Apple's *Developer ID* CA and the leaf # is a *Developer ID Application* certificate. Without them any Apple # certificate qualifies, including the free Apple Development certificate # every developer account can issue in a minute. # * `subject.OU` — that certificate belongs to *this* team. CODECONNECT_REQUIREMENT="anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$CODECONNECT_TEAM_ID\"" # Fixed, and not a matter of taste — see the note in `install_release`. CODECONNECT_PREFIX="${CODECONNECT_HOME:-$HOME/.codeconnect}" # ------------------------------------------------------------------ output # # Everything goes to stderr. stdout is reserved so a caller can pipe this # script's *result* somewhere without status chatter contaminating it. cc_say() { printf '%s\n' "$*" >&2; } cc_step() { printf '\n\033[1m%s\033[0m\n' "$*" >&2; } cc_warn() { printf 'warning: %s\n' "$*" >&2; } # Failure is loud, names what did and did not happen, and gives exactly one # next action. It never speculates about a cause it did not observe. cc_die() { printf '\nerror: %s\n' "$1" >&2 shift for line in "$@"; do printf '%s\n' "$line" >&2; done exit 1 } cc_usage() { cat >&2 <<'USAGE' CodeConnect installer curl -fsSL https://codeconnect.sh | sh Options: --reinstall Replace an existing install. Normally an existing install is left to `codeconnect update`, which is the product's own updater and does the job better. --source Build from a source checkout instead of installing a release. Requires a Rust toolchain and takes several minutes. --version Print the installer version and exit. --help Print this message and exit. Installs to ~/.codeconnect/bin. Never uses sudo. Never edits your shell configuration. Never enables a system service — it prints the command and lets you decide. Audit before running: curl -fsSL https://codeconnect.sh -o codeconnect-install.sh less codeconnect-install.sh sh codeconnect-install.sh USAGE } # ------------------------------------------------------------------- tools # Children never inherit the pipe. stdin *is* this script when invoked as # `curl | sh`, so any child that reads stdin would eat the installer's own # remaining bytes — and `git` and `cargo` both read stdin under some # conditions. cc_run() { "$@" /dev/null 2>&1; } # `-q` first and always: without it curl reads ~/.curlrc, which can add URLs, # attach headers or redirect output. # # `--proto '=https'` governs the *initial* request and `--proto-redir '=https'` # governs every hop after it — without the second, a redirect can walk the # download down to plaintext, which is exactly the hop worth attacking. # `--max-filesize` bounds a response that lies about its size. cc_fetch() { cc_run curl -q --proto '=https' --proto-redir '=https' --tlsv1.2 \ -fsSL --max-time 300 --max-filesize "$3" "$1" -o "$2" } cc_fetch_stdout() { cc_run curl -q --proto '=https' --tlsv1.2 -fsSL --max-time 30 --max-filesize 262144 "$1" } # One string field out of a JSON object, without requiring jq. Deliberately # narrow: it matches `"key": "value"` and nothing cleverer, because the only # document it reads is a GitHub release object and a parser that guesses is a # parser that lies. cc_json_string() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1 } # --------------------------------------------------------------- preflight # Collected in one pass rather than one-at-a-time: a user missing three tools # should learn that once, not across three failed runs. # Built with a command substitution rather than an embedded literal newline: # a definition that spans lines is one a truncated download can split in half. cc_missing="" cc_need() { cc_missing="$(printf '%s\n - %s' "$cc_missing" "$1")"; } # Written as a full `if` rather than `[ ... ] && return 0`. Under `set -e` an # AND-OR list whose result is non-zero aborts the shell, so the short form # would exit silently on the branch that most needs to print something. cc_preflight_common() { if [ "$(id -u)" = "0" ]; then cc_die "do not run this installer as root." \ "" \ "CodeConnect installs into your own home directory and needs no" \ "elevated privileges. Rerun it without sudo." fi } cc_preflight_platform() { os="$(uname -s)" if [ "$os" != "Darwin" ]; then cc_die "CodeConnect is macOS-only; this machine reports '$os'." \ "" \ "Nothing was installed." fi arch="$(uname -m)" case "$arch" in arm64 | x86_64) ;; *) cc_die "unrecognised architecture '$arch'." \ "" \ "CodeConnect ships for arm64 and x86_64. Nothing was installed." ;; esac if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then cc_die "\$HOME is not set to a directory that exists." \ "" \ "Nothing was installed." fi } # tmux is a *runtime* dependency, not a build one: the daemon installs fine # without it and then cannot host a single session. So this warns rather than # refuses — refusing would overreach on an install that is genuinely valid, # and staying silent would let the user discover it at the worst moment. cc_check_tmux() { if cc_have tmux; then return 0 fi cc_warn "tmux is not installed." cc_say " CodeConnect hosts every session in a private tmux server, so" cc_say " \`codeconnect claude\` will not start one until tmux exists." if cc_have brew; then cc_say " Install it with: brew install tmux" else cc_say " Install it via Homebrew (https://brew.sh) or your package manager." fi } # ----------------------------------------------------------------- locking # `mkdir` is the atomic primitive every POSIX filesystem agrees on. A stale # lock is never broken automatically: two installers racing on the same # prefix is exactly the situation where guessing is worst. cc_lock_dir="" cc_stage_dir="" # Full `if`s, not `[ … ] && …` chains. `set -e` is in force inside a trap # handler too, so a chain whose first test is false aborts the handler — which # would have skipped the lock removal below on every early exit, leaving the # next run to report an install that is not running. cc_cleanup() { if [ -n "$cc_stage_dir" ] && [ -d "$cc_stage_dir" ]; then rm -rf "$cc_stage_dir" fi if [ -n "$cc_lock_dir" ] && [ -d "$cc_lock_dir" ]; then rmdir "$cc_lock_dir" 2>/dev/null || true fi return 0 } cc_acquire_lock() { cc_lock_dir="$CODECONNECT_PREFIX/.install-lock" if ! mkdir "$cc_lock_dir" 2>/dev/null; then cc_lock_dir="" cc_die "another CodeConnect install is already running." \ "" \ "If you are certain none is, remove the lock and rerun:" \ " rmdir $CODECONNECT_PREFIX/.install-lock" \ "" \ "Nothing was changed." fi trap cc_cleanup EXIT HUP INT TERM } cc_prepare_prefix() { if [ -e "$CODECONNECT_PREFIX" ] && [ ! -d "$CODECONNECT_PREFIX" ]; then cc_die "$CODECONNECT_PREFIX exists and is not a directory." \ "" \ "Nothing was changed." fi if [ -L "$CODECONNECT_PREFIX" ]; then cc_die "$CODECONNECT_PREFIX is a symlink." \ "" \ "This installer will not write through a symlinked prefix." \ "Nothing was changed." fi mkdir -p "$CODECONNECT_PREFIX" } # ------------------------------------------------------- existing installs # An existing install is handed to `codeconnect update`, not replaced here. # # This installer is a bootstrap. `codeconnect update` is the product's own # updater: it compares versions across all three binaries, refuses a # downgrade, exchanges the whole directory in one step so a power cut cannot # leave a mixed set, and restarts a managed daemon. Reimplementing a worse # version of that on the upgrade path would be the wrong trade. # # `--reinstall` is the escape hatch for a set that is broken badly enough that # its own updater cannot run. cc_guard_existing() { if [ ! -x "$CODECONNECT_PREFIX/bin/codeconnect" ]; then return 0 fi if [ "$cc_reinstall" = "yes" ]; then return 0 fi existing="$(cc_run "$CODECONNECT_PREFIX/bin/codeconnect" --version 2>/dev/null || echo 'unreadable')" cc_die "CodeConnect is already installed on this Mac." \ "" \ " $existing" \ "" \ "To move it to the latest release, use its own updater — it swaps all" \ "three binaries in one step and restarts a managed daemon:" \ "" \ " $CODECONNECT_PREFIX/bin/codeconnect update" \ "" \ "If that install is broken badly enough that its updater will not run," \ "rerun this with --reinstall." \ "" \ "Nothing was changed." } # -------------------------------------------------------- release resolve cc_resolve_release() { cc_step "Resolving the latest CodeConnect release…" body="$cc_stage_dir/release.json" if ! cc_fetch_stdout "$CODECONNECT_API" >"$body" 2>/dev/null; then cc_die "could not reach the GitHub Releases API." \ "" \ "This means one of: no network, GitHub is unreachable, the API" \ "rate-limited this IP, or the repository is not public. The" \ "request does not distinguish them, so this message will not" \ "guess." \ "" \ "Nothing was installed." fi cc_tag="$(cc_json_string "$body" tag_name)" if [ -z "$cc_tag" ]; then cc_die "the Releases API returned no tag for a latest release." \ "" \ "CodeConnect may not have published one yet." \ "Nothing was installed." fi # `v0.4.0` -> `0.4.0`. The tag is the contract; the bare version is what # asset names are built from. cc_version="${cc_tag#v}" case "$cc_version" in *[!0-9.]* | "") cc_die "the latest release tag '$cc_tag' is not a plain version." \ "" \ "Nothing was installed." ;; esac cc_say " latest release: $cc_tag" } # --------------------------------------------------------- release install # Runs before anything is created, so that "nothing was changed" is literally # true rather than true-about-binaries. An empty directory left behind by a # refusal is a small lie, and this codebase does not get to tell small ones. cc_preflight_release() { if [ -z "$CODECONNECT_TEAM_ID" ]; then cc_die "this installer has no signing identity configured." \ "" \ "Release installs verify an Apple Developer ID signature, and" \ "the Team ID to check against is not set in this build of the" \ "installer. It will not install an unverified binary." \ "" \ "Build from source instead, if you have a Rust toolchain:" \ "" \ " curl -fsSL https://codeconnect.sh | sh -s -- --source" \ "" \ "Nothing was changed." fi } cc_install_release() { cc_resolve_release archive="codeconnect-${cc_version}-macos-universal.tar.gz" base="https://github.com/${CODECONNECT_REPO}/releases/download/${cc_tag}" cc_step "Downloading ${archive}…" cc_fetch "$base/$archive.sha256" "$cc_stage_dir/$archive.sha256" 4096 || cc_die "could not download the checksum for $cc_tag." "" "Nothing was installed." cc_fetch "$base/$archive" "$cc_stage_dir/$archive" 104857600 || cc_die "could not download $archive." "" "Nothing was installed." # Integrity, not authenticity. Checksum and archive share an origin, so # this catches a truncated or corrupted download and nothing more. The # signature check below is the one that survives a hostile origin. cc_step "Verifying the download…" (cd "$cc_stage_dir" && cc_run shasum -a 256 -c "$archive.sha256" >/dev/null 2>&1) || cc_die "$archive failed its checksum." \ "" \ "The download is corrupt or was tampered with in transit." \ "Nothing was installed." cc_say " checksum ok" cc_run tar -xzf "$cc_stage_dir/$archive" -C "$cc_stage_dir" || cc_die "could not unpack $archive." "" "Nothing was installed." unpacked="$cc_stage_dir/codeconnect-${cc_version}" [ -d "$unpacked" ] || cc_die "$archive did not contain codeconnect-${cc_version}/." "" "Nothing was installed." # **The check that matters.** A bare `codesign --verify` only proves a # signature is internally intact, which an attacker's own signature also # satisfies. The pinned requirement is what makes this a statement about # *who* signed it. # # `--all-architectures` is not optional here. A universal binary carries # one signature per slice, and codesign otherwise checks only the slice # this Mac would run: the x86_64 half of a universal file could be # replaced wholesale and an arm64 Mac would never look at it. # # There is no `--require-timestamp` verify flag — passing one makes # codesign exit on a usage error, which a `|| cc_die` then reports as a # bad signature. The release workflow checks the timestamp separately, by # reading `codesign --display` output, and that is the right place for it. # # `-R` rather than `spctl`: spctl assesses against system policy, whose # behaviour on a non-quarantined bare Mach-O depends on state this script # does not control, and which needs the network. An explicit requirement # is deterministic and offline. cc_step "Verifying the signature…" for binary in codeconnect ccd cc-hook; do cc_run codesign --verify --strict --all-architectures \ -R="$CODECONNECT_REQUIREMENT" \ "$unpacked/$binary" >/dev/null 2>&1 || cc_die "$binary is not signed by CodeConnect (team $CODECONNECT_TEAM_ID)." \ "" \ "Refusing to install it. Nothing was installed." done cc_say " signed by $CODECONNECT_TEAM_ID, all slices" cc_place_binaries "$unpacked" cc_installed_from="release $cc_tag" } # ---------------------------------------------------------- source install cc_install_source() { cc_step "Checking build prerequisites…" cc_missing="" cc_have git || cc_need "git" cc_have cargo || cc_need "a Rust toolchain (https://rustup.rs)" cc_run xcode-select -p >/dev/null 2>&1 || cc_need "Xcode command line tools (xcode-select --install)" if [ -n "$cc_missing" ]; then cc_die "building from source needs tools this Mac does not have:" \ "$cc_missing" \ "" \ "Install them and rerun. Nothing was installed." fi cc_say " git, cargo and the command line tools are present" src="$CODECONNECT_PREFIX/src" if [ -e "$src" ]; then cc_die "$src already exists." \ "" \ "This installer will not build over a directory it did not" \ "create. Inspect it, move it aside, and rerun." \ "" \ "Nothing was changed." fi cc_step "Cloning ${CODECONNECT_REPO}…" GIT_TERMINAL_PROMPT=0 cc_run git -c credential.helper= \ clone --branch main "$CODECONNECT_REPO_URL" "$cc_stage_dir/src" || cc_die "could not clone $CODECONNECT_REPO." \ "" \ "Either the network is unavailable or the repository is not" \ "public. This message will not guess which." \ "" \ "Nothing was installed." # The clone moves out of staging; the (now empty) staging directory stays # registered so the exit trap still removes it. mv "$cc_stage_dir/src" "$src" cc_step "Building — this takes a few minutes…" # The repository's own installer does the build, the ad-hoc signing, the # inode replacement and the `source-checkout` record. Reimplementing any # of that here would be a second copy of logic that has already been paid # for in bugs. cc_run /bin/bash "$src/mac/install.sh" || cc_die "the CodeConnect installer did not complete." \ "" \ "The checkout is at $src and some binaries may have been" \ "replaced. Read the error above, fix it, and rerun:" \ "" \ " cd $src && ./mac/install.sh" cc_installed_from="source ($src)" } # ------------------------------------------------------------- placement # **The prefix is fixed at ~/.codeconnect/bin and this is not a style # preference.** `cc-hook`'s absolute path is written into the settings file # generated for each live session, and sessions deliberately outlive the # daemon. Installing the binaries anywhere else — or moving them during an # update — breaks the hooks of every session currently running. # # The rm-before-cp is the other half of the same care: macOS caches a # binary's code signature against its inode, and overwriting in place leaves # the cached signature describing different bytes. The kernel then SIGKILLs # every subsequent exec with no diagnostic at all. cc_place_binaries() { from="$1" bin="$CODECONNECT_PREFIX/bin" mkdir -p "$bin" cc_step "Installing to ${bin}…" for binary in codeconnect ccd cc-hook; do rm -f "$bin/$binary" cp "$from/$binary" "$bin/$binary" chmod 755 "$bin/$binary" # Never re-sign. An ad-hoc signature over a Developer ID one # destroys it, and with it the guarantee just verified. cc_run "$bin/$binary" --version >/dev/null 2>&1 || cc_die "the installed $binary does not run." \ "" \ "$bin may now hold a partially replaced set of binaries." \ "Rerun the installer once the cause is understood." done cc_say " codeconnect, ccd and cc-hook installed" # A daemon already under launchd keeps executing the binaries it started # with, so replacing files on disk without this leaves every visible sign # saying the new build is live while the old one is still serving. # Gated on the plist rather than on asking the daemon: a file test cannot # be refused, and the freshly signed inode can be refused exactly once. if [ -f "$HOME/Library/LaunchAgents/com.codeconnect.ccd.plist" ]; then cc_say " restarting the managed daemon…" cc_run "$bin/codeconnect" daemon restart >/dev/null 2>&1 || cc_warn "the daemon did not restart; run 'codeconnect daemon restart' yourself." fi } # ---------------------------------------------------------------- summary cc_summary() { bin="$CODECONNECT_PREFIX/bin" version="$(cc_run "$bin/codeconnect" --version 2>/dev/null || echo 'unknown')" cc_step "CodeConnect is installed." cc_say " $version" cc_say " from: $cc_installed_from" cc_say " at: $bin" cc_say "" # The PATH line is printed, never written. A child process cannot change # its parent's environment anyway, and editing someone's shell # configuration behind their back is not a thing this project does. case ":$PATH:" in *":$bin:"*) ;; *) cc_say "$bin is not on your PATH. For this terminal:" cc_say "" cc_say " export PATH=\"$bin:\$PATH\"" cc_say "" cc_say "To make it permanent, add that line to your shell profile" cc_say "yourself — this installer does not edit it for you." cc_say "" ;; esac cc_say "Next:" cc_say " codeconnect daemon install # run ccd under launchd" cc_say " codeconnect pair # QR code to pair your iPhone" cc_say " codeconnect claude # start a session here" cc_say "" cc_say "Nothing has been enabled for you. Those are yours to run." } # ------------------------------------------------------------------- main codeconnect_install_main() { set -eu umask 077 mode="release" cc_reinstall="no" for argument in "$@"; do case "$argument" in --source) mode="source" ;; --reinstall) cc_reinstall="yes" ;; --version) printf 'codeconnect-installer %s\n' "$CODECONNECT_INSTALLER_VERSION" return 0 ;; --help | -h) cc_usage return 0 ;; *) cc_say "unknown option: $argument" cc_usage return 2 ;; esac done cc_say "CodeConnect installer $CODECONNECT_INSTALLER_VERSION" # Everything that can refuse runs before anything that can write, and the # more specific refusal is ordered first: someone who already has # CodeConnect should be told that, not told the installer lacks a signing # identity. Same ranking CodeConnect itself uses when a checkout advisory # and a release advisory both apply. cc_preflight_common cc_preflight_platform cc_guard_existing if [ "$mode" = "release" ]; then cc_preflight_release fi # First write happens here. cc_prepare_prefix cc_acquire_lock cc_stage_dir="$(mktemp -d "$CODECONNECT_PREFIX/.install-stage.XXXXXX")" if [ "$mode" = "source" ]; then cc_install_source else cc_install_release fi cc_check_tmux cc_summary } codeconnect_install_main "$@"