From b51632e54b8423e0c93596d226d902085f50a41c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 02:14:58 +0100 Subject: [PATCH] feat(desktop): install Codeman from the app, with no Terminal and no password The This Mac tab runs install.sh --no-admin itself. The two questions only a person can answer move into the app: which coding agent to install, shown only when this Mac has none, and who may reach Codeman. The installer's output goes to a file, so a running install survives the app quitting and is picked up again when it reopens. install.sh gains --ai-cli, --progress and a preflight subcommand, which is what the app reads its choices from, so it keeps no copy of the CLI catalogue. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 19 + CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 + docs/wiki/Installation.md | 7 +- install.sh | 270 +++++++-- packages/desktop/README.md | 68 ++- packages/desktop/assets/connect.html | 602 ++++++++++++++++++- packages/desktop/src/gui_install.rs | 855 +++++++++++++++++++++++++++ packages/desktop/src/local_server.rs | 35 +- packages/desktop/src/main.rs | 63 +- packages/desktop/src/policy.rs | 116 ++++ packages/desktop/src/setup.rs | 58 +- test/desktop-connect-install.test.ts | 447 ++++++++++++++ test/install-sh-invariants.test.ts | 231 +++++++- 14 files changed, 2670 insertions(+), 105 deletions(-) create mode 100644 packages/desktop/src/gui_install.rs create mode 100644 test/desktop-connect-install.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bea9d6d..0114133b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,25 @@ jobs: CLI_DETECT_DONE=""; detect_all_clis offer_ai_cli_install >/dev/null 2>&1 echo "bash $BASH_VERSION: skipping the AI CLI install menu continues" + # --ai-cli answers the same menu with no terminal: `none` carries on, and a + # name runs that one entry (bash 3.2 cannot expand an empty offer list under + # set -u, which is the guard the loop over it has to get right). + has_tty() { return 1; } + AI_CLI_CHOICE=none + offer_ai_cli_install >/dev/null 2>&1 + AI_CLI_CHOICE=codex + k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do + CLI_INSTALL_CMD_TRUSTED[$k]="" + k=$((k + 1)) + done + offer_ai_cli_install >/dev/null 2>&1 + echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer" + # What the desktop app runs: preflight on stdout, and only JSON there. + out=$(bash /w/install.sh preflight --no-admin) + [[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] + json_string "a\"b\\c" >/tmp/json-out + [[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] + echo "bash $BASH_VERSION: preflight prints JSON" ' # Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an # array, and on stock macOS there is no `timeout`, so the array is empty and the diff --git a/CLAUDE.md b/CLAUDE.md index 305a5844..db59f6ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -384,6 +384,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer ## Scripts & Tunnel -**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer) +**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli |none`, `install.sh preflight`, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer) Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7b6115e0..ed0ca148 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1303,6 +1303,8 @@ On this route Node.js comes from nodejs.org (`latest-v.x/SHASUMS256.txt` ⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting. +**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli |none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). + ⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log. ## Agent inspection and guarded submission diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 2542c36a..0474ecce 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -78,6 +78,7 @@ install.sh uninstall # remove (offers to undo a rename it performed) install.sh tailscale # retrofit Tailscale access onto an existing install install.sh name [] # rename this machine on your tailnet (default codeman-) install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel +install.sh preflight # print what is on this machine as JSON; changes nothing ``` **Flags** answer the questions from the command line and pipe through `bash -s --`: @@ -94,10 +95,14 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every `--port ` moves Codeman off 3000; the service file and the serve mapping follow it. On an existing install, `--port` and `--password` re-run the setup so the service file picks them up, and a re-run with `--lan` or `--tailscale` keeps the password the service already has. +`--ai-cli ` answers the agent menu that appears when no agent CLI is found, with no +terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it. **Automation and CI**: with no terminal attached, any step that would change the system aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to -approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never +approve those steps. With nothing else said, that also installs the first agent CLI of the +catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none. +`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never installs Tailscale itself non-interactively; a non-interactive run never renames the machine and never starts a service. Everything the unattended steps print goes to `~/.codeman/install.log`, and the last lines of it are shown when a step fails. diff --git a/install.sh b/install.sh index bb5b4c92..6efa3320 100755 --- a/install.sh +++ b/install.sh @@ -20,6 +20,10 @@ # --no-admin Never use sudo or Homebrew: download Node.js and tmux into # the home folder instead (chosen by itself when this account # has no administrator rights) +# --ai-cli |none Which AI CLI to install when none is found, without the menu +# (the ids are the keys of the catalogue below) +# --progress Also print machine-readable progress lines (for the +# desktop app; see progress_phase) # --help, -h Print this text # # Environment variables: @@ -49,6 +53,8 @@ # CODEMAN_TARBALL_URL - Source archive (.tar.gz) to install from when no # working git is available (default: the GitHub # archive of CODEMAN_REPO_URL at CODEMAN_BRANCH) +# CODEMAN_AI_CLI - Same as --ai-cli +# CODEMAN_PROGRESS=1 - Same as --progress # # Subcommands: # install.sh update - Update an existing install @@ -57,6 +63,7 @@ # install.sh name [] - Rename this machine on your tailnet (default: codeman-) # install.sh status - Print the URLs, the QR code and how to manage the service # install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel +# install.sh preflight - Print what is on this machine as JSON; changes nothing set -euo pipefail @@ -154,6 +161,12 @@ ASSUME_YES="0" # Set by parse_flags when a flag asks to change how an existing install is # reached or run, so a bare re-run takes the full flow instead of a quiet update. RECONFIGURE="0" +# --ai-cli / CODEMAN_AI_CLI: answers the AI CLI menu, with a catalogue id or +# "none". Empty means ask. Consent comes with the flag, so unlike the +# CODEMAN_NONINTERACTIVE default it installs exactly what was named. +AI_CLI_CHOICE="${CODEMAN_AI_CLI:-}" +# --progress / CODEMAN_PROGRESS=1: see progress_phase. +PROGRESS="${CODEMAN_PROGRESS:-0}" SUBCOMMAND="" SUBCOMMAND_ARG="" # Answers to question 3 (choose_launch_mode): LAUNCH_CHOICE 1/2/3 as above, @@ -261,6 +274,24 @@ warn() { error() { echo -e "${RED}Error:${NC} $1" >&2 + if [[ "$PROGRESS" == "1" ]]; then + printf '@@codeman error %s\n' "${1//$'\n'/ }" >&2 + fi +} + +# progress_phase : tell the desktop app which part of the install has +# begun. The protocol is one line, `@@codeman phase `, for each of +# runtime, agent, source, packages, build, service and done, always in that +# order, and `@@codeman error ` for each error() above. A phase runs until +# the next one starts; a run that ends without `done` failed. A phase with +# nothing to do may be skipped. The app ignores a name it does not know, so a +# new phase needs no new app, but `done` must stay the last thing main() reports. +# On stderr, because this can be reached inside a $( ) that captures stdout. +progress_phase() { + if [[ "$PROGRESS" == "1" ]]; then + printf '@@codeman phase %s\n' "$1" >&2 + fi + return 0 } die() { @@ -737,6 +768,17 @@ cli_catalog_names() { printf '%s' "$out" } +# "claude, opencode, codex, ...": the ids --ai-cli takes. +cli_catalog_ids() { + local i out="" + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue + out="${out:+$out, }${CLI_IDS[$i]}" + done + printf '%s' "$out" +} + # The "install one yourself" hints: every enabled CLI that is not installed, # showing the trusted install command. An entry with no install command gets # its docs URL instead of being silently omitted, which is what used to @@ -774,6 +816,72 @@ cli_catalog_print_install_hints() { # the first time a hint is printed. cli_catalog_select_platform +# cli_index : print the catalogue index of an enabled CLI that has a binary +# to look for. Fails for anything else, `shell` included. +cli_index() { + local i + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + if [[ "${CLI_IDS[$i]}" == "$1" ]] && [[ "${CLI_ENABLED[$i]}" == "1" ]] && [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]]; then + printf '%s' "$i" + return 0 + fi + done + return 1 +} + +# cli_offerable : can this run install that entry by itself? 0 = yes, +# 1 = no, 2 = no, only because its command needs curl and this host has wget. +# One rule for the menu, for --ai-cli and for `preflight`, so what the desktop +# app lists is exactly what the menu would. detect_all_clis must have run. +# +# ⚠️ The registry's install commands are a MIX: some call `curl` directly +# (vendor one-liners), others are `npm install -g …`, which never needed curl +# at all. A wget-only host used to lose the WHOLE menu over this, including +# every npm entry: the two literals this replaced went through +# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1 +# silently stopped defaulting to Claude Code as documented. So this is decided +# per entry: only a command that actually starts with `curl ` is curl-dependent. +# Rewriting curl to wget inside a string about to be executed is the wrong +# instinct either way; the ones we can't run, we show as a hint. +cli_offerable() { + local i="$1" + [[ "${CLI_ENABLED[$i]}" == "1" ]] || return 1 + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || return 1 + [[ -z "${CLI_FOUND_PATH[$i]}" ]] || return 1 + [[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || return 1 + if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then + return 2 + fi + # Same reasoning for a Homebrew command: it cannot run where there is no + # Homebrew, and the no-admin route never installs one. + if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then + return 1 + fi + return 0 +} + +# install_offered_cli : run the trusted command of one entry and prove +# it left a CLI behind. A chosen install that leaves nothing is fatal. +install_offered_cli() { + local idx="$1" + info "Installing ${CLI_LABELS[$idx]}..." + # /dev/null || true + CLI_DETECT_DONE="" + detect_all_clis + if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then + success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}" + else + warn "${CLI_LABELS[$idx]} installation failed." + fi + if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then + die "The selected AI CLI failed to install. Install one manually and re-run the installer." + fi + return 0 +} + # Offer to install one AI CLI from the catalogue, or let the user skip. # # Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts @@ -787,7 +895,10 @@ offer_ai_cli_install() { local i echo "" warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)." - headless_guard "install an AI CLI (curl | bash from its vendor)" + # --ai-cli is the consent the guard otherwise looks for at a terminal. + if [[ -z "$AI_CLI_CHOICE" ]]; then + headless_guard "install an AI CLI (curl | bash from its vendor)" + fi echo "" # The menu is built from the catalogue: every enabled CLI that is not @@ -799,36 +910,38 @@ offer_ai_cli_install() { # ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED, # the only array the generated block above writes and the only one the # installer ever runs or displays — see cli_catalog_select_platform. - # - # ⚠️ The registry's install commands are a MIX: some call `curl` directly - # (vendor one-liners), others are `npm install -g …`, which never needed - # curl at all. A wget-only host used to lose the WHOLE menu over this, - # including every npm entry — the two literals this replaced went through - # download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1 - # silently stopped defaulting to Claude Code as documented. Filter per - # entry instead: only a command that actually starts with `curl ` is - # curl-dependent, so only THOSE are held back on a wget-only host. - # Rewriting curl to wget inside a string about to be executed is the - # wrong instinct either way — the ones we can't run, we show as a hint. local -a offer_idx=() - local curl_only_skipped=0 + local curl_only_skipped=0 offerable for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do - [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue - [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue - [[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue - [[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue - if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then - curl_only_skipped=$((curl_only_skipped + 1)) - continue - fi - # Same reasoning for a Homebrew command: it cannot run where there is no - # Homebrew, and the no-admin route never installs one. - if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then - continue - fi - offer_idx[${#offer_idx[@]}]=$i + offerable=0 + cli_offerable "$i" || offerable=$? + case "$offerable" in + 0) offer_idx[${#offer_idx[@]}]=$i ;; + 2) curl_only_skipped=$((curl_only_skipped + 1)) ;; + esac done + # The answer was given up front: never the menu, and never a different CLI + # than the one named. A name this host cannot install (parse_flags already + # refused a typo) warns and carries on, the same as Skip. + if [[ -n "$AI_CLI_CHOICE" ]]; then + local named="" entry + if [[ "$AI_CLI_CHOICE" != "none" ]]; then + named=$(cli_index "$AI_CLI_CHOICE") || named="" + for entry in ${offer_idx[@]+"${offer_idx[@]}"}; do + if [[ "$entry" == "$named" ]]; then + install_offered_cli "$named" + return 0 + fi + done + warn "$AI_CLI_CHOICE cannot be installed automatically here. Codeman will run, but sessions need a CLI to drive." + else + warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." + fi + cli_catalog_print_install_hints + return 0 + fi + if [[ "$curl_only_skipped" -gt 0 ]]; then warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)." fi @@ -877,22 +990,7 @@ offer_ai_cli_install() { warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." cli_catalog_print_install_hints else - idx="${offer_idx[$((cli_choice - 1))]}" - info "Installing ${CLI_LABELS[$idx]}..." - # /dev/null || true - CLI_DETECT_DONE="" - detect_all_clis - if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then - success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}" - else - warn "${CLI_LABELS[$idx]} installation failed." - fi - if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then - die "The selected AI CLI failed to install. Install one manually and re-run the installer." - fi + install_offered_cli "${offer_idx[$((cli_choice - 1))]}" fi fi } @@ -1993,7 +2091,8 @@ run_step() { success "$label ($(fmt_elapsed $((SECONDS - start))))" return 0 fi - error "$label failed (exit $rc). Last lines of $LOG_FILE:" + error "$label failed (exit $rc)." + echo "Last lines of $LOG_FILE:" >&2 tail -n 25 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2 return "$rc" } @@ -3680,6 +3779,7 @@ main() { # ======================================================================== # Ask everything up front, so the build afterwards can run unattended # ======================================================================== + progress_phase runtime ask_dependencies "$os" "$distro" # AI CLI: needs a human choice AND node (installed just above), so it sits @@ -3689,6 +3789,7 @@ main() { CLI_DETECT_DONE="" detect_all_clis if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then + progress_phase agent offer_ai_cli_install fi @@ -3703,8 +3804,11 @@ main() { # ======================================================================== info "Installing Codeman to $INSTALL_DIR (a few minutes; you can leave this running)" INSTALL_STARTED="1" + progress_phase source install_or_update_repo + progress_phase packages npm_install_deps + progress_phase build run_step "Building Codeman" npm run build || exit 1 finish_source_swap install_symlink @@ -3715,6 +3819,7 @@ main() { # (symlinks, PATH, launch menu) instead of silently "updating". date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" + progress_phase service local service_ok="true" if [[ "$LAUNCH_CHOICE" == "2" ]]; then if [[ "$SERVICE_TYPE" == "launchd" ]]; then @@ -3739,6 +3844,7 @@ main() { # Security notice: last informational block so it stays visible (when not # auto-launching below; if we exec, the server prints the same notice anyway). print_security_notice + progress_phase done # Run now in foreground (must be last: exec replaces the shell) if [[ "$LAUNCH_CHOICE" == "1" ]]; then @@ -4471,6 +4577,65 @@ uninstall() { echo "" } +# json_string : as one JSON string. Control characters that JSON +# has no short form for are dropped; nothing printed here needs them. +json_string() { + local text + text=$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\010\013\014\016-\037') + text="${text//\\/\\\\}" + text="${text//\"/\\\"}" + text="${text//$'\t'/\\t}" + text="${text//$'\r'/\\r}" + text="${text//$'\n'/\\n}" + printf '"%s"' "$text" +} + +json_bool() { + if [[ "$1" == "1" ]]; then printf 'true'; else printf 'false'; fi +} + +# What is on this machine and what an install would do here, as one JSON object +# on stdout: the desktop app shows its install choices from this. Prompts for +# nothing, changes nothing, and never runs sudo beyond resolve_admin_mode's +# `sudo -n`. With --no-admin, `blocker` is why that route cannot finish here +# (empty when it can). `clis` lists the same entries as the AI CLI menu, with +# the same rule for `installable`, plus the ones already installed. +# +# `schema` is bumped only when a field changes meaning: the app is released on +# its own, and refuses a number it does not know. +preflight_subcommand() { + local os arch need_node="0" need_tmux="0" need_git="0" blocker="" i first="1" + os=$(detect_os) + arch=$(detect_arch) + check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first." + check_node || need_node="1" + check_tmux || need_tmux="1" + check_git || need_git="1" + detect_all_clis + if [[ "$NO_ADMIN" == "1" ]]; then + blocker=$(no_admin_blocker "$os" "$arch" "$need_node" "$need_tmux") + fi + + printf '{"schema":1,"os":%s,"arch":%s,"noAdmin":%s,"blocker":%s' \ + "$(json_string "$os")" "$(json_string "$arch")" "$(json_bool "$NO_ADMIN")" "$(json_string "$blocker")" + printf ',"needs":{"node":%s,"tmux":%s,"git":%s},"clis":[' \ + "$(json_bool "$need_node")" "$(json_bool "$need_tmux")" "$(json_bool "$need_git")" + for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do + [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue + [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue + local installable="0" installed="0" + if cli_offerable "$i"; then installable="1"; fi + [[ -z "${CLI_FOUND_PATH[$i]}" ]] || installed="1" + [[ "$first" == "1" ]] || printf ',' + first="0" + printf '{"id":%s,"label":%s,"installed":%s,"installable":%s,"docs":%s}' \ + "$(json_string "${CLI_IDS[$i]}")" "$(json_string "${CLI_LABELS[$i]}")" \ + "$(json_bool "$installed")" "$(json_bool "$installable")" "$(json_string "${CLI_DOCS[$i]}")" + done + printf ']}\n' + return 0 +} + usage() { cat << 'EOF' Codeman installer @@ -4487,6 +4652,8 @@ Flags --port Port Codeman listens on (default 3000) --no-admin Never use sudo or Homebrew: Node.js and tmux go into the home folder (chosen by itself without administrator rights) + --ai-cli |none Which AI CLI to install when none is found, without the menu + --progress Also print machine-readable progress lines --help, -h This text Subcommands @@ -4496,11 +4663,12 @@ Subcommands name [] Rename this machine on your tailnet (default: codeman-) status Print the URLs, the QR code and how to manage the service cloudflared Install cloudflared for the in-app Cloudflare tunnel + preflight Print what is on this machine as JSON; changes nothing Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST, CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH, -CODEMAN_NO_ADMIN=1, CODEMAN_TARBALL_URL. +CODEMAN_NO_ADMIN=1, CODEMAN_TARBALL_URL, CODEMAN_AI_CLI, CODEMAN_PROGRESS=1. EOF } @@ -4528,6 +4696,12 @@ parse_flags() { --no-start) LAUNCH_PRESET="3" ;; --yes|-y) ASSUME_YES="1" ;; --no-admin) NO_ADMIN="1" ;; + --ai-cli) + shift + [[ $# -gt 0 ]] || die "--ai-cli needs a value (a catalogue id, or none)" + AI_CLI_CHOICE="$1" ;; + --ai-cli=*) AI_CLI_CHOICE="${1#--ai-cli=}" ;; + --progress) PROGRESS="1" ;; --password) shift [[ $# -gt 0 ]] || die "--password needs a value" @@ -4539,7 +4713,7 @@ parse_flags() { CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;; --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;; --help|-h) usage; exit 0 ;; - update|uninstall|tailscale|name|status|cloudflared) + update|uninstall|tailscale|name|status|cloudflared|preflight) [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." SUBCOMMAND="$1" ;; -*) die "Unknown option: $1 (see --help)" ;; @@ -4555,6 +4729,9 @@ parse_flags() { if [[ -n "${CODEMAN_PORT:-}" ]] && ! [[ "$CODEMAN_PORT" =~ ^[0-9]+$ && "$CODEMAN_PORT" -ge 1 && "$CODEMAN_PORT" -le 65535 ]]; then die "Invalid port: $CODEMAN_PORT" fi + if [[ -n "$AI_CLI_CHOICE" && "$AI_CLI_CHOICE" != "none" ]] && ! cli_index "$AI_CLI_CHOICE" >/dev/null; then + die "Unknown AI CLI: $AI_CLI_CHOICE (use none, or one of: $(cli_catalog_ids))" + fi return 0 } @@ -4579,6 +4756,7 @@ case "$SUBCOMMAND" in name) setup_name_subcommand ;; status) status_subcommand ;; cloudflared) cloudflared_subcommand ;; + preflight) preflight_subcommand ;; *) # Only a COMPLETED install re-runs as a quiet update. A partial one # (clone succeeded but build/menu never finished) lacks the marker and diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 75eaf4fc..8ba34096 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -28,7 +28,7 @@ cannot do. | | | | --- | --- | -| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | +| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | | **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. | | **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | | **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | @@ -54,6 +54,11 @@ cargo test # unit tests open dist/Codeman.app ``` +To try a change to `install.sh` before it is merged, build with +`CODEMAN_DESKTOP_INSTALLER_URL` (where to get the installer) and +`CODEMAN_DESKTOP_INSTALL_BRANCH` (the branch it installs from) set. Both are +read at compile time: a shipped app cannot be pointed at another installer. + The script builds with `--locked` against the committed `Cargo.lock`, targets the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a @@ -133,19 +138,40 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency. - `codeman web -d` gets the port and base path as separate arguments, never as shell text. -**The installer runs in Terminal, not inside the app.** `install.sh` asks for -the administrator password on a terminal (Homebrew, its sudo keep-alive), the -Xcode tools arrive as a system dialog, and ssh asks about host keys and -passwords on a terminal too, so an install without one fails on the first -fresh Mac. The app writes a short script to the per-user temporary folder -(mode 0700, created fresh, deleted by the script as it starts) and opens it -with `open -b com.apple.Terminal`, which needs no permission; scripting -Terminal with osascript would ask for Automation access. The script prints -what it runs. A This Mac install downloads the installer to a temporary file -and runs it from there, so the installer's input is the terminal. Everything in -the scripts is built and unit-tested in `src/setup.rs`; the page sends only -which install it wants, whether other devices should reach it, and the SSH -login, which is checked before it is quoted. +**The app runs the installer itself; Terminal is the fallback.** `install.sh +--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs +no password and therefore no terminal, and the two questions only a person can +answer arrive as flags (`src/gui_install.rs`). The app downloads the installer +once to a private folder, asks it what is on this Mac (`install.sh preflight`, +which prints JSON and changes nothing) and draws its choices from that answer. +So the app has no list of coding agents of its own: a new one appears without a +new app, and the list always matches what the installer can really install +here. The installer runs with the login shell's environment, so it finds what +this person already has. The page sends only the agent's id, whether other +devices may reach Codeman, and that password; the password reaches the +installer through its environment, never its command line, where `ps` shows it. + +**The install outlives the app.** Its output goes to a file, never a pipe, and +it runs in a process group of its own. With a pipe, quitting the app would kill +the installer at its next write; in the app's group, launchd would kill it with +the app. The file holds the whole run, so a page that was reloaded, or an app +that was opened again, finds the process in `ps` and replays the file from the +first line. That is why the app keeps no state about a run. A run that ends +without the installer's last `done` line failed, and the first error it reported +is the one shown: the ones after it are consequences. + +**Terminal stays for what the app cannot do.** The route with Homebrew asks for +the administrator password on a terminal, the Xcode tools arrive as a system +dialog, and ssh asks about host keys and passwords on a terminal too. The app +writes a short script to the per-user temporary folder (mode 0700, created +fresh, deleted by the script as it starts) and opens it with `open -b +com.apple.Terminal`, which needs no permission; scripting Terminal with +osascript would ask for Automation access. The script prints what it runs. A +This Mac install downloads the installer to a temporary file and runs it from +there, so the installer's input is the terminal. Everything in the scripts is +built and unit-tested in `src/setup.rs`; the page sends only which install it +wants, whether other devices should reach it, and the SSH login, which is +checked before it is quoted. **This Mac's own login comes from where Codeman keeps it.** A Codeman installed with a password keeps it in its LaunchAgent (`install.sh`) or in @@ -249,6 +275,20 @@ and the Dock icon bounces. - **No desktop notifications yet.** WKWebView has no Web Notification API. Native notifications are the first item on the list below. - **No service worker.** Codeman works without it; it is only an offline cache. +- **The graphical install needs macOS 15, or a tmux that is already there.** + tmux comes from the tmux project's own builds, which need macOS 15. On an + older Mac without tmux the app says so and offers Terminal, where Homebrew + installs it (that takes an administrator once). +- **Who can reach it stops at the local network.** Tailscale needs its own + login and setup, which the installer does at a terminal. Choose *Install in + Terminal instead*, or run `install.sh tailscale` later. +- **The agent is chosen during the install.** With Codeman already installed, + or after *Skip for now*, an agent is added by hand: its commands are in the + installer's closing notes and in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli). +- **An agent only your interactive profile finds counts as missing.** The app + reads the login shell's environment, without `.zshrc`, so a CLI on an nvm + PATH set up there is offered again as if it were not installed. Installing + it again is harmless; *Skip for now* leaves it as it is. - **Installing from the app is macOS only so far**, and installing on another machine needs ssh access to it and `curl` or `wget` there. The app does not know when a remote install is done or which address it chose: the installer diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index 972c7e2f..cd2457a2 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -25,10 +25,12 @@ background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; - display: flex; align-items: center; justify-content: center; + display: flex; justify-content: center; -webkit-user-select: none; user-select: none; } - main { width: min(440px, calc(100vw - 48px)); } + /* margin:auto, not align-items:center: a flex child taller than the window + then starts at the top instead of being cut off above it. */ + main { width: min(440px, calc(100vw - 48px)); margin: auto; padding: 24px 0; } header { text-align: center; margin-bottom: 26px; } header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; } h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; } @@ -102,7 +104,6 @@ @media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } } #local-title { font-weight: 600; } .sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; } - #local-network-row[hidden] { display: none; } #elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); } #elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; } #elsewhere summary::-webkit-details-marker { display: none; } @@ -118,6 +119,81 @@ .statusrow { display: flex; gap: 10px; align-items: flex-start; } .statusrow #status { flex: 1; } .statusrow #cancel { margin-top: 10px; } + /* The graphical install takes over the card, and its steps differ in height: + anchored from the top, so the logo does not jump from one to the next. */ + main:has(.card.wizard-on) { margin-top: max(0px, calc(14vh - 60px)); margin-bottom: auto; } + main:has(.card.wizard-on) header p { display: none; } + .card.wizard-on > .tabs, .card.wizard-on > #pane-local, .card.wizard-on > #form, + .card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > .prefs { display: none; } + #wizard[hidden], #wizard [hidden] { display: none; } + .wiz-head { display: flex; align-items: center; gap: 9px; } + .wiz-head .spinner { margin-top: 0; } + #wiz-title { font-size: 17px; font-weight: 650; letter-spacing: -0.01em; margin: 0; } + #wiz-title:focus { outline: none; } + #wiz-lead { margin: 6px 0 0; color: var(--muted); font-size: 13px; } + #wiz-message { + margin: 12px 0 0; padding: 10px 12px; border: 1px solid rgba(248,113,113,0.4); border-radius: 9px; + background: rgba(248,113,113,0.08); color: var(--bad); font-size: 13px; white-space: pre-wrap; + overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; + } + #wiz-message.warn { border-color: rgba(251,191,36,0.4); background: rgba(251,191,36,0.08); color: var(--warn); } + .wiz-group { margin-top: 14px; } + #wiz-agents { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } + .choice { + display: flex; gap: 9px; align-items: flex-start; padding: 9px 11px; border-radius: 9px; + border: 1px solid var(--line-strong); background: #0f1013; cursor: default; + } + .choice:hover { border-color: #454a55; } + .choice:has(input:checked) { border-color: var(--accent-2); background: rgba(59,130,246,0.1); } + .choice:has(input:focus-visible) { outline: 2px solid var(--accent-2); outline-offset: 1px; } + .choice input { accent-color: var(--accent-2); margin: 2px 0 0; flex: none; } + .choice b { display: block; font-weight: 600; } + .choice small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; } + #wiz-reach .choice + .choice { margin-top: 6px; } + #wiz-password { margin-top: 10px; } + #wiz-agent-note { margin: 10px 0 0; color: var(--muted); font-size: 12px; } + #wiz-agent-note a, .wiz-link { all: unset; color: #7c8491; cursor: default; } + #wiz-agent-note a { color: var(--accent); white-space: nowrap; } + #wiz-agent-note a:hover, .wiz-link:hover { color: var(--text); } + .wiz-link:focus-visible, #wiz-agent-note a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; border-radius: 3px; } + .wiz-link { display: block; margin: 12px auto 0; font-size: 12px; text-align: center; } + #wiz-summary { margin: 14px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); list-style: none; color: var(--muted); font-size: 12px; } + #wiz-summary li { padding: 2px 0 2px 14px; position: relative; } + #wiz-summary li::before { content: ''; position: absolute; left: 2px; top: 9px; width: 4px; height: 4px; border-radius: 50%; background: #5d626b; } + #wiz-summary li.warn { color: var(--warn); } + /* The install's rail: one node per phase, joined by a line that fills in as + the phases finish. The running node carries the phase's own clock, because + the longest phases print nothing while they work. */ + #wiz-rail { margin: 16px 0 0; padding: 0; list-style: none; } + #wiz-rail li { position: relative; display: flex; align-items: baseline; gap: 10px; padding: 0 0 13px 24px; color: #6b717c; } + #wiz-rail li:last-child { padding-bottom: 0; } + #wiz-rail li::before { + content: ''; position: absolute; left: 4px; top: 6px; width: 8px; height: 8px; box-sizing: content-box; + border-radius: 50%; background: var(--panel); border: 1px solid #454a55; z-index: 1; + } + #wiz-rail li::after { content: ''; position: absolute; left: 8.5px; top: 12px; bottom: -6px; width: 1px; background: var(--line-strong); } + #wiz-rail li:last-child::after { display: none; } + #wiz-rail li.done { color: var(--text); } + #wiz-rail li.done::before { background: var(--ok); border-color: var(--ok); } + #wiz-rail li.done::after { background: rgba(74,222,128,0.45); } + #wiz-rail li.now { color: var(--text); font-weight: 600; } + #wiz-rail li.now::before { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px rgba(96,165,250,0.18); animation: pulse 1.2s ease-in-out infinite; } + #wiz-rail li.failed { color: var(--bad); font-weight: 600; } + #wiz-rail li.failed::before { background: var(--bad); border-color: var(--bad); } + @media (prefers-reduced-motion: reduce) { #wiz-rail li.now::before { animation: none; } } + #wiz-rail .clock { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 400; font-variant-numeric: tabular-nums; } + #wiz-details { margin-top: 14px; } + #wiz-details summary { color: var(--muted); font-size: 12px; cursor: default; list-style: none; } + #wiz-details summary::-webkit-details-marker { display: none; } + #wiz-details summary::before { content: '\25B8'; display: inline-block; width: 13px; transition: transform 0.15s; } + #wiz-details[open] summary::before { transform: rotate(90deg); } + #wiz-details summary:hover { color: var(--text); } + #wiz-log { + margin: 8px 0 0; height: 124px; overflow: auto; padding: 8px 10px; border-radius: 8px; background: #0c0d10; + border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; + } + #wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; } footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; } footer a { color: #7c8491; text-decoration: none; cursor: default; } footer a:hover { color: var(--accent); } @@ -138,6 +214,45 @@ + +
@@ -148,7 +263,6 @@
-
@@ -236,7 +350,13 @@ auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'), tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'), localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'), - localNetworkRow: $('local-network-row'), localNetwork: $('local-network'), + card: document.querySelector('.card'), + wizard: $('wizard'), wizSpinner: $('wiz-spinner'), wizTitle: $('wiz-title'), wizLead: $('wiz-lead'), wizMessage: $('wiz-message'), + wizAgentStep: $('wiz-agent-step'), wizAgents: $('wiz-agents'), wizAgentNote: $('wiz-agent-note'), + wizReachStep: $('wiz-reach-step'), wizPassword: $('wiz-password'), wizPw: $('wiz-pw'), wizPw2: $('wiz-pw2'), + wizSummary: $('wiz-summary'), wizRail: $('wiz-rail'), wizDetails: $('wiz-details'), wizLog: $('wiz-log'), + wizBack: $('wiz-back'), wizSkip: $('wiz-skip'), wizNext: $('wiz-next'), wizCancel: $('wiz-cancel'), + wizTerminal: $('wiz-terminal'), localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'), localStop: $('local-stop'), localCheck: $('local-check'), elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy') @@ -247,17 +367,21 @@ // suggestion: the address the suggest button connects to. // tab: 'local' (This Mac) or 'remote' (Another machine). local: the last // This Mac status from the app. wait: set while an install runs in - // Terminal and this page polls for the server it brings up. + // Terminal and this page polls for the server it brings up. gui: the + // graphical install while its screens are up (newWizard), null otherwise. var state = { servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '', tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0, - stoppedWaiting: false + stoppedWaiting: false, gui: null }; // While an install runs: how often to look, how long a finished install may // take to answer before Start is offered, and when to stop looking. var WAIT_POLL_MS = 3000; var WAIT_START_GRACE_MS = 90 * 1000; var WAIT_MAX_MS = 30 * 60 * 1000; + // The phases `install.sh --progress` reports, in its order. + var PHASES = ['runtime', 'agent', 'source', 'packages', 'build', 'service']; + var MAX_LOG_LINES = 600; function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } } @@ -541,7 +665,7 @@ function renderLocal() { var s = state.local; - var show = { open: false, start: false, install: false, stop: false, check: false, network: false }; + var show = { open: false, start: false, install: false, stop: false, check: false }; var dot = ''; var title = ''; var detail = ''; @@ -589,19 +713,17 @@ show.check = true; } else if (s.state === 'notInstalled') { title = 'Codeman is not installed on this Mac.'; - detail = 'Install runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password. ' + + detail = 'Install sets it up here in a few minutes, with no password and no Terminal. ' + 'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.'; show.install = true; - show.network = true; show.check = true; } else if (!s.installed) { // Something else holds Codeman's port; a fresh install takes the next free one. var other = s.outcome || {}; title = 'Codeman is not installed on this Mac.'; detail = 'Something else answers at ' + s.url + (other.kind === 'notCodeman' ? ' (HTTP ' + other.status + ')' : '') + - ', so Install sets Codeman up on the next free port. It runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password.'; + ', so Install sets Codeman up on the next free port: a few minutes, with no password and no Terminal.'; show.install = true; - show.network = true; show.check = true; } else { var o = s.outcome || {}; @@ -624,16 +746,24 @@ els.localInstall.hidden = !show.install; els.localStop.hidden = !show.stop; els.localCheck.hidden = !show.check; - els.localNetworkRow.hidden = !show.network; syncLocalButtons(); } function onLocalStatus(s) { state.localChecking = false; state.local = s || null; + // The graphical install is running without this page (it was reloaded, or + // the app was quit and opened again): show it from its first line. + if (s && s.installInApp && !state.gui) { + state.gui = newWizard('installing'); + state.gui.port = s.installPort || 3000; + post({ cmd: 'installAttach' }); + renderWizard(); + return; + } // An install from this app is still running (this page was shown again // while it ran): wait for it, unless the user stopped waiting. - if (!state.wait && s && s.installRunning && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') { + if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') { state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 }; renderLocal(); scheduleWait(); @@ -670,12 +800,402 @@ post({ cmd: 'startServer', url: state.startUrl }); } - function installLocal() { + // The Terminal route: what the graphical install cannot do, and what anyone + // may prefer. The installer asks its own questions there. + function installInTerminal() { if (state.busy || tooOld || state.opening) return; + var network = !!state.gui && state.gui.reach === 'network'; + closeWizard(); + selectTab('local'); state.opening = true; syncLocalButtons(); setStatus('busy', 'Opening Terminal…'); - post({ cmd: 'installLocal', network: els.localNetwork.checked }); + post({ cmd: 'installLocal', network: network }); + } + + function newWizard(step) { + return { + step: step, plan: null, agent: '', reach: 'local', port: 3000, message: '', phase: '', + phaseSince: 0, seen: {}, log: [], ok: false + }; + } + + function openWizard() { + if (state.busy || tooOld || state.opening) return; + state.gui = newWizard('preparing'); + post({ cmd: 'installPrepare' }); + renderWizard(); + } + + function closeWizard() { + clearInterval(state.clock); + state.gui = null; + renderWizard(); + renderLocal(); + } + + function retryWizard() { + var reach = state.gui ? state.gui.reach : 'local'; + var agent = state.gui ? state.gui.agent : ''; + state.gui = newWizard('preparing'); + state.gui.reach = reach; + state.gui.agent = agent; + post({ cmd: 'installPrepare' }); + renderWizard(); + } + + function installedAgents() { + var g = state.gui; + return g && g.plan ? g.plan.clis.filter(function (c) { return c.installed; }) : []; + } + + function offeredAgents() { + var g = state.gui; + return g && g.plan ? g.plan.clis.filter(function (c) { return c.installable; }) : []; + } + + function agentById(id) { + return offeredAgents().filter(function (c) { return c.id === id; })[0] || null; + } + + // The first step the installer's answer calls for. The agent step exists + // only when this Mac has no coding agent at all. + function onInstallPlan(plan) { + var g = state.gui; + if (!g || g.step !== 'preparing') return; + g.plan = plan; + if (plan.blocker) { + g.step = 'blocked'; + } else if (!installedAgents().length && offeredAgents().length) { + // Kept across Try again; otherwise the first of the catalogue's order. + if (!agentById(g.agent)) g.agent = offeredAgents()[0].id; + g.step = 'agent'; + } else { + g.step = 'reach'; + } + renderWizard(); + } + + function renderAgents() { + var g = state.gui; + els.wizAgents.textContent = ''; + offeredAgents().forEach(function (c) { + var label = document.createElement('label'); + label.className = 'choice'; + var input = document.createElement('input'); + input.type = 'radio'; + input.name = 'agent'; + input.value = c.id; + input.checked = c.id === g.agent; + input.addEventListener('change', function () { + g.agent = c.id; + renderAgentNote(); + }); + var name = document.createElement('b'); + name.textContent = c.label; + var text = document.createElement('span'); + text.appendChild(name); + label.appendChild(input); + label.appendChild(text); + els.wizAgents.appendChild(label); + }); + renderAgentNote(); + } + + function renderAgentNote() { + var c = agentById(state.gui.agent); + els.wizAgentNote.textContent = ''; + if (!c) return; + els.wizAgentNote.appendChild(document.createTextNode('You sign in to ' + c.label + ' the first time you start it. ')); + if (/^https:\/\//.test(c.docs)) { + var a = document.createElement('a'); + a.href = '#'; + a.textContent = 'About ' + c.label; + a.addEventListener('click', function (ev) { + ev.preventDefault(); + post({ cmd: 'openExternal', url: c.docs }); + }); + els.wizAgentNote.appendChild(a); + } + } + + function renderSummary() { + var g = state.gui; + var plan = g.plan; + var lines = [{ text: 'Installs into your home folder (about 1 GB), with no administrator password' }]; + var needs = []; + if (plan.needs.node) needs.push('Node.js'); + if (plan.needs.tmux) needs.push('tmux'); + if (needs.length) lines.push({ text: 'Downloads ' + needs.join(' and ') }); + var found = installedAgents(); + var chosen = agentById(g.agent); + if (found.length) { + lines.push({ text: 'Uses ' + found.map(function (c) { return c.label; }).join(', ') + ', already on this Mac' }); + } else if (chosen) { + lines.push({ text: 'Installs ' + chosen.label }); + } else { + lines.push({ text: 'No coding agent yet: Codeman opens, but sessions need one to run', warn: true }); + } + lines.push({ text: 'Runs in the background and starts when you log in' }); + if (plan.needs.git) lines.push({ text: 'This Mac has no git, so you update Codeman by running this install again' }); + els.wizSummary.textContent = ''; + lines.forEach(function (line) { + var li = document.createElement('li'); + li.textContent = line.text; + if (line.warn) li.className = 'warn'; + els.wizSummary.appendChild(li); + }); + } + + // Which rows the rail has. A run this page did not start has no plan, so it + // shows what the installer reported instead. + function railRows() { + var g = state.gui; + var plan = g.plan; + var chosen = agentById(g.agent); + var rows = []; + PHASES.forEach(function (id) { + var label = { + runtime: 'Getting Node.js and tmux', + agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'), + source: 'Downloading Codeman', + packages: 'Installing packages', + build: 'Building Codeman', + service: 'Starting Codeman' + }[id]; + if (id === 'runtime' && plan) { + if (!plan.needs.node && !plan.needs.tmux) return; + if (!plan.needs.tmux) label = 'Getting Node.js'; + if (!plan.needs.node) label = 'Getting tmux'; + } + if (id === 'agent' && (plan ? !chosen : !g.seen.agent)) return; + rows.push({ id: id, label: label }); + }); + return rows; + } + + function clockText(since) { + var s = Math.max(0, Math.floor((Date.now() - since) / 1000)); + return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); + } + + function renderRail() { + var g = state.gui; + var current = PHASES.indexOf(g.phase); + els.wizRail.textContent = ''; + railRows().forEach(function (row) { + var at = PHASES.indexOf(row.id); + var li = document.createElement('li'); + var name = document.createElement('span'); + name.textContent = row.label; + li.appendChild(name); + if (g.ok || at < current) { + li.className = 'done'; + } else if (at === current && g.step === 'failed') { + li.className = 'failed'; + } else if (at === current) { + li.className = 'now'; + li.setAttribute('aria-current', 'step'); + var clock = document.createElement('span'); + clock.className = 'clock'; + clock.id = 'wiz-clock'; + clock.textContent = clockText(g.phaseSince); + li.appendChild(clock); + } + els.wizRail.appendChild(li); + }); + } + + function renderLog() { + var log = els.wizLog; + var atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 8; + log.textContent = state.gui.log.join('\n'); + if (atEnd) log.scrollTop = log.scrollHeight; + } + + function appendLog(lines) { + var g = state.gui; + if (!lines || !lines.length) return; + g.log = g.log.concat(lines).slice(-MAX_LOG_LINES); + renderLog(); + } + + function setPhase(phase) { + var g = state.gui; + if (!phase || phase === g.phase || PHASES.indexOf(phase) < 0) return; + g.phase = phase; + g.phaseSince = Date.now(); + g.seen[phase] = true; + renderRail(); + } + + function onInstallUpdate(msg) { + var g = state.gui; + if (!g || g.step !== 'installing') return; + setPhase(msg.phase); + appendLog(msg.log); + } + + function onInstallFinished(msg) { + var g = state.gui; + if (!g) return; + appendLog(msg.log); + clearInterval(state.clock); + if (msg.ok) { + g.ok = true; + g.step = 'finishing'; + renderWizard(); + // The same watch as a Terminal install: it opens Codeman at the first + // answer, and offers Start when none comes. + state.wait = { since: Date.now(), finishedAt: 0, port: g.port }; + checkLocal('quick'); + return; + } + if (msg.cancelled) { + closeWizard(); + checkLocal('login'); + setStatus('', 'Install cancelled.', 'Install again to pick up where it stopped.'); + return; + } + g.step = 'failed'; + g.message = msg.error || 'The installer stopped without saying why.'; + renderWizard(); + els.wizDetails.open = true; + renderLog(); + } + + // Each screen of the graphical install, drawn from state.gui alone. + function renderWizard() { + var g = state.gui; + els.card.classList.toggle('wizard-on', !!g); + els.wizard.hidden = !g; + if (!g) return; + var step = g.step; + var copy = { + preparing: ['Looking at this Mac', 'Getting the installer and checking what is already here.'], + blocked: ['This Mac needs something first', ''], + prepareFailed: ['Could not get the installer', ''], + agent: ['Choose a coding agent', 'Codeman runs coding agents, and this Mac has none yet. Pick one to install with Codeman. You can add more later.'], + reach: ['Who can reach Codeman?', ''], + installing: ['Installing Codeman', 'You can close this window. The install keeps going, and the app picks it up when you open it again.'], + finishing: ['Starting Codeman', 'Codeman opens here as soon as it answers.'], + failed: ['The install stopped', 'Its full log is in ~/.codeman/install.log. Try again picks up where it stopped.'] + }[step]; + els.wizSpinner.hidden = !(step === 'preparing' || step === 'finishing'); + els.wizTitle.textContent = copy[0]; + els.wizLead.textContent = copy[1]; + els.wizLead.hidden = !copy[1]; + + var message = step === 'blocked' ? g.plan.blocker : (step === 'prepareFailed' || step === 'failed') ? g.message : ''; + els.wizMessage.textContent = message; + els.wizMessage.hidden = !message; + els.wizMessage.className = step === 'blocked' ? 'warn' : ''; + + els.wizAgentStep.hidden = step !== 'agent'; + els.wizReachStep.hidden = step !== 'reach'; + var running = step === 'installing' || step === 'finishing' || step === 'failed'; + els.wizRail.hidden = !running; + els.wizDetails.hidden = !running; + + if (step === 'agent') renderAgents(); + if (step === 'reach') { + renderSummary(); + syncReach(); + } + if (running) renderRail(); + clearInterval(state.clock); + if (step === 'installing') { + state.clock = setInterval(function () { + var clock = $('wiz-clock'); + if (clock) clock.textContent = clockText(state.gui.phaseSince); + }, 1000); + } + + var next = { + agent: 'Continue', reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal' + }[step]; + els.wizNext.hidden = !next; + els.wizNext.textContent = next || ''; + // Not while preparing: a second prepare would clear the folder of the first. + els.wizBack.hidden = !(step === 'blocked' || step === 'prepareFailed' || step === 'agent' || step === 'reach'); + els.wizSkip.hidden = step !== 'agent'; + els.wizCancel.hidden = step !== 'installing'; + els.wizCancel.disabled = false; + els.wizTerminal.hidden = !(step === 'reach' || step === 'failed' || step === 'prepareFailed'); + + if (step === 'agent') { + var checked = els.wizAgents.querySelector('input:checked'); + if (checked) checked.focus(); + } else if (step === 'reach' || step === 'blocked' || step === 'failed' || step === 'prepareFailed') { + els.wizNext.focus(); + } else { + els.wizTitle.focus(); + } + } + + function syncReach() { + var g = state.gui; + var network = g.reach === 'network'; + els.wizPassword.hidden = !network; + Array.prototype.forEach.call(document.getElementsByName('reach'), function (r) { r.checked = r.value === g.reach; }); + } + + function startInstall() { + var g = state.gui; + var password = ''; + if (g.reach === 'network') { + password = els.wizPw.value; + if (!password) { + els.wizMessage.textContent = 'Set a password for the other devices to sign in with.'; + els.wizMessage.hidden = false; + els.wizPw.focus(); + return; + } + if (password !== els.wizPw2.value) { + els.wizMessage.textContent = 'The two passwords are different.'; + els.wizMessage.hidden = false; + els.wizPw2.focus(); + return; + } + } + var msg = { cmd: 'installStart' }; + if (password) msg.networkPassword = password; + if (!installedAgents().length && agentById(g.agent)) msg.aiCli = g.agent; + els.wizPw.value = ''; + els.wizPw2.value = ''; + g.step = 'installing'; + g.phase = ''; + g.seen = {}; + g.log = []; + renderLog(); + renderWizard(); + post(msg); + } + + function wizardNext() { + var g = state.gui; + if (!g) return; + if (g.step === 'agent') { + g.step = 'reach'; + renderWizard(); + } else if (g.step === 'reach') { + startInstall(); + } else if (g.step === 'failed' || g.step === 'prepareFailed') { + retryWizard(); + } else if (g.step === 'blocked') { + installInTerminal(); + } + } + + function wizardBack() { + var g = state.gui; + if (!g) return; + if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) { + g.step = 'agent'; + renderWizard(); + } else { + closeWizard(); + } } function scheduleWait() { @@ -686,6 +1206,7 @@ function endWait() { clearTimeout(state.waitTimer); state.wait = null; + if (state.gui && state.gui.step === 'finishing') closeWizard(); } // One answer while an install runs. It stops at the first answer from a @@ -822,6 +1343,32 @@ renderLocal(); scheduleWait(); break; + case 'installPlan': + onInstallPlan(msg.plan); + break; + case 'installPrepareFailed': + if (state.gui && state.gui.step === 'preparing') { + state.gui.step = 'prepareFailed'; + state.gui.message = msg.message || 'Could not get the installer.'; + renderWizard(); + } + break; + case 'installRunning': + if (state.gui) state.gui.port = Number(msg.port) || 3000; + break; + case 'installStartFailed': + if (state.gui) { + state.gui.step = 'failed'; + state.gui.message = msg.message || 'Could not start the installer.'; + renderWizard(); + } + break; + case 'installUpdate': + onInstallUpdate(msg); + break; + case 'installFinished': + onInstallFinished(msg); + break; case 'remoteInstallStarted': state.opening = false; syncLocalButtons(); @@ -875,7 +1422,28 @@ }); els.localOpen.addEventListener('click', openLocal); els.localStart.addEventListener('click', startLocal); - els.localInstall.addEventListener('click', installLocal); + els.localInstall.addEventListener('click', openWizard); + els.wizNext.addEventListener('click', wizardNext); + els.wizBack.addEventListener('click', wizardBack); + els.wizSkip.addEventListener('click', function () { + if (!state.gui) return; + state.gui.agent = ''; + state.gui.step = 'reach'; + renderWizard(); + }); + els.wizCancel.addEventListener('click', function () { + els.wizCancel.disabled = true; + post({ cmd: 'installCancel' }); + }); + els.wizTerminal.addEventListener('click', installInTerminal); + Array.prototype.forEach.call(document.getElementsByName('reach'), function (radio) { + radio.addEventListener('change', function () { + if (!state.gui) return; + state.gui.reach = radio.value; + els.wizMessage.hidden = true; + syncReach(); + }); + }); els.localStop.addEventListener('click', function () { state.stoppedWaiting = true; endWait(); checkLocal('login'); }); els.localCheck.addEventListener('click', function () { if (!state.busy && !state.localChecking) checkLocal('deep'); }); els.sshInstall.addEventListener('click', installRemote); diff --git a/packages/desktop/src/gui_install.rs b/packages/desktop/src/gui_install.rs new file mode 100644 index 00000000..9363d92e --- /dev/null +++ b/packages/desktop/src/gui_install.rs @@ -0,0 +1,855 @@ +//! The graphical install: the app runs Codeman's installer itself and the +//! connect page is its interface, so a fresh Mac needs neither Terminal nor an +//! administrator password. +//! +//! `install.sh --no-admin` is what makes that possible: it puts Node.js and +//! tmux in the home folder, so nothing in it asks for a password, and the two +//! questions only a person can answer (which coding agent, who may reach +//! Codeman) are asked by the page and passed as flags. The Terminal route in +//! `setup.rs` stays for what this one cannot do, a Mac older than macOS 15 with +//! no tmux first of all. +//! +//! `prepare` downloads the installer once into a private folder and asks it +//! what is on this Mac (`install.sh preflight`); `start` runs the same file with +//! every answer given; `follow` reports what it prints. +//! +//! **Its output goes to a file, never a pipe.** With a pipe, quitting the app +//! would kill the installer at its next write, half way through an install. The +//! file also holds the whole run, so `follow` can start over from the top: a +//! connect page that was reloaded, or an app that was quit and opened again, +//! finds the running install in `ps` and replays it. That is why this module +//! keeps no state about a run but the file and the process. + +use std::fs::{self, DirBuilder, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom}; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::local_server; +use crate::setup; + +/// The folder under the per-user temporary folder that holds a run. A fixed +/// name, so an app that was opened again finds it. +const RUN_DIR: &str = "codeman-install"; +const SCRIPT_FILE: &str = "install.sh"; +const OUTPUT_FILE: &str = "install.out"; + +/// The `install.sh preflight` schema this app understands. +const PREFLIGHT_SCHEMA: u32 = 1; + +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); +const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(60); + +/// How often `follow` looks at the output file and the process. +const POLL: Duration = Duration::from_millis(500); + +/// How long the installer gets to end after Cancel before it is killed. +const CANCEL_GRACE: Duration = Duration::from_secs(5); + +/// One line of output, for the page's details view. +const MAX_LOG_LINE: usize = 500; + +/// When the login shell's PATH cannot be read. +const FALLBACK_PATH: &str = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; + +/// Lines of the installer's output protocol (`progress_phase` in install.sh). +const PROTOCOL_PREFIX: &str = "@@codeman "; + +/// Set by Cancel, so `follow` can tell a cancelled run from a failed one. +static CANCELLED: AtomicBool = AtomicBool::new(false); + +/// What `install.sh preflight` found. Fields the installer adds later are +/// ignored here, and `schema` changes when one changes meaning. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Preflight { + pub schema: u32, + pub os: String, + pub arch: String, + /// Why `--no-admin` cannot finish on this Mac; empty when it can. + pub blocker: String, + pub needs: Needs, + pub clis: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Needs { + pub node: bool, + pub tmux: bool, + pub git: bool, +} + +/// One coding agent of the installer's catalogue. The app has no list of its +/// own, so a new agent appears here without a new app. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CliEntry { + pub id: String, + pub label: String, + pub installed: bool, + pub installable: bool, + pub docs: String, +} + +/// What the person chose on the connect page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Plan { + pub port: u16, + /// Reachable from other devices, with this login password. `None`: this + /// Mac only. + pub network_password: Option, + /// The catalogue id of the agent to install. `None`: none. + pub ai_cli: Option, +} + +/// `install.sh`'s flags for a graphical install. The password is not among +/// them: anything here can be read in `ps`. Always `--service`, like the +/// Terminal route: a LaunchAgent runs Codeman, so it keeps running when the app +/// quits. Always `--ai-cli`: without it `CODEMAN_NONINTERACTIVE` would install +/// the first agent of the catalogue by itself. +pub fn install_args(plan: &Plan) -> Vec { + let mut args: Vec = ["--no-admin", "--progress", "--service"] + .iter() + .map(|s| s.to_string()) + .collect(); + args.push( + if plan.network_password.is_some() { + "--lan" + } else { + "--local" + } + .to_string(), + ); + args.push("--port".to_string()); + args.push(plan.port.to_string()); + args.push("--ai-cli".to_string()); + args.push(plan.ai_cli.clone().unwrap_or_else(|| "none".to_string())); + args +} + +/// What a graphical install adds to the environment. There is no terminal, so +/// `CODEMAN_NONINTERACTIVE` is what lets `headless_guard` go ahead. +pub fn install_env(plan: &Plan) -> Vec<(String, String)> { + let mut env = vec![("CODEMAN_NONINTERACTIVE".to_string(), "1".to_string())]; + if let Some(password) = &plan.network_password { + env.push(("CODEMAN_PASSWORD".to_string(), password.clone())); + } + if let Some(branch) = setup::INSTALL_BRANCH { + env.push(("CODEMAN_BRANCH".to_string(), branch.to_string())); + } + env +} + +/// Read what `install.sh preflight` printed. An installer without that +/// subcommand ends before this; one that speaks another schema stops here. +pub fn parse_preflight(stdout: &str) -> Result { + let found: Preflight = serde_json::from_str(stdout.trim()) + .map_err(|_| "The installer gave an answer this app cannot read.".to_string())?; + if found.schema != PREFLIGHT_SCHEMA { + return Err(format!( + "This installer speaks a newer language than this app (schema {}). Update Codeman Desktop, or install in Terminal.", + found.schema + )); + } + Ok(found) +} + +/// Whether a command line is an install this module started. `preflight` from +/// the same file is not one: it has no `--progress`. +pub fn is_install_run(command: &str) -> bool { + let suffix = format!("/{RUN_DIR}/{SCRIPT_FILE}"); + let mut words = command.split_whitespace(); + words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress") +} + +fn run_dir() -> PathBuf { + std::env::temp_dir().join(RUN_DIR) +} + +/// A fresh folder for a run, for this user only. The per-user temporary folder +/// of macOS is private already; the mode covers a `TMPDIR` that is not. +fn fresh_run_dir() -> Result { + let dir = run_dir(); + match fs::remove_dir_all(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("Could not clear {}: {e}", dir.display())), + } + DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(|e| format!("Could not create {}: {e}", dir.display()))?; + Ok(dir) +} + +/// The login shell's environment, so the installer finds the Node.js, git and +/// agents this person already has, wherever their profile puts them on PATH. A +/// double-clicked app gets launchd's bare PATH instead (see local_server.rs). +fn installer_env() -> Vec<(String, String)> { + with_path(local_server::login_environment().unwrap_or_default()) +} + +fn with_path(mut env: Vec<(String, String)>) -> Vec<(String, String)> { + if !env.iter().any(|(k, _)| k == "PATH") { + env.push(("PATH".to_string(), FALLBACK_PATH.to_string())); + } + env +} + +fn home() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) +} + +fn download_installer(script: &Path) -> Result<(), String> { + let mut cmd = Command::new("/usr/bin/curl"); + cmd.args(["-fsSL", "--connect-timeout", "15", "-o"]) + .arg(script) + .arg(setup::INSTALLER_URL); + let out = local_server::exec_bounded( + cmd, + Instant::now() + DOWNLOAD_TIMEOUT, + "Downloading the installer took too long, so it was stopped.", + )?; + if out.code == Some(0) && script.is_file() { + return Ok(()); + } + let said = out.stderr.trim(); + Err(if said.is_empty() { + "Could not download the installer. Check the internet connection, then try again.".to_string() + } else { + format!("Could not download the installer. Check the internet connection, then try again.\n{said}") + }) +} + +fn ask_installer(script: &Path, env: &[(String, String)]) -> Result { + let mut cmd = Command::new("/bin/bash"); + cmd.arg(script) + .args(["preflight", "--no-admin"]) + .envs(env.iter().map(|(k, v)| (k, v))) + .current_dir(home()); + let out = local_server::exec_bounded( + cmd, + Instant::now() + PREFLIGHT_TIMEOUT, + "The installer took too long to look at this Mac, so it was stopped.", + )?; + if out.code != Some(0) { + return Err("This installer cannot run from the app yet. Install in Terminal instead.".to_string()); + } + parse_preflight(&out.stdout) +} + +/// Get the installer and ask it what is on this Mac. +pub fn prepare() -> Result { + if let Some(why) = refuse_start() { + return Err(why); + } + fetch_and_ask() +} + +fn fetch_and_ask() -> Result { + let dir = fresh_run_dir()?; + let script = dir.join(SCRIPT_FILE); + download_installer(&script)?; + ask_installer(&script, &installer_env()) +} + +/// Why no install may start now: one runs already, or a Codeman server does and +/// the installer's service step would replace it under its sessions. +fn refuse_start() -> Option { + if setup::install_in_app().is_some() { + return Some("Codeman is already being installed.".to_string()); + } + setup::already_running() +} + +/// Run the installer `prepare` fetched. It outlives the app (see the module +/// docs): when an app's job ends, launchd kills what is left in that job's +/// process group, so the installer gets a group of its own. A thread reaps it +/// if it ends first. +pub fn start(plan: &Plan) -> Result<(), String> { + if let Some(why) = refuse_start() { + return Err(why); + } + spawn_run(plan) +} + +fn spawn_run(plan: &Plan) -> Result<(), String> { + let dir = run_dir(); + let script = dir.join(SCRIPT_FILE); + if !script.is_file() { + return Err("The installer is gone from the temporary folder. Try again.".to_string()); + } + let output = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(dir.join(OUTPUT_FILE)) + .map_err(|e| format!("Could not start the installer: {e}"))?; + let stderr = output + .try_clone() + .map_err(|e| format!("Could not start the installer: {e}"))?; + CANCELLED.store(false, Ordering::SeqCst); + let mut child = Command::new("/bin/bash") + .arg(&script) + .args(install_args(plan)) + .envs(installer_env()) + .envs(install_env(plan)) + .current_dir(home()) + .stdin(Stdio::null()) + .stdout(output) + .stderr(stderr) + .process_group(0) + .spawn() + .map_err(|e| format!("Could not start the installer: {e}"))?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +/// The process group of `pid`. `setup::install_in_app` may answer with one of +/// the installer's subshells, which `ps` shows with the installer's own command +/// line, so its pid is not always the group's. +fn group_of(pid: u32) -> Option { + let out = Command::new("/bin/ps") + .args(["-o", "pgid=", "-p", &pid.to_string()]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .ok() + .filter(|g| *g > 1) +} + +fn signal_group(group: u32, signal: &str) { + let _ = Command::new("/bin/kill") + .arg(format!("-{signal}")) + .arg("--") + .arg(format!("-{group}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Stop the running install and whatever it started. What it left behind is +/// picked up by the next run: `install.sh` re-runs a partial install in full. +pub fn cancel() { + let Some(group) = setup::install_in_app().and_then(group_of) else { + return; + }; + CANCELLED.store(true, Ordering::SeqCst); + signal_group(group, "TERM"); + let deadline = Instant::now() + CANCEL_GRACE; + while Instant::now() < deadline { + if setup::install_in_app().is_none() { + return; + } + std::thread::sleep(Duration::from_millis(200)); + } + signal_group(group, "KILL"); +} + +/// Drop terminal colour and cursor sequences. +fn strip_ansi(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + if chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + } + out +} + +/// One line of the installer's output. +#[derive(Debug, PartialEq, Eq)] +enum Line { + Phase(String), + Error(String), + Text(String), +} + +fn parse_line(raw: &str) -> Line { + let line = strip_ansi(raw.trim_end_matches('\r')); + if let Some(rest) = line.strip_prefix(PROTOCOL_PREFIX) { + let (word, text) = rest.split_once(' ').unwrap_or((rest, "")); + match word { + "phase" if !text.is_empty() && text.chars().all(|c| c.is_ascii_lowercase()) => { + return Line::Phase(text.to_string()) + } + "error" if !text.is_empty() => return Line::Error(text.to_string()), + _ => {} + } + } + Line::Text(line) +} + +/// What a run has said so far. +#[derive(Debug, Default)] +pub struct Progress { + partial: Vec, + phase: Option, + /// The first error: the cause. The ones after it are its consequences. + error: Option, + done: bool, +} + +impl Progress { + fn take_line(&mut self, raw: &str, log: &mut Vec) { + match parse_line(raw) { + Line::Phase(phase) => { + self.done = phase == "done"; + self.phase = Some(phase); + } + Line::Error(text) => { + self.error.get_or_insert(text); + } + Line::Text(text) if text.trim().is_empty() => {} + Line::Text(text) => log.push(text.chars().take(MAX_LOG_LINE).collect()), + } + } + + /// Take bytes of output; returns the complete lines to show. + pub fn feed(&mut self, bytes: &[u8]) -> Vec { + self.partial.extend_from_slice(bytes); + let mut log = Vec::new(); + while let Some(end) = self.partial.iter().position(|b| *b == b'\n') { + let rest = self.partial.split_off(end + 1); + let line = std::mem::replace(&mut self.partial, rest); + self.take_line(String::from_utf8_lossy(&line[..end]).as_ref(), &mut log); + } + log + } + + /// The output ended: a last line without a newline is complete now. + pub fn finish(&mut self) -> Vec { + let line = std::mem::take(&mut self.partial); + let mut log = Vec::new(); + self.take_line(String::from_utf8_lossy(&line).as_ref(), &mut log); + log + } + + pub fn update_message(&self, log: Vec) -> Value { + json!({ "type": "installUpdate", "phase": self.phase, "log": log }) + } + + pub fn finished_message(&self, cancelled: bool, log: Vec) -> Value { + json!({ + "type": "installFinished", + "ok": self.done, + "cancelled": cancelled && !self.done, + "error": self.error, + "log": log, + }) + } +} + +fn read_more(file: &mut Option, path: &Path, offset: &mut u64) -> Vec { + if file.is_none() { + *file = File::open(path).ok(); + } + let mut bytes = Vec::new(); + if let Some(f) = file { + if f.seek(SeekFrom::Start(*offset)).is_ok() && f.read_to_end(&mut bytes).is_ok() { + *offset += bytes.len() as u64; + } + } + bytes +} + +/// Report a run from its first line to its end. `stale` ends this reader +/// (the page asked for another). Every look at the process comes BEFORE the read +/// of the file, so what it wrote just before it ended is never missed. `done` +/// ends the run by itself: a subshell of the installer that lingers looks just +/// like the installer, and must not keep the page waiting. +pub fn follow(stale: impl Fn() -> bool, send: impl Fn(Value)) { + let path = run_dir().join(OUTPUT_FILE); + let mut file = None; + let mut offset = 0; + let mut progress = Progress::default(); + let mut reported = None; + loop { + if stale() { + return; + } + let running = setup::install_in_app().is_some(); + let log = progress.feed(&read_more(&mut file, &path, &mut offset)); + if !running || progress.done { + let mut log = log; + log.extend(progress.finish()); + let cancelled = CANCELLED.load(Ordering::SeqCst); + if progress.done { + let _ = fs::remove_dir_all(run_dir()); + } + send(progress.finished_message(cancelled, log)); + return; + } + if !log.is_empty() || reported != progress.phase { + reported = progress.phase.clone(); + send(progress.update_message(log)); + } + std::thread::sleep(POLL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plan() -> Plan { + Plan { + port: 3000, + network_password: None, + ai_cli: None, + } + } + + #[test] + fn a_this_mac_install_never_waits_for_a_person() { + let args = install_args(&plan()); + for flag in ["--no-admin", "--progress", "--service", "--local"] { + assert!(args.iter().any(|a| a == flag), "{flag}"); + } + assert!(!args.iter().any(|a| a == "--lan")); + let env = install_env(&plan()); + assert!(env.contains(&("CODEMAN_NONINTERACTIVE".to_string(), "1".to_string()))); + } + + #[test] + fn it_always_names_the_agent_so_the_installer_never_picks_one() { + let at = |args: &[String]| args[args.iter().position(|a| a == "--ai-cli").unwrap() + 1].clone(); + assert_eq!(at(&install_args(&plan())), "none"); + let mut p = plan(); + p.ai_cli = Some("codex".to_string()); + assert_eq!(at(&install_args(&p)), "codex"); + } + + #[test] + fn the_password_goes_in_the_environment_and_never_in_the_arguments() { + let mut p = plan(); + p.network_password = Some("hunter2".to_string()); + let args = install_args(&p); + assert!(args.iter().any(|a| a == "--lan")); + assert!(!args.iter().any(|a| a == "--local")); + assert!(!args.iter().any(|a| a.contains("hunter2"))); + assert!(!args.iter().any(|a| a.starts_with("--password"))); + assert!(install_env(&p).contains(&("CODEMAN_PASSWORD".to_string(), "hunter2".to_string()))); + } + + #[test] + fn it_reads_the_preflight_answer() { + let text = r#"{"schema":1,"os":"macos","arch":"arm64","noAdmin":true,"blocker":"", + "needs":{"node":true,"tmux":true,"git":false},"later":"ignored", + "clis":[{"id":"claude","label":"Claude Code","installed":false,"installable":true,"docs":"https://x"}]}"#; + let found = parse_preflight(text).unwrap(); + assert_eq!(found.arch, "arm64"); + assert!(found.needs.node && !found.needs.git); + assert_eq!(found.clis[0].label, "Claude Code"); + } + + #[test] + fn it_refuses_an_answer_it_cannot_trust() { + assert!(parse_preflight("Unknown option").is_err()); + let newer = r#"{"schema":2,"os":"macos","arch":"arm64","blocker":"","needs":{"node":true,"tmux":true,"git":true},"clis":[]}"#; + assert!(parse_preflight(newer).unwrap_err().contains("schema 2")); + } + + #[test] + fn the_page_gets_the_preflight_in_its_own_spelling() { + let found = Preflight { + schema: 1, + os: "macos".into(), + arch: "arm64".into(), + blocker: String::new(), + needs: Needs { + node: true, + tmux: false, + git: true, + }, + clis: vec![], + }; + let text = serde_json::to_value(&found).unwrap(); + assert_eq!(text["needs"]["node"], json!(true)); + assert_eq!(text["blocker"], json!("")); + } + + #[test] + fn only_a_real_run_counts_as_an_install() { + let script = "/var/folders/ab/T/codeman-install/install.sh"; + assert!(is_install_run(&format!( + "/bin/bash {script} --no-admin --progress --service --local --port 3000" + ))); + assert!(!is_install_run(&format!("/bin/bash {script} preflight --no-admin"))); + assert!(!is_install_run("/bin/bash /tmp/other/install.sh --progress")); + assert!(!is_install_run("vim /tmp/codeman-install/notes.txt --progress")); + } + + #[test] + fn it_follows_the_installer_protocol() { + assert_eq!(parse_line("@@codeman phase build"), Line::Phase("build".into())); + assert_eq!( + parse_line("@@codeman error Building Codeman failed (exit 1)"), + Line::Error("Building Codeman failed (exit 1)".into()) + ); + assert_eq!(parse_line("plain text\r"), Line::Text("plain text".into())); + } + + #[test] + fn what_is_not_the_protocol_stays_output() { + for line in [ + "@@codeman phase", + "@@codeman phase Not-A-Phase", + "@@codeman nonsense here", + "@@codeman", + ] { + assert_eq!(parse_line(line), Line::Text(line.into())); + } + } + + #[test] + fn colours_do_not_reach_the_page() { + assert_eq!( + parse_line("\u{1b}[0;34m==>\u{1b}[0m \u{1b}[1mDone\u{1b}[0m"), + Line::Text("==> Done".into()) + ); + assert_eq!(parse_line("\u{1b}[2K\u{1b}[1Gnpm"), Line::Text("npm".into())); + } + + #[test] + fn a_line_split_across_reads_is_read_whole() { + let mut p = Progress::default(); + assert!(p.feed(b"@@codeman pha").is_empty()); + assert!(p.feed(b"se packages\nHello ").is_empty()); + assert_eq!(p.phase.as_deref(), Some("packages")); + assert_eq!(p.feed(b"world\n"), vec!["Hello world".to_string()]); + } + + #[test] + fn output_without_a_last_newline_is_kept() { + let mut p = Progress::default(); + p.feed(b"@@codeman phase done"); + assert!(!p.done); + p.finish(); + assert!(p.done); + } + + #[test] + fn a_broken_utf8_byte_costs_one_character_not_the_line() { + let mut p = Progress::default(); + let log = p.feed(b"caf\xff bar\n"); + assert_eq!(log, vec!["caf\u{fffd} bar".to_string()]); + } + + #[test] + fn a_very_long_line_is_cut() { + let mut p = Progress::default(); + let line = format!("{}\n", "x".repeat(MAX_LOG_LINE * 2)); + assert_eq!(p.feed(line.as_bytes())[0].chars().count(), MAX_LOG_LINE); + } + + #[test] + fn the_first_error_is_the_one_reported() { + let mut p = Progress::default(); + p.feed(b"@@codeman error Building Codeman failed\n@@codeman error Installation failed. Partial\n"); + let done = p.finished_message(false, vec![]); + assert_eq!(done["error"], json!("Building Codeman failed")); + assert_eq!(done["ok"], json!(false)); + } + + #[test] + fn only_done_is_success() { + let mut p = Progress::default(); + p.feed(b"@@codeman phase service\n"); + assert_eq!(p.finished_message(false, vec![])["ok"], json!(false)); + p.feed(b"@@codeman phase done\n"); + assert_eq!(p.finished_message(false, vec![])["ok"], json!(true)); + } + + #[test] + fn a_run_that_finished_before_cancel_landed_is_not_cancelled() { + let mut p = Progress::default(); + p.feed(b"@@codeman phase done\n"); + assert_eq!(p.finished_message(true, vec![])["cancelled"], json!(false)); + let mut q = Progress::default(); + q.feed(b"@@codeman phase build\n"); + assert_eq!(q.finished_message(true, vec![])["cancelled"], json!(true)); + } + + /// The process side, against a stand-in installer: `cargo test` on a Mac + /// runs it. One test, because a run is one folder and one process. + #[test] + fn a_run_outlives_its_reader_and_ends_as_reported() { + use std::sync::atomic::AtomicUsize; + use std::sync::{Arc, Mutex}; + + let plan = Plan { + port: 3999, + network_password: Some("not-in-ps".to_string()), + ai_cli: None, + }; + let stand_in = |body: &str| { + let dir = fresh_run_dir().unwrap(); + fs::write(dir.join(SCRIPT_FILE), format!("#!/bin/bash\n{body}\n")).unwrap(); + spawn_run(&plan).unwrap(); + dir + }; + let collect = |stop_after: Option| { + let got = Arc::new(Mutex::new(Vec::::new())); + let (seen, sink) = (got.clone(), got.clone()); + let calls = AtomicUsize::new(0); + follow( + move || { + calls.fetch_add(1, Ordering::SeqCst); + stop_after.is_some_and(|n| seen.lock().unwrap().len() >= n) + }, + move |msg| sink.lock().unwrap().push(msg), + ); + let out = got.lock().unwrap().clone(); + out + }; + + // A reader that goes away (the page reloaded, the app quit) leaves the + // install running, and the next reader gets it from the first line. + let dir = stand_in( + r#"echo "==> first" +echo "@@codeman phase source" >&2 +while [ ! -f "$(dirname "$0")/go" ]; do sleep 0.1; done +echo "==> second" +echo "@@codeman phase done" >&2"#, + ); + let first = collect(Some(1)); + assert_eq!(first[0]["phase"], json!("source")); + assert_eq!(first[0]["log"], json!(["==> first"])); + assert!(setup::install_in_app().is_some(), "the install stopped with its reader"); + + fs::write(dir.join("go"), "").unwrap(); + let replay = collect(None); + let last = replay.last().unwrap(); + assert_eq!(last["type"], json!("installFinished")); + assert_eq!(last["ok"], json!(true)); + let lines: Vec<&Value> = replay.iter().flat_map(|m| m["log"].as_array().unwrap()).collect(); + assert_eq!(lines, vec!["==> first", "==> second"]); + assert!(!dir.exists(), "a finished run leaves nothing behind"); + + // Cancel reaches what the installer started, not only the installer. + let marker = "31337"; + stand_in(&format!( + r#"echo "@@codeman phase build" >&2 +sleep {marker} & +( while true; do sleep {marker}; done ) & +wait"# + )); + std::thread::sleep(Duration::from_millis(500)); + let sleeper = |procs: Vec<(u32, String)>| procs.iter().any(|(_, c)| c == &format!("sleep {marker}")); + assert!(sleeper(setup::list_processes()), "the stand-in did not start"); + cancel(); + assert!(setup::install_in_app().is_none()); + std::thread::sleep(Duration::from_millis(300)); + assert!(!sleeper(setup::list_processes()), "a child outlived Cancel"); + let done = collect(None); + assert_eq!(done.last().unwrap()["cancelled"], json!(true)); + assert_eq!(done.last().unwrap()["ok"], json!(false)); + + // A subshell that outlives `done` does not keep the page waiting. + let dir = stand_in( + r#"( while true; do sleep 1; done ) & +echo "@@codeman phase done" >&2 +wait"#, + ); + assert_eq!(collect(None).last().unwrap()["ok"], json!(true)); + assert!(!dir.exists()); + std::thread::sleep(Duration::from_millis(100)); + cancel(); + + // A failure reports its first error, and keeps the output for a look. + let dir = stand_in( + r#"echo "@@codeman phase packages" >&2 +echo "@@codeman error npm said no" >&2 +echo "@@codeman error Installation failed." >&2 +exit 1"#, + ); + let done = collect(None); + let last = done.last().unwrap(); + assert_eq!(last["ok"], json!(false)); + assert_eq!(last["cancelled"], json!(false)); + assert_eq!(last["error"], json!("npm said no")); + assert!(dir.join(OUTPUT_FILE).is_file()); + let _ = fs::remove_dir_all(&dir); + } + + /// A whole real install, driven down the same path as the page: build with + /// `CODEMAN_DESKTOP_INSTALLER_URL` and run under a throwaway HOME with + /// `CODEMAN_E2E_AI_CLI` set. Skips `refuse_start`, which a machine with a + /// live Codeman would answer, and that machine is where this runs. With + /// `CODEMAN_E2E_CANCEL_AT=` it cancels there instead of finishing. + #[test] + #[ignore = "installs Codeman: see the comment"] + fn a_real_install_under_a_throwaway_home() { + let found = fetch_and_ask().unwrap(); + println!("preflight: {}", serde_json::to_string(&found).unwrap()); + let ai_cli = std::env::var("CODEMAN_E2E_AI_CLI").ok(); + let port = std::env::var("CODEMAN_E2E_PORT").unwrap().parse().unwrap(); + spawn_run(&Plan { + port, + network_password: None, + ai_cli, + }) + .unwrap(); + let ok = std::sync::Arc::new(AtomicBool::new(false)); + let result = ok.clone(); + let started = Instant::now(); + let cancel_at = std::env::var("CODEMAN_E2E_CANCEL_AT").ok(); + let cancelling = cancel_at.is_some(); + let asked = AtomicBool::new(false); + follow( + || false, + move |msg| { + let at = started.elapsed().as_secs(); + if cancel_at.as_deref().is_some_and(|p| msg["phase"] == json!(p)) && !asked.swap(true, Ordering::SeqCst) + { + println!("[{at}s] cancelling"); + std::thread::spawn(cancel); + } + if msg["type"] == "installFinished" { + result.store(msg["ok"] == json!(true), Ordering::SeqCst); + println!("[{at}s] {}", serde_json::to_string(&msg).unwrap()); + } else if !msg["log"].as_array().unwrap().is_empty() { + println!("[{at}s] {} {}", msg["phase"], msg["log"]); + } + }, + ); + assert_eq!( + ok.load(Ordering::SeqCst), + !cancelling, + "the install did not end as asked" + ); + } + + #[test] + fn a_bare_path_is_filled_in() { + let env = with_path(vec![("HOME".to_string(), "/Users/a".to_string())]); + assert!(env.iter().any(|(k, v)| k == "PATH" && v == FALLBACK_PATH)); + let own = with_path(vec![("PATH".to_string(), "/mine".to_string())]); + assert_eq!(own, vec![("PATH".to_string(), "/mine".to_string())]); + } +} diff --git a/packages/desktop/src/local_server.rs b/packages/desktop/src/local_server.rs index 9047f6d4..62423322 100644 --- a/packages/desktop/src/local_server.rs +++ b/packages/desktop/src/local_server.rs @@ -91,10 +91,10 @@ fn flavor(shell: &Path) -> Flavor { } /// What one process printed, stdout and stderr kept apart. -struct Output { - code: Option, - stdout: String, - stderr: String, +pub struct Output { + pub code: Option, + pub stdout: String, + pub stderr: String, } /// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page. @@ -139,8 +139,18 @@ fn pump(pipe: Option, stream: usize, tx: mpsc::Send }); } -/// Run `cmd` to completion with stdin closed, or kill it at `deadline`. -fn exec(mut cmd: Command, deadline: Instant) -> Result { +/// `exec_bounded` for a start. +fn exec(cmd: Command, deadline: Instant) -> Result { + exec_bounded( + cmd, + deadline, + "Starting Codeman took longer than a minute; it was stopped.", + ) +} + +/// Run `cmd` to completion with stdin closed, or kill it at `deadline` and +/// answer `timed_out`. +pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Result { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd .spawn() @@ -158,7 +168,7 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result { Ok(None) => { let _ = child.kill(); let _ = child.wait(); - return Err("Starting Codeman took longer than a minute; it was stopped.".into()); + return Err(timed_out.to_string()); } Err(e) => return Err(e.to_string()), } @@ -265,6 +275,17 @@ fn capture_env( })) } +/// The login shell's environment, for a process the app runs without it (the +/// graphical install). `None` when the shell reports none. +pub fn login_environment() -> Option> { + let shell = login_shell(); + let flags: &[&str] = match flavor(&shell) { + Flavor::Posix => &["-l", "-c"], + Flavor::Capture(attempts) => attempts.first().copied().unwrap_or(&["-c"]), + }; + capture_env(&shell, flags, Instant::now() + FIND_TIMEOUT).ok()?.ok() +} + /// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured /// environment, so the server gets the profile's PATH, API keys and CODEMAN_* /// settings and nothing of launchd's bare one. diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 0025f47a..374cc769 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -22,6 +22,7 @@ mod assets; mod config; mod credentials; +mod gui_install; mod local_server; #[cfg(target_os = "macos")] mod macos; @@ -34,7 +35,8 @@ use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::process::Command; use std::rc::Rc; -use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use muda::accelerator::{Accelerator, Code, Modifiers}; @@ -267,6 +269,8 @@ struct App { menu: AppMenu, /// The one Keychain worker; every Keychain job goes through its queue. keychain: Keychain, + /// Which reader of the install's output is the current one. + install_reader: Arc, /// While the main window may still have to be put into the full screen it /// asked for at launch: when to look next, and how many retries were made. fullscreen_retry: Option<(Instant, u8)>, @@ -350,6 +354,7 @@ fn main() { downloads: HashMap::new(), menu, keychain, + install_reader: Arc::new(AtomicU64::new(0)), fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)), }; @@ -1174,6 +1179,54 @@ impl App { }); } } + ConnectCmd::InstallPrepare => { + if choosing { + self.in_background(|| match gui_install::prepare() { + Ok(plan) => json!({ "type": "installPlan", "plan": plan }), + Err(message) => json!({ "type": "installPrepareFailed", "message": message }), + }); + } + } + ConnectCmd::InstallStart { + network_password, + ai_cli, + } => { + if choosing { + let proxy = self.proxy.clone(); + let stale = self.next_install_reader(); + std::thread::spawn(move || { + let plan = gui_install::Plan { + port: setup::pick_install_port(), + network_password, + ai_cli, + }; + let send = |msg| { + let _ = proxy.send_event(UserEvent::ToConnectPage(msg)); + }; + match gui_install::start(&plan) { + Ok(()) => { + send(json!({ "type": "installRunning", "port": plan.port })); + gui_install::follow(stale, send); + } + Err(message) => send(json!({ "type": "installStartFailed", "message": message })), + } + }); + } + } + ConnectCmd::InstallAttach => { + if choosing { + let proxy = self.proxy.clone(); + let stale = self.next_install_reader(); + std::thread::spawn(move || { + gui_install::follow(stale, |msg| { + let _ = proxy.send_event(UserEvent::ToConnectPage(msg)); + }); + }); + } + } + ConnectCmd::InstallCancel => { + std::thread::spawn(gui_install::cancel); + } ConnectCmd::InstallRemote { target } => { if choosing { match setup::SshTarget::parse(&target) { @@ -1205,6 +1258,14 @@ impl App { } } + /// A new reader of the install's output replaces the one before it: the + /// answer is how that new reader learns it was replaced in turn. + fn next_install_reader(&self) -> impl Fn() -> bool + Send + 'static { + let generation = self.install_reader.clone(); + let mine = generation.fetch_add(1, Ordering::SeqCst) + 1; + move || generation.load(Ordering::SeqCst) != mine + } + /// Run `work` off the event loop (it may run a login shell, plutil, /// Terminal or a probe) and hand what it returns to the connect page. fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) { diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index 7680cc33..ad476ad5 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -565,6 +565,18 @@ pub enum ConnectCmd { InstallLocal { network: bool, }, + /// Fetch the installer and ask it what is on this Mac (gui_install). + InstallPrepare, + /// Run the graphical install. `network_password`: reachable from other + /// devices, with this password. `ai_cli`: an id in the shape of the + /// installer's catalogue, which checks it again. + InstallStart { + network_password: Option, + ai_cli: Option, + }, + /// Show the graphical install that is running already. + InstallAttach, + InstallCancel, /// Run the installer on another machine over SSH, in Terminal. `target` /// is what the user typed; setup::SshTarget::parse checks it. InstallRemote { @@ -619,6 +631,17 @@ enum RawConnect { #[serde(default)] network: bool, }, + InstallPrepare, + InstallStart { + // rename_all above reaches variant names only, and deny_unknown_fields + // would refuse the page's spelling of these. + #[serde(default, rename = "networkPassword")] + network_password: Option, + #[serde(default, rename = "aiCli")] + ai_cli: Option, + }, + InstallAttach, + InstallCancel, InstallRemote { target: String, }, @@ -635,6 +658,17 @@ enum RawHost { SessionExpired, } +/// The shape of a CLI catalogue id. It becomes one argument of `install.sh`, +/// so it could not be read as a flag even without this: it must start with a +/// letter. +fn is_cli_id(id: &str) -> bool { + id.len() <= 32 + && id.starts_with(|c: char| c.is_ascii_lowercase()) + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + fn bounded(s: Option, max: usize) -> Option> { match s { Some(v) if v.len() > max => None, @@ -692,6 +726,21 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo RawConnect::OpenExternal { url } if url.len() <= MAX_URL_LEN => ConnectCmd::OpenExternal(url), RawConnect::LocalStatus { quick, deep } => ConnectCmd::LocalStatus { quick, deep }, RawConnect::InstallLocal { network } => ConnectCmd::InstallLocal { network }, + RawConnect::InstallPrepare => ConnectCmd::InstallPrepare, + RawConnect::InstallStart { + network_password, + ai_cli, + } => { + if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) { + return None; + } + ConnectCmd::InstallStart { + network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()), + ai_cli, + } + } + RawConnect::InstallAttach => ConnectCmd::InstallAttach, + RawConnect::InstallCancel => ConnectCmd::InstallCancel, RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => { ConnectCmd::InstallRemote { target } } @@ -1480,6 +1529,73 @@ mod tests { assert_eq!(parse_ipc(page, r#"{"cmd":"installLocal"}"#, &loaded, true), None); } + #[test] + fn ipc_graphical_install_commands() { + let page = "codemanapp://localhost/index.html"; + let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true); + assert_eq!( + c(r#"{"cmd":"installPrepare"}"#), + Some(Ipc::Connect(ConnectCmd::InstallPrepare)) + ); + assert_eq!( + c(r#"{"cmd":"installAttach"}"#), + Some(Ipc::Connect(ConnectCmd::InstallAttach)) + ); + assert_eq!( + c(r#"{"cmd":"installCancel"}"#), + Some(Ipc::Connect(ConnectCmd::InstallCancel)) + ); + // The spelling connect.html sends (test/desktop-connect-install.test.ts + // pins the page's side). + assert_eq!( + c(r#"{"cmd":"installStart","networkPassword":"s3cret","aiCli":"claude"}"#), + Some(Ipc::Connect(ConnectCmd::InstallStart { + network_password: Some("s3cret".into()), + ai_cli: Some("claude".into()), + })) + ); + assert_eq!( + c(r#"{"cmd":"installStart"}"#), + Some(Ipc::Connect(ConnectCmd::InstallStart { + network_password: None, + ai_cli: None, + })) + ); + // An empty password is no password: never a network install without one. + assert_eq!( + c(r#"{"cmd":"installStart","networkPassword":""}"#), + Some(Ipc::Connect(ConnectCmd::InstallStart { + network_password: None, + ai_cli: None, + })) + ); + // Only the shape of an id: nothing that could read as a flag or more. + for id in ["--yes", "-y", "Claude", "a b", "a;b", "", "a/b"] { + assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{id}"}}"#)), None, "{id}"); + } + let long = "a".repeat(33); + assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{long}"}}"#)), None); + let long = "a".repeat(MAX_FIELD_LEN + 1); + assert_eq!( + c(&format!(r#"{{"cmd":"installStart","networkPassword":"{long}"}}"#)), + None + ); + // Nothing else rides along. + assert_eq!(c(r#"{"cmd":"installStart","args":["--yes"]}"#), None); + assert_eq!(c(r#"{"cmd":"installStart","ai_cli":"claude"}"#), None); + // Only the connect page, only in the main window, never a server page. + assert_eq!( + parse_ipc(page, r#"{"cmd":"installStart"}"#, &Phase::Connect, false), + None + ); + let loaded = Phase::Loaded(u("http://127.0.0.1:3000/")); + assert_eq!( + parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"installStart"}"#, &loaded, true), + None + ); + assert_eq!(parse_ipc(page, r#"{"cmd":"installCancel"}"#, &loaded, true), None); + } + #[test] fn ipc_from_connect_page() { let page = "codemanapp://localhost/index.html"; diff --git a/packages/desktop/src/setup.rs b/packages/desktop/src/setup.rs index 2efa931f..709a0f1b 100644 --- a/packages/desktop/src/setup.rs +++ b/packages/desktop/src/setup.rs @@ -1,12 +1,12 @@ -//! The connect screen's "This Mac" tab and its installs: where the Codeman on -//! this Mac listens, whether it is installed, its login, and the installer runs -//! for this Mac and for another machine over SSH. +//! The connect screen's "This Mac" tab and its Terminal installs: where the +//! Codeman on this Mac listens, whether it is installed, its login, and the +//! installer runs in Terminal for this Mac and for another machine over SSH. +//! The install most people get runs in the app, in `gui_install.rs`. //! -//! The installer always runs in Terminal, never inside the app. `install.sh` -//! asks for the administrator password on a terminal (Homebrew, its sudo -//! keep-alive), the Xcode tools arrive as a system dialog, and ssh asks about -//! host keys and passwords on a terminal too: a run without one fails on the -//! first fresh Mac. So the app writes a small script that deletes itself and +//! The Terminal route is for what that one cannot do. `install.sh` asks for +//! the administrator password on a terminal (Homebrew, its sudo keep-alive), +//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and +//! passwords on a terminal too. So the app writes a small script that deletes itself and //! hands it to Terminal (`open -b com.apple.Terminal`), which needs no //! permission, unlike scripting Terminal through osascript (an Automation //! prompt). Everything the scripts contain is built here and unit-tested; the @@ -24,12 +24,22 @@ use std::time::Duration; use serde::Serialize; use url::Url; +use crate::gui_install; use crate::local_server; use crate::policy; use crate::probe::{self, ProbeOutcome}; -/// Codeman's published installer (a redirect to `install.sh` on master). -pub const INSTALLER_URL: &str = "https://getcodeman.com/install"; +/// Codeman's published installer (a redirect to `install.sh` on master). A test +/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is +/// compiled; a shipped app has no way to point this anywhere else, at runtime +/// or otherwise. +pub const INSTALLER_URL: &str = match option_env!("CODEMAN_DESKTOP_INSTALLER_URL") { + Some(url) => url, + None => "https://getcodeman.com/install", +}; + +/// The branch that test build installs Codeman from (`CODEMAN_BRANCH`). +pub const INSTALL_BRANCH: Option<&str> = option_env!("CODEMAN_DESKTOP_INSTALL_BRANCH"); /// What to paste into a terminal on another machine; the SSH install runs the /// same thing there. @@ -289,7 +299,7 @@ pub fn local_login(url: &Url) -> Option<(String, String)> { } /// This user's processes, as `ps` lists them: (pid, command line). -fn list_processes() -> Vec<(u32, String)> { +pub fn list_processes() -> Vec<(u32, String)> { let Ok(out) = Command::new("/bin/ps") .args(["-x", "-ww", "-o", "pid=", "-o", "command="]) .stdin(Stdio::null()) @@ -329,8 +339,8 @@ pub fn is_codeman_server(command: &str) -> bool { } /// Whether a command line is an install the app started on this Mac (the -/// Terminal script, or the installer it downloaded), and the port it asked -/// for once the installer runs. +/// Terminal script, the installer it downloaded, or the graphical install), and +/// the port it asked for once the installer runs. pub fn install_in_progress(command: &str) -> Option> { let words: Vec<&str> = command.split_whitespace().collect(); let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string(); @@ -340,7 +350,7 @@ pub fn install_in_progress(command: &str) -> Option> { }) { return Some(None); } - if words.iter().any(|w| name(w).starts_with("codeman-install.")) { + if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::is_install_run(command) { let port = words .iter() .position(|w| *w == "--port") @@ -351,6 +361,15 @@ pub fn install_in_progress(command: &str) -> Option> { None } +/// The process of the graphical install running on this Mac, if one does. It +/// leads its process group. +pub fn install_in_app() -> Option { + list_processes() + .into_iter() + .find(|(_, command)| gui_install::is_install_run(command)) + .map(|(pid, _)| pid) +} + fn codeman_servers(procs: &[(u32, String)]) -> Vec { procs .iter() @@ -465,10 +484,13 @@ pub struct LocalStatus { pub installed: bool, /// `install.sh` finished its work (its `.install-complete` marker). pub install_complete: bool, - /// An install the app started is still running in Terminal, and the - /// port it asked for once that is known. + /// An install the app started is still running, and the port it asked + /// for once that is known. pub install_running: bool, pub install_port: Option, + /// That install is the graphical one, not a Terminal window: the page can + /// show it (gui_install::follow), also after the app was quit. + pub install_in_app: bool, /// For RunningElsewhere: the addresses that Codeman listens on. pub elsewhere: Vec, /// The probe's outcome, for the connect page's explanations. @@ -495,6 +517,7 @@ pub fn inspect(depth: Depth) -> LocalStatus { let servers = codeman_servers(&procs); let found = listeners(&servers); let install = procs.iter().find_map(|(_, c)| install_in_progress(c)); + let install_in_app = procs.iter().any(|(_, c)| gui_install::is_install_run(c)); // The login goes only to this user's own Codeman on that port. let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) { @@ -587,6 +610,7 @@ pub fn inspect(depth: Depth) -> LocalStatus { install_complete, install_running: install.is_some(), install_port: install.flatten(), + install_in_app, elsewhere, outcome, } @@ -597,7 +621,7 @@ pub fn inspect(depth: Depth) -> LocalStatus { /// attach to the first one's live sessions (src/config/instance.ts), and /// `codeman web -d` only notices a server it started itself or one at the /// same address. -fn already_running() -> Option { +pub fn already_running() -> Option { let servers = codeman_servers(&list_processes()); let pid = servers.first()?; let found = listeners(&servers); diff --git a/test/desktop-connect-install.test.ts b/test/desktop-connect-install.test.ts new file mode 100644 index 00000000..8f85119c --- /dev/null +++ b/test/desktop-connect-install.test.ts @@ -0,0 +1,447 @@ +/** + * @fileoverview The graphical install's screens in the Codeman Desktop connect page. + * + * Runs the real packages/desktop/assets/connect.html in jsdom with a fake native side: + * `window.ipc` records what the page asks the app for, and `onNative` is what the app + * calls. The Rust side of the same messages is pinned in packages/desktop/src/policy.rs + * (`ipc_graphical_install_commands`) and src/gui_install.rs, which do not build on Linux + * CI, so the spelling of every message the page sends is asserted here. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it } from 'vitest'; + +const HTML = readFileSync(resolve(import.meta.dirname, '../packages/desktop/assets/connect.html'), 'utf8'); + +type Sent = { cmd: string; [key: string]: unknown }; +type Cli = { id: string; label: string; installed: boolean; installable: boolean; docs: string }; + +const cli = (id: string, label: string, over: Partial = {}): Cli => ({ + id, + label, + installed: false, + installable: true, + docs: `https://example.org/${id}`, + ...over, +}); + +const plan = (over: Record = {}) => ({ + schema: 1, + os: 'macos', + arch: 'arm64', + blocker: '', + needs: { node: true, tmux: true, git: false }, + clis: [cli('claude', 'Claude Code'), cli('codex', 'Codex'), cli('deepseek', 'DeepSeek', { installable: false })], + ...over, +}); + +const NOT_INSTALLED = { + url: 'http://127.0.0.1:3000/', + state: 'notInstalled', + installed: false, + installComplete: false, + installRunning: false, + installInApp: false, + elsewhere: [], + outcome: { kind: 'refused' }, +}; + +let dom: JSDOM | null = null; + +function open() { + const sent: Sent[] = []; + dom = new JSDOM(HTML, { + url: 'codemanapp://localhost/index.html', + runScripts: 'dangerously', + beforeParse(window) { + (window as unknown as { ipc: unknown }).ipc = { postMessage: (m: string) => sent.push(JSON.parse(m)) }; + }, + }); + const window = dom.window as unknown as Window & { + __codemanDesktop: { onNative(m: unknown): void }; + }; + const doc = window.document; + const $ = (id: string) => doc.getElementById(id) as HTMLElement; + const input = (id: string) => $(id) as HTMLInputElement; + const native = (m: unknown) => window.__codemanDesktop.onNative(m); + const click = (id: string) => $(id).click(); + const visible = (id: string) => { + const el = $(id); + if (el.hidden) return false; + for (let p = el.parentElement; p; p = p.parentElement) if (p.hidden) return false; + return true; + }; + const rail = () => + Array.from(doc.querySelectorAll('#wiz-rail li')).map((li) => [li.querySelector('span')?.textContent, li.className]); + const sentOf = (cmd: string) => sent.filter((m) => m.cmd === cmd); + native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0' }); + native({ type: 'localStatus', status: NOT_INSTALLED }); + return { sent, sentOf, doc, $, input, native, click, visible, rail }; +} + +/** Open the wizard and answer its first question with `p`. */ +function toPlan(p = plan()) { + const t = open(); + t.click('local-install'); + t.native({ type: 'installPlan', plan: p }); + return t; +} + +afterEach(() => { + dom?.window.close(); + dom = null; +}); + +describe('starting the graphical install', () => { + it('asks the app for the installer instead of opening Terminal', () => { + const t = open(); + t.click('local-install'); + expect(t.sentOf('installPrepare')).toHaveLength(1); + expect(t.sentOf('installLocal')).toHaveLength(0); + expect(t.$('wiz-title').textContent).toBe('Looking at this Mac'); + expect(t.visible('wizard')).toBe(true); + }); + + it('hands the whole card to the install', () => { + const t = open(); + t.click('local-install'); + expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(true); + }); + + it('goes back to the tabs from any question', () => { + const t = toPlan(); + t.click('wiz-back'); + expect(t.visible('wizard')).toBe(false); + expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(false); + }); + + it('offers no way out while preparing, so two prepares never share the folder', () => { + const t = open(); + t.click('local-install'); + expect(t.visible('wiz-back')).toBe(false); + expect(t.visible('wiz-next')).toBe(false); + }); + + it('explains a failed download and offers Try again and Terminal', () => { + const t = open(); + t.click('local-install'); + t.native({ type: 'installPrepareFailed', message: 'Could not download the installer.' }); + expect(t.$('wiz-message').textContent).toBe('Could not download the installer.'); + expect(t.visible('wiz-terminal')).toBe(true); + t.click('wiz-next'); + expect(t.sentOf('installPrepare')).toHaveLength(2); + }); +}); + +describe('choosing a coding agent', () => { + it('asks when this Mac has none, listing what the installer can install, first one chosen', () => { + const t = toPlan(); + expect(t.$('wiz-title').textContent).toBe('Choose a coding agent'); + const radios = Array.from(t.doc.querySelectorAll('#wiz-agents input')); + expect(radios.map((r) => r.value)).toEqual(['claude', 'codex']); + expect(radios.map((r) => r.checked)).toEqual([true, false]); + }); + + it('does not ask when one is already there, and names it instead', () => { + const t = toPlan( + plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] }) + ); + expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?'); + expect(t.$('wiz-summary').textContent).toContain('Uses Claude Code, already on this Mac'); + t.click('wiz-back'); + expect(t.visible('wizard')).toBe(false); + }); + + it('sends no agent then, so the installer never adds one', () => { + const t = toPlan( + plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] }) + ); + t.click('wiz-next'); + expect(t.sentOf('installStart')[0].aiCli).toBeUndefined(); + }); + + it('skips the question when nothing can be installed', () => { + const t = toPlan(plan({ clis: [cli('deepseek', 'DeepSeek', { installable: false })] })); + expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?'); + expect(t.$('wiz-summary').textContent).toContain('No coding agent yet'); + }); + + it('installs the one that was picked', () => { + const t = toPlan(); + const codex = t.doc.querySelector('#wiz-agents input[value=codex]')!; + codex.click(); + expect(t.$('wiz-agent-note').textContent).toContain('You sign in to Codex'); + t.click('wiz-next'); + expect(t.$('wiz-summary').textContent).toContain('Installs Codex'); + t.click('wiz-next'); + expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', aiCli: 'codex' }]); + }); + + it('Skip for now installs none, and says what that means', () => { + const t = toPlan(); + t.click('wiz-skip'); + expect(t.$('wiz-summary').textContent).toContain('No coding agent yet'); + t.click('wiz-next'); + expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart' }]); + }); + + it('Back from the next question returns to the choice, and keeps it', () => { + const t = toPlan(); + t.doc.querySelector('#wiz-agents input[value=codex]')!.click(); + t.click('wiz-next'); + t.click('wiz-back'); + expect(t.$('wiz-title').textContent).toBe('Choose a coding agent'); + expect(t.doc.querySelector('#wiz-agents input:checked')?.value).toBe('codex'); + }); + + it('opens an agent docs link through the app, and only https', () => { + const t = toPlan(); + t.doc.querySelector('#wiz-agent-note a')!.click(); + expect(t.sentOf('openExternal')).toEqual([{ cmd: 'openExternal', url: 'https://example.org/claude' }]); + const u = toPlan(plan({ clis: [cli('x', 'X', { docs: 'javascript:alert(1)' })] })); + expect(u.doc.querySelector('#wiz-agent-note a')).toBeNull(); + }); + + it('shows a label from the installer as text, never as markup', () => { + const t = toPlan(plan({ clis: [cli('x', '')] })); + expect(t.doc.querySelector('#wiz-agents img')).toBeNull(); + expect(t.$('wiz-agents').textContent).toContain(' { + const toReach = () => { + const t = toPlan(); + t.click('wiz-next'); + return t; + }; + const network = (t: ReturnType) => + t.doc.querySelector('input[name=reach][value=network]')!.click(); + + it('is this Mac alone unless changed, with no password', () => { + const t = toReach(); + expect(t.visible('wiz-password')).toBe(false); + t.click('wiz-next'); + expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined(); + }); + + it('refuses the network without a password', () => { + const t = toReach(); + network(t); + expect(t.visible('wiz-password')).toBe(true); + t.click('wiz-next'); + expect(t.sentOf('installStart')).toHaveLength(0); + expect(t.$('wiz-message').textContent).toContain('Set a password'); + }); + + it('refuses two different passwords', () => { + const t = toReach(); + network(t); + t.input('wiz-pw').value = 'one'; + t.input('wiz-pw2').value = 'two'; + t.click('wiz-next'); + expect(t.sentOf('installStart')).toHaveLength(0); + expect(t.$('wiz-message').textContent).toContain('different'); + }); + + it('sends the password once and clears the fields', () => { + const t = toReach(); + network(t); + t.input('wiz-pw').value = 's3cret'; + t.input('wiz-pw2').value = 's3cret'; + t.click('wiz-next'); + expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', networkPassword: 's3cret', aiCli: 'claude' }]); + expect(t.input('wiz-pw').value).toBe(''); + expect(t.input('wiz-pw2').value).toBe(''); + }); + + it('never sends a password a person typed before going back to this Mac', () => { + const t = toReach(); + network(t); + t.input('wiz-pw').value = 's3cret'; + t.input('wiz-pw2').value = 's3cret'; + t.doc.querySelector('input[name=reach][value=local]')!.click(); + t.click('wiz-next'); + expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined(); + }); + + it('keeps the Terminal route, carrying the choice', () => { + const t = toReach(); + network(t); + t.click('wiz-terminal'); + expect(t.sentOf('installLocal')).toEqual([{ cmd: 'installLocal', network: true }]); + expect(t.visible('wizard')).toBe(false); + }); +}); + +describe('when the no-admin route cannot finish here', () => { + it('says why and sends the person to Terminal', () => { + const t = toPlan(plan({ blocker: 'The tmux project’s build needs macOS 15 or newer.' })); + expect(t.$('wiz-message').textContent).toContain('macOS 15'); + expect(t.$('wiz-next').textContent).toBe('Install in Terminal'); + expect(t.visible('wiz-agent-step')).toBe(false); + t.click('wiz-next'); + expect(t.sentOf('installLocal')).toHaveLength(1); + expect(t.sentOf('installStart')).toHaveLength(0); + }); +}); + +describe('following the install', () => { + const toRun = (p = plan()) => { + const t = toPlan(p); + t.click('wiz-next'); + t.click('wiz-next'); + t.native({ type: 'installRunning', port: 3001 }); + return t; + }; + + it('draws only the phases this install has', () => { + const noDownloads = toRun(plan({ needs: { node: false, tmux: false, git: false } })); + expect(noDownloads.rail().map((r) => r[0])).toEqual([ + 'Installing Claude Code', + 'Downloading Codeman', + 'Installing packages', + 'Building Codeman', + 'Starting Codeman', + ]); + const tmuxOnly = toRun(plan({ needs: { node: false, tmux: true, git: false } })); + expect(tmuxOnly.rail()[0][0]).toBe('Getting tmux'); + }); + + it('moves the rail along as the installer reports, and ignores a phase it does not know', () => { + const t = toRun(); + t.native({ type: 'installUpdate', phase: 'runtime', log: [] }); + t.native({ type: 'installUpdate', phase: 'packages', log: ['==> Installing dependencies...'] }); + t.native({ type: 'installUpdate', phase: 'somethingnew', log: [] }); + expect(t.rail()).toEqual([ + ['Getting Node.js and tmux', 'done'], + ['Installing Claude Code', 'done'], + ['Downloading Codeman', 'done'], + ['Installing packages', 'now'], + ['Building Codeman', ''], + ['Starting Codeman', ''], + ]); + expect(t.$('wiz-log').textContent).toContain('Installing dependencies'); + expect(t.$('wiz-clock')).not.toBeNull(); + }); + + it('asks the app to cancel, once', () => { + const t = toRun(); + t.click('wiz-cancel'); + t.click('wiz-cancel'); + expect(t.sentOf('installCancel')).toHaveLength(1); + }); + + it('a cancelled run goes back to the tabs and looks again', () => { + const t = toRun(); + t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] }); + expect(t.visible('wizard')).toBe(false); + expect(t.sentOf('localStatus').length).toBeGreaterThan(0); + }); + + it('a failed run shows the cause on its phase, opens the output and offers Try again', () => { + const t = toRun(); + t.native({ type: 'installUpdate', phase: 'build', log: [] }); + t.native({ + type: 'installFinished', + ok: false, + cancelled: false, + error: 'Building Codeman failed (exit 1).', + log: ['npm error'], + }); + expect(t.$('wiz-title').textContent).toBe('The install stopped'); + expect(t.$('wiz-message').textContent).toBe('Building Codeman failed (exit 1).'); + expect(t.rail().find((r) => r[1] === 'failed')?.[0]).toBe('Building Codeman'); + expect((t.$('wiz-details') as HTMLDetailsElement).open).toBe(true); + expect(t.$('wiz-log').textContent).toContain('npm error'); + t.click('wiz-next'); + expect(t.sentOf('installPrepare')).toHaveLength(2); + }); + + it('Try again keeps what was chosen', () => { + const t = toPlan(); + t.doc.querySelector('#wiz-agents input[value=codex]')!.click(); + t.click('wiz-next'); + t.click('wiz-next'); + t.native({ type: 'installFinished', ok: false, cancelled: false, error: 'x', log: [] }); + t.click('wiz-next'); + t.native({ type: 'installPlan', plan: plan() }); + expect(t.doc.querySelector('#wiz-agents input:checked')?.value).toBe('codex'); + }); + + it('a run with no error text still says something', () => { + const t = toRun(); + t.native({ type: 'installFinished', ok: false, cancelled: false, error: null, log: [] }); + expect(t.$('wiz-message').textContent).toContain('without saying why'); + }); + + it('a start the app refused is shown as a failure', () => { + const t = toPlan(); + t.click('wiz-next'); + t.click('wiz-next'); + t.native({ type: 'installStartFailed', message: 'Codeman is already being installed.' }); + expect(t.$('wiz-message').textContent).toBe('Codeman is already being installed.'); + }); + + it('a finished run opens Codeman at its own port, not at whatever answers on 3000', () => { + const t = toRun(); + t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] }); + expect(t.$('wiz-title').textContent).toBe('Starting Codeman'); + expect(t.rail().every((r) => r[1] === 'done')).toBe(true); + expect(t.sentOf('localStatus').at(-1)?.quick).toBe(true); + + const running = { ...NOT_INSTALLED, state: 'running', version: '1.48.0', installed: true }; + t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3000/' } }); + expect(t.sentOf('connect')).toHaveLength(0); + t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3001/' } }); + expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: 'http://127.0.0.1:3001/' })]); + expect(t.visible('wizard')).toBe(false); + }); +}); + +describe('an install that was already running', () => { + it('is picked up from the app, with no question asked', () => { + const t = open(); + t.native({ + type: 'localStatus', + status: { ...NOT_INSTALLED, installRunning: true, installInApp: true, installPort: 3002 }, + }); + expect(t.sentOf('installAttach')).toHaveLength(1); + expect(t.$('wiz-title').textContent).toBe('Installing Codeman'); + expect(t.visible('wiz-cancel')).toBe(true); + }); + + it('asks only once, however many looks arrive', () => { + const t = open(); + const status = { ...NOT_INSTALLED, installRunning: true, installInApp: true }; + t.native({ type: 'localStatus', status }); + t.native({ type: 'localStatus', status }); + expect(t.sentOf('installAttach')).toHaveLength(1); + }); + + it('draws what the installer reports, since it has no plan', () => { + const t = open(); + t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } }); + t.native({ type: 'installUpdate', phase: 'agent', log: [] }); + t.native({ type: 'installUpdate', phase: 'build', log: [] }); + expect(t.rail().map((r) => r[0])).toContain('Installing a coding agent'); + expect(t.rail().find((r) => r[1] === 'now')?.[0]).toBe('Building Codeman'); + }); + + it('never takes a graphical install for one in Terminal', () => { + const t = open(); + t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } }); + expect(t.$('local-title').textContent).not.toContain('Terminal'); + expect(t.visible('local-stop')).toBe(false); + }); + + it('an install still running in Terminal keeps the old wait', () => { + const t = open(); + t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: false } }); + expect(t.sentOf('installAttach')).toHaveLength(0); + expect(t.$('local-title').textContent).toContain('Terminal'); + }); +}); diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index 384937dd..21e23634 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -372,6 +372,235 @@ describe('install.sh AI CLI install menu', () => { }); }); +describe('install.sh --ai-cli answers the menu up front', () => { + // The desktop app installs with no terminal, so the agent is chosen in its window and + // arrives as a flag. Everything here runs the real functions in a real bash, with + // detection pointed at nothing, every trusted command rewritten to record which entry + // ran, and no tty, which is what the app's child process has. + const DRIVER = ` + set -euo pipefail + export CODEMAN_INSTALL_SH_LIB=1 + . "$1" + set +e + parse_flags "\${@:2}" || exit 9 + set -e + k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done + k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done + k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do + if [[ -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then + if [[ "\${KEEP_BREW:-}" == "1" && "\${CLI_INSTALL_CMD_TRUSTED[$k]}" == brew\\ * ]]; then :; else + CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}" + fi + fi + k=$((k + 1)) + done + CLI_DETECT_DONE="" + detect_all_clis + NONINTERACTIVE=\${FORCE_NONINTERACTIVE:-0} + DOWNLOADER=curl + has_tty() { return 1; } + offer_ai_cli_install + echo "REACHED THE STEP AFTER THE MENU" + `; + + function drive(args: string[], env: Record = {}) { + const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, ...env }, + }); + const stdout = result.stdout ?? ''; + // Anchored: the skip hints print each command's text, so `echo RAN=x` there ran nothing. + const ran = [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]); + return { status: result.status, stdout, stderr: result.stderr ?? '', ran }; + } + + it('none installs nothing and carries on, even with CODEMAN_NONINTERACTIVE', () => { + const run = drive(['--ai-cli', 'none'], { FORCE_NONINTERACTIVE: '1' }); + expect(run.stdout, run.stderr).toContain('REACHED THE STEP AFTER THE MENU'); + expect(run.ran).toEqual([]); + expect(run.stderr).toContain('Skipping AI CLI install'); + }); + + it('a named CLI runs that entry and no other, without the menu and without a terminal', () => { + const run = drive(['--ai-cli=codex']); + expect(run.ran).toEqual(['codex']); + expect(run.stdout).not.toContain('Which AI CLI'); + expect(run.status).toBe(1); // The stand-in command leaves no binary: the gate below still holds. + expect(run.stderr).toContain('The selected AI CLI failed to install'); + }); + + it('takes the same answer from CODEMAN_AI_CLI', () => { + expect(drive([], { CODEMAN_AI_CLI: 'gemini' }).ran).toEqual(['gemini']); + }); + + it('a CLI this host cannot install warns and carries on, never installing another', () => { + const run = drive(['--no-admin', '--ai-cli', 'omp'], { KEEP_BREW: '1' }); + if (process.platform === 'darwin') { + expect(run.ran).toEqual([]); + expect(run.stderr).toContain('omp cannot be installed automatically here'); + expect(run.stdout).toContain('REACHED THE STEP AFTER THE MENU'); + } else { + expect(run.ran).toEqual(['omp']); // Linux installs omp with curl, not brew. + } + }); + + it('deepseek has no command to run, so it warns and carries on', () => { + const run = drive(['--ai-cli', 'deepseek']); + expect(run.ran).toEqual([]); + expect(run.stderr).toContain('deepseek cannot be installed automatically here'); + expect(run.stdout).toContain('REACHED THE STEP AFTER THE MENU'); + }); + + it.each([['cluade'], ['shell'], ['--yes']])('refuses %s before anything runs', (id) => { + const run = drive([`--ai-cli=${id}`]); + expect(run.status).not.toBe(0); + expect(run.stderr).toContain('Unknown AI CLI'); + expect(run.ran).toEqual([]); + }); + + it('a bare run with no terminal and no answer is still refused by the guard', () => { + const run = drive([]); + expect(run.status).toBe(1); + expect(run.stderr).toContain('No interactive terminal'); + expect(run.ran).toEqual([]); + }); +}); + +describe('install.sh preflight', () => { + function preflight(args: string[], env: Record = {}) { + const home = mkdtempSync(join(tmpdir(), 'codeman-preflight-')); + try { + const result = spawnSync('bash', [INSTALL_SH, 'preflight', ...args], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, HOME: home, ...env }, + }); + expect(result.status, result.stderr).toBe(0); + return { stdout: result.stdout, home }; + } finally { + rmSync(home, { recursive: true, force: true }); + } + } + + it('prints one JSON object in the shape the desktop app reads', () => { + const { stdout } = preflight(['--no-admin']); + const found = JSON.parse(stdout); + expect(found.schema).toBe(1); + expect(found.noAdmin).toBe(true); + expect(typeof found.blocker).toBe('string'); + expect(Object.keys(found.needs).sort()).toEqual(['git', 'node', 'tmux']); + for (const key of ['node', 'tmux', 'git']) expect(typeof found.needs[key]).toBe('boolean'); + // Exactly what src/gui_install.rs deserializes, or the app calls the answer unreadable. + for (const cli of found.clis) { + expect(Object.keys(cli).sort()).toEqual(['docs', 'id', 'installable', 'installed', 'label']); + } + }); + + it('lists every agent of the catalogue, in its order, and never the shell', () => { + const ids = JSON.parse(preflight(['--no-admin']).stdout).clis.map((c: { id: string }) => c.id); + const agents = STOCK_CLIS.filter((c) => c.discovery.binaries.length > 0).sort((a, b) => a.order - b.order); + expect(ids).toEqual(agents.map((c) => c.id)); + }); + + it('never calls an installed CLI installable, and agrees with the menu on the rest', () => { + for (const cli of JSON.parse(preflight(['--no-admin']).stdout).clis) { + if (cli.installed) expect(cli.installable, cli.id).toBe(false); + } + const deepseek = JSON.parse(preflight(['--no-admin']).stdout).clis.find((c: { id: string }) => c.id === 'deepseek'); + if (!deepseek.installed) expect(deepseek.installable).toBe(false); // A launcher alone cannot drive a pane. + }); + + it('writes nothing into the home folder', () => { + const home = mkdtempSync(join(tmpdir(), 'codeman-preflight-')); + try { + const result = spawnSync('bash', [INSTALL_SH, 'preflight', '--no-admin'], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, HOME: home }, + }); + expect(result.status).toBe(0); + expect(readdirSync(home)).toEqual([]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('json_string survives what a path or a message can hold', () => { + const driver = ` + export CODEMAN_INSTALL_SH_LIB=1 + . "$1" + set +e + json_string "$2" + `; + const text = 'quote " back\\slash tab\t line\nfeed bell\u0007 é ✓'; + const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, text], { encoding: 'utf-8', timeout: 30_000 }); + expect(JSON.parse(result.stdout)).toBe(text.replace('\u0007', '')); + }); +}); + +describe('install.sh --progress, the protocol the desktop app reads', () => { + const CONNECT_HTML = readFileSync( + fileURLToPath(new URL('../packages/desktop/assets/connect.html', import.meta.url)), + 'utf-8' + ); + + function mainBody() { + const start = SOURCE.indexOf('main() {'); + const end = SOURCE.indexOf('# Phase 0: look', start); + return SOURCE.slice(start, end); + } + + it('reports its phases in the order the app draws them, ending on done', () => { + const phases = [...mainBody().matchAll(/^\s*progress_phase (\w+)$/gm)].map((m) => m[1]); + const drawn = /var PHASES = \[([^\]]*)\]/.exec(CONNECT_HTML)?.[1].match(/\w+/g); + expect(phases).toEqual([...(drawn ?? []), 'done']); + }); + + it('reports done after the service step and before a foreground start replaces the shell', () => { + const main = mainBody(); + expect(main.indexOf('progress_phase done')).toBeGreaterThan(main.indexOf('print_done_screen')); + expect(main.indexOf('progress_phase done')).toBeLessThan(main.indexOf('exec node')); + }); + + function driveProtocol(env: Record) { + const driver = ` + export CODEMAN_INSTALL_SH_LIB=1 + . "$1" + set +e + progress_phase build + error "first +second" + `; + return spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], { + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, ...env }, + }); + } + + it('prints protocol lines on stderr, one line each, so a $( ) capture stays clean', () => { + const run = driveProtocol({ CODEMAN_PROGRESS: '1' }); + expect(run.stdout).toBe(''); + expect(run.stderr).toContain('@@codeman phase build\n'); + expect(run.stderr).toContain('@@codeman error first second\n'); + }); + + it('says nothing of it to a person at a terminal', () => { + expect(driveProtocol({ CODEMAN_PROGRESS: '0' }).stderr).not.toContain('@@codeman'); + }); + + it('never prints the password', () => { + expect(mainBody()).not.toMatch(/progress_phase.*PASSWORD/); + expect(fnBody('error')).not.toMatch(/PASSWORD/); + }); + + function fnBody(name: string) { + const start = SOURCE.indexOf(`${name}() {`); + return SOURCE.slice(start, SOURCE.indexOf('\n}\n', start)); + } +}); + describe('install.sh detect_all_clis and a disabled entry', () => { // No stock entry ships disabled today, so this is characterization rather than a regression // pin on real data: it drives the real function in a real bash with entry 0 fabricated