diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bea9d6d..cb9c00ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,35 @@ 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. These + # child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the + # sourcing guard and print nothing. Each check ends in `|| exit 1`, because + # bash 3.2 does not let a false [[ ]] trip set -e. + out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin) + [[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; } + json_string "a\"b\\c" >/tmp/json-out + [[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; } + echo "bash $BASH_VERSION: preflight prints JSON" + # How the desktop app adds an agent: a real run of the subcommand, which stops + # on the missing npm before it could install anything. + if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then + echo "agent ran with no npm to run"; exit 1 + fi + [[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; } + echo "bash $BASH_VERSION: agent stops without saying done" ' # 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 f729ae40..932dca02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -388,6 +388,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`, `install.sh agent ` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, 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 5d67547d..f3fb427d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1355,6 +1355,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. `install.sh agent ` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and only the CLI management routes clear that, so the app says so on its last screen and never restarts the service for it. ⚠️ `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 364dadbe..08174e83 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -78,6 +78,8 @@ 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 +install.sh agent # install one agent CLI and nothing else, also next to a running Codeman ``` **Flags** answer the questions from the command line and pipe through `bash -s --`: @@ -94,10 +96,18 @@ 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. +To add an agent later, `install.sh agent codex` installs that one and changes nothing else: +it does not touch Codeman or its service, and an agent that is already there is left alone. +Codeman Desktop does the same from *Add a coding agent*. A running Codeman can take a few +minutes to notice the new agent; reload the page if it does not show up. **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 0a5fa2a5..1dedef07 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 @@ -165,6 +172,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, @@ -272,6 +285,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() { @@ -748,6 +779,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 @@ -785,6 +827,118 @@ 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 +} + +# run_offered_cli : run the trusted command of one entry and look again +# at what is installed. What that means is the caller's to judge. +run_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 + return 0 +} + +# install_offered_cli : the same during an install, where a chosen CLI +# that leaves no CLI at all behind is fatal. +install_offered_cli() { + run_offered_cli "$1" + 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 +} + +# Install one AI CLI and nothing else (`install.sh agent `): how the desktop +# app adds an agent once Codeman is here, also over a running server, which this +# never touches. It judges the run itself, because install_offered_cli cannot: its +# gate is fatal only when NO CLI is left, and here another one usually is, so a +# failed install would report success, and its message is about re-running the +# installer. A CLI that is here already is a success, and installs nothing. +agent_subcommand() { + if [[ -z "$AI_CLI_CHOICE" || "$AI_CLI_CHOICE" == "none" ]]; then + die "Name the AI CLI to install: install.sh agent (one of: $(cli_catalog_ids))." + fi + check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first." + detect_all_clis + progress_phase agent + + local idx offerable=0 + idx=$(cli_index "$AI_CLI_CHOICE") + if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then + success "${CLI_LABELS[$idx]} is already installed at ${CLI_FOUND_PATH[$idx]}" + progress_phase done + return 0 + fi + cli_offerable "$idx" || offerable=$? + if [[ "$offerable" -ne 0 ]]; then + die "${CLI_LABELS[$idx]} cannot be installed automatically here. See ${CLI_DOCS[$idx]:-its documentation}." + fi + # What this run sees is a login shell's PATH at best: a Node.js that only an + # interactive profile loads (nvm in .zshrc) is not on it. + if [[ "${CLI_INSTALL_CMD_TRUSTED[$idx]}" == npm\ * ]] && ! check_npm; then + die "${CLI_LABELS[$idx]} installs with npm, and this run cannot find it. Run this in a terminal instead: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" + fi + + run_offered_cli "$idx" + if [[ -z "${CLI_FOUND_PATH[$idx]}" ]]; then + die "${CLI_LABELS[$idx]} did not install. You can run its installer yourself: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" + fi + progress_phase done + 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 @@ -798,7 +952,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 @@ -810,36 +967,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 @@ -888,22 +1047,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 } @@ -2033,7 +2177,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" } @@ -3723,6 +3868,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 @@ -3732,6 +3878,7 @@ main() { CLI_DETECT_DONE="" detect_all_clis if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then + progress_phase agent offer_ai_cli_install fi @@ -3746,8 +3893,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 @@ -3758,6 +3908,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 @@ -3782,6 +3933,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 @@ -4520,6 +4672,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 @@ -4536,6 +4747,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 @@ -4545,11 +4758,13 @@ 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 + agent Install one AI CLI and nothing else, also next to a running Codeman 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 or 0, CODEMAN_TARBALL_URL. +CODEMAN_NO_ADMIN=1 or 0, CODEMAN_TARBALL_URL, CODEMAN_AI_CLI, CODEMAN_PROGRESS=1. EOF } @@ -4577,6 +4792,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" @@ -4588,13 +4809,15 @@ 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|agent) [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." SUBCOMMAND="$1" ;; -*) die "Unknown option: $1 (see --help)" ;; *) if [[ "$SUBCOMMAND" == "name" && -z "$SUBCOMMAND_ARG" ]]; then SUBCOMMAND_ARG="$1" + elif [[ "$SUBCOMMAND" == "agent" && -z "$AI_CLI_CHOICE" ]]; then + AI_CLI_CHOICE="$1" else die "Unexpected argument: $1 (see --help)" fi ;; @@ -4604,6 +4827,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 } @@ -4628,6 +4854,8 @@ case "$SUBCOMMAND" in name) setup_name_subcommand ;; status) status_subcommand ;; cloudflared) cloudflared_subcommand ;; + preflight) preflight_subcommand ;; + agent) agent_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 34043a5c..8f487fe4 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -28,9 +28,9 @@ 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. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. 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). The tab also finds every Codeman server of yours that already runs on this Mac (with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, a beta instance beside the installed one) and recommends connecting to it. Each one's address is worked out from its listening socket and its command line (`127.0.0.1` for a bind to loopback or to every interface, its own address for a bind to one, `https` with `--https`, the path of `--base-url`), and each is asked once whether Codeman answers there. When nothing answers at the usual address (the LaunchAgent's, or `http://127.0.0.1:3000` without one), *Open Codeman* is for the first found server that does; when none of them can be opened, the tab says *Codeman is already running on this Mac* and lists them. Next to a Codeman that answers, the others are listed under *Also running on this Mac*. A row shows the address, the version, and a *Connect* button, or the reason the app cannot open that one (HTTPS with Codeman's own certificate, no answer). *Connect* is the connect of the Another machine tab with that address filled in: the same check, the login saved for it, the same entry under recent servers. Finding a server connects to nothing by itself. The tab never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. 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. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. | | **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. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. 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. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | +| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. 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. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac (or connects to the one found here, when that is the tab's main button) and Esc cancels a connect, as in a Mac dialog (Install always takes a click). 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. | | **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. | | **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. | @@ -62,6 +62,11 @@ root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives Content-Security-Policy the app serves it with. It is part of the nightly browser suite, not of `npm test`. +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 @@ -141,19 +146,52 @@ 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. + +**Adding an agent is a run of its own.** `install.sh agent ` installs one +agent and touches neither Codeman nor its service, so it may run next to a live +server, which an install must refuse: the installer's service step would replace +that server under its sessions. It has its own temporary folder, so `ps` tells the +two apart: everything that reads a running install takes it to mean "Codeman is +being installed", which an agent run does not. One run goes at a time, since both +can write to the runtime folder. It asks `preflight` for the list like an install +does, and both pass `--no-admin`: that flag decides which agents count as +installable, so without it on both the page could offer one the run refuses. The +menu item is on the connect page because that is where the app may run things: +from a Codeman page it leaves the page the way *Switch Server* does. + +**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 @@ -169,7 +207,8 @@ with Codeman stopped, whatever else listens there never gets it. data directory and tmux socket attach to each other's live sessions, and `codeman web -d` only notices a server it started itself or one at the same address. So the app looks for running Codeman servers of this user (`ps`) -before it offers or runs a start, and opens the one it finds instead. +before it offers or runs a start, and offers to connect to the ones it finds +instead (`found_addresses` in `src/setup.rs`, unit-tested). **Closing the main window quits the app.** Codeman's sessions keep running on the server, so nothing is lost, and reopening takes about a second. @@ -177,7 +216,9 @@ the server, so nothing is lost, and reopening takes about a second. **One reachability probe per user action, and one to check a refused login.** Codeman counts a request without valid credentials against its login rate limit (10 per IP, 15 minutes; a successful login resets the count), so the app -never retries in a loop. Once the server refuses the login in a challenge, the +never retries in a loop. A look at this Mac asks each Codeman server found +here once; the app has a login only for the one at the usual address, so the +others are asked without one. Once the server refuses the login in a challenge, the app sends it only once more, in the single probe that checks the refusal. If that probe is refused or rate limited, the connect screen comes back; the app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server @@ -239,6 +280,31 @@ crosses, so the login is never on a command line. ## Known limits +- **A new agent can take a few minutes to show up in a running Codeman.** The + server remembers that a CLI is missing, for up to 5 minutes after it last looked + (`cliResolveRetryDelayMs`), so that a status poll never spawns a login shell. The + app cannot clear that from outside, so the screen after *Add a coding agent* says + so. An open Codeman page also needs a reload, because the list of installed + agents is part of the page. +- **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS + install is Homebrew, so it is not offered, also on a Mac that has Homebrew. +- **Agents that install with npm use whichever Node.js the login shell finds.** + With the Node.js the installer downloaded, or Homebrew's, that folder is yours. + The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those + agents would be expected to fail there, with npm's permission error shown, while + the ones with their own installer still work. This has not been tried. +- **Only your own Codeman servers on this Mac are found, by their command + line.** A server another macOS user runs, or one in a container or a virtual + machine, is not listed: connect to it by address under Another machine. A + base path that a server started by hand has only in its environment + (`CODEMAN_BASE_URL`) is not visible either, so its row has the address + without the path; the LaunchAgent's base path is read from its job. +- **A found server that is slow to answer shows as not answering.** Next to a + Codeman that answers, each other server gets two seconds (five when nothing + answers at the usual address). A password-protected one counts the + look as a failed login, like any look at a server the app has no login for. + *Check again* asks again. + - **The logo blink rests on a private macOS interface.** Apple documents the setting, not the way the app reaches it, so a macOS update can take it away; the menu then disappears. Every change of the logo writes `LEBacklightMode`, @@ -319,6 +385,20 @@ crosses, so the login is never on a command line. - **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 30b1ad89..c6196f68 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -125,7 +125,20 @@ @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; } + /* The Codeman servers found on this Mac: a row each, in the look of a choice + in the install, with the row's own Connect button. */ + #local-found-note { margin: 8px 0 0; } + #local-also { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } + #local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; } + .found { + display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px; + border: 1px solid var(--line-strong); background: #0f1013; + } + .found + .found { margin-top: 6px; } + .found > span { flex: 1; min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; } + .found b { display: block; font-weight: 600; } + .found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; } + .found button { flex: none; } #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } #elsewhere[hidden] { display: none; } #elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; } @@ -143,6 +156,82 @@ .statusrow[hidden] { display: none; } .statusrow #status { flex: 1; } .statusrow #cancel { margin-top: 10px; } + /* The graphical install takes over the card. Its steps differ in height, and + the page is anchored from the top (see `main`), so the logo stays put. */ + 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 > #elsewhere, + .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; } + .choice:has(input:disabled) { border-color: var(--line); background: none; color: var(--muted); } + #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: var(--muted); font-size: 12px; } footer a { color: #aab1bb; text-decoration: none; cursor: default; } footer a:hover { color: var(--accent); } @@ -163,6 +252,45 @@ + +
@@ -173,7 +301,8 @@
- + +
@@ -181,6 +310,11 @@
+ +