mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge #773 feat(desktop): install Codeman from the app, with no Terminal and no password
Conflicts resolved as on test/desktop-macbook: install.sh usage() lists CODEMAN_NO_ADMIN=1 or 0 and #773's CODEMAN_AI_CLI and CODEMAN_PROGRESS=1; probe.rs keeps #781's agent() with TIMEOUT, MAX_BODY and probe_within all pub; the desktop README keeps both known-limits entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4b349b57f6
16 files changed
+4699
-227
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 <id>|none`, `install.sh preflight`, `install.sh agent <id>` 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.
|
||||
@@ -1355,6 +1355,8 @@ On this route Node.js comes from nodejs.org (`latest-v<major>.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 <id>|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 <id>` 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/<ref>.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 `<install dir>.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
|
||||
|
||||
@@ -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 [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
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 <id> # 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 <n>` 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 <id>` 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.
|
||||
|
||||
+274
-46
@@ -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 <id>|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 [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
# 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 <name>: tell the desktop app which part of the install has
|
||||
# begun. The protocol is one line, `@@codeman phase <name>`, for each of
|
||||
# runtime, agent, source, packages, build, service and done, always in that
|
||||
# order, and `@@codeman error <text>` 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 <id>: 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 <index>: 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 <index>: 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: under `curl | bash` a child that reads stdin would
|
||||
# consume the rest of this script.
|
||||
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
|
||||
hash -r 2>/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 <index>: 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 <id>`): 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 <id> (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: under `curl | bash` a child that reads stdin would
|
||||
# consume the rest of this script.
|
||||
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
|
||||
hash -r 2>/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 <text>: <text> 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 <n> 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 <id>|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 [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
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 <id> 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
|
||||
|
||||
+97
-17
@@ -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://<host>: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:<port>` or `http://localhost:<port>` 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 <id>` 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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -91,10 +91,10 @@ fn flavor(shell: &Path) -> Flavor {
|
||||
}
|
||||
|
||||
/// What one process printed, stdout and stderr kept apart.
|
||||
struct Output {
|
||||
code: Option<i32>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
pub struct Output {
|
||||
pub code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
|
||||
@@ -143,8 +143,18 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, 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<Output, String> {
|
||||
/// `exec_bounded` for a start.
|
||||
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
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<Output, String> {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
@@ -168,7 +178,7 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
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()),
|
||||
}
|
||||
@@ -275,6 +285,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<Vec<(String, String)>> {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Run `cmd` in the home folder, where a terminal and the LaunchAgent start
|
||||
/// Codeman too. The server keeps the directory it was started in and uses it
|
||||
/// as the default for a session that names none; an app opened from the Finder
|
||||
|
||||
@@ -24,6 +24,7 @@ mod agents;
|
||||
mod assets;
|
||||
mod config;
|
||||
mod credentials;
|
||||
mod gui_install;
|
||||
mod local_server;
|
||||
mod logo;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -37,7 +38,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};
|
||||
@@ -105,6 +107,7 @@ const START_REFUSED: &str = "Codeman can only be started here for an http://127.
|
||||
|
||||
mod menu_id {
|
||||
pub const SWITCH: &str = "switch-server";
|
||||
pub const ADD_AGENT: &str = "add-agent";
|
||||
pub const RELOAD: &str = "reload";
|
||||
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
||||
pub const DEVTOOLS: &str = "devtools";
|
||||
@@ -294,6 +297,11 @@ 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<AtomicU64>,
|
||||
/// The connect page opens the agent chooser when it next loads: the menu
|
||||
/// asked for it from a Codeman page.
|
||||
add_agent_on_load: bool,
|
||||
/// 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)>,
|
||||
@@ -386,6 +394,8 @@ fn main() {
|
||||
downloads: HashMap::new(),
|
||||
menu,
|
||||
keychain,
|
||||
install_reader: Arc::new(AtomicU64::new(0)),
|
||||
add_agent_on_load: false,
|
||||
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
||||
logo_watch: logo::Watcher::default(),
|
||||
};
|
||||
@@ -423,6 +433,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&switch,
|
||||
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
@@ -1179,6 +1190,7 @@ impl App {
|
||||
}
|
||||
match id {
|
||||
menu_id::SWITCH => self.show_connect_page(None, true),
|
||||
menu_id::ADD_AGENT => self.add_agent(),
|
||||
menu_id::RELOAD => self.reload_focused(),
|
||||
menu_id::START_FULLSCREEN => {
|
||||
let value = self.menu.start_fullscreen.is_checked();
|
||||
@@ -1316,6 +1328,76 @@ 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(gui_install::Run::Codeman, stale, send);
|
||||
}
|
||||
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ConnectCmd::InstallAttach => {
|
||||
if choosing {
|
||||
self.attach_to_run(gui_install::Run::Codeman);
|
||||
}
|
||||
}
|
||||
ConnectCmd::InstallCancel => {
|
||||
std::thread::spawn(gui_install::cancel);
|
||||
}
|
||||
ConnectCmd::AgentPrepare => {
|
||||
if choosing {
|
||||
self.in_background(|| match gui_install::prepare_agent() {
|
||||
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
|
||||
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
|
||||
});
|
||||
}
|
||||
}
|
||||
ConnectCmd::AgentStart { ai_cli } => {
|
||||
if choosing {
|
||||
let proxy = self.proxy.clone();
|
||||
let stale = self.next_install_reader();
|
||||
std::thread::spawn(move || {
|
||||
let send = |msg| {
|
||||
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
|
||||
};
|
||||
match gui_install::start_agent(&ai_cli) {
|
||||
Ok(()) => gui_install::follow(gui_install::Run::Agent, stale, send),
|
||||
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ConnectCmd::AgentAttach => {
|
||||
if choosing {
|
||||
self.attach_to_run(gui_install::Run::Agent);
|
||||
}
|
||||
}
|
||||
ConnectCmd::InstallRemote { target } => {
|
||||
if choosing {
|
||||
match setup::SshTarget::parse(&target) {
|
||||
@@ -1347,6 +1429,40 @@ 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.
|
||||
/// Report a run that is going already to the page, from its first line.
|
||||
fn attach_to_run(&self, run: gui_install::Run) {
|
||||
let proxy = self.proxy.clone();
|
||||
let stale = self.next_install_reader();
|
||||
std::thread::spawn(move || {
|
||||
gui_install::follow(run, stale, |msg| {
|
||||
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The agent chooser lives on the connect page: from a Codeman page, this
|
||||
/// leaves it the way Switch Server does.
|
||||
fn add_agent(&mut self) {
|
||||
if matches!(*self.phase.borrow(), Phase::Connect) {
|
||||
if let Some(main) = self.windows.get(&self.main_id) {
|
||||
main.window.set_visible(true);
|
||||
main.window.set_focus();
|
||||
}
|
||||
self.to_connect_page(json!({ "type": "addAgent" }));
|
||||
} else {
|
||||
self.add_agent_on_load = true;
|
||||
self.show_connect_page(None, true);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1358,6 +1474,7 @@ impl App {
|
||||
|
||||
fn send_init(&mut self) {
|
||||
let notice = self.notice.take();
|
||||
let add_agent = std::mem::take(&mut self.add_agent_on_load);
|
||||
self.to_connect_page(json!({
|
||||
"type": "init",
|
||||
"servers": self.config.servers,
|
||||
@@ -1366,6 +1483,7 @@ impl App {
|
||||
"version": VERSION,
|
||||
"starting": self.start_running(),
|
||||
"notice": notice,
|
||||
"addAgent": add_agent,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -609,6 +609,27 @@ 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<String>,
|
||||
ai_cli: Option<String>,
|
||||
},
|
||||
/// Show the graphical install that is running already.
|
||||
InstallAttach,
|
||||
/// Stop whichever graphical run is going.
|
||||
InstallCancel,
|
||||
/// Fetch the installer to add a coding agent to a Mac that has Codeman.
|
||||
AgentPrepare,
|
||||
/// Add this agent: an id in the shape of the installer's catalogue.
|
||||
AgentStart {
|
||||
ai_cli: String,
|
||||
},
|
||||
/// Show the agent install that is running already.
|
||||
AgentAttach,
|
||||
/// Run the installer on another machine over SSH, in Terminal. `target`
|
||||
/// is what the user typed; setup::SshTarget::parse checks it.
|
||||
InstallRemote {
|
||||
@@ -663,6 +684,23 @@ 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<String>,
|
||||
#[serde(default, rename = "aiCli")]
|
||||
ai_cli: Option<String>,
|
||||
},
|
||||
InstallAttach,
|
||||
InstallCancel,
|
||||
AgentPrepare,
|
||||
AgentStart {
|
||||
#[serde(rename = "aiCli")]
|
||||
ai_cli: String,
|
||||
},
|
||||
AgentAttach,
|
||||
InstallRemote {
|
||||
target: String,
|
||||
},
|
||||
@@ -679,6 +717,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<String>, max: usize) -> Option<Option<String>> {
|
||||
match s {
|
||||
Some(v) if v.len() > max => None,
|
||||
@@ -736,6 +785,24 @@ 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::AgentPrepare => ConnectCmd::AgentPrepare,
|
||||
RawConnect::AgentStart { ai_cli } if is_cli_id(&ai_cli) => ConnectCmd::AgentStart { ai_cli },
|
||||
RawConnect::AgentAttach => ConnectCmd::AgentAttach,
|
||||
RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => {
|
||||
ConnectCmd::InstallRemote { target }
|
||||
}
|
||||
@@ -1599,6 +1666,111 @@ 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_adding_a_coding_agent() {
|
||||
let page = "codemanapp://localhost/index.html";
|
||||
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
|
||||
assert_eq!(
|
||||
c(r#"{"cmd":"agentPrepare"}"#),
|
||||
Some(Ipc::Connect(ConnectCmd::AgentPrepare))
|
||||
);
|
||||
assert_eq!(
|
||||
c(r#"{"cmd":"agentAttach"}"#),
|
||||
Some(Ipc::Connect(ConnectCmd::AgentAttach))
|
||||
);
|
||||
assert_eq!(
|
||||
c(r#"{"cmd":"agentStart","aiCli":"codex"}"#),
|
||||
Some(Ipc::Connect(ConnectCmd::AgentStart { ai_cli: "codex".into() }))
|
||||
);
|
||||
// It names one agent, in the shape of an id, and nothing else.
|
||||
assert_eq!(c(r#"{"cmd":"agentStart"}"#), None);
|
||||
for id in ["--service", "-y", "Codex", "a b", "a;b", "", "a/b"] {
|
||||
assert_eq!(c(&format!(r#"{{"cmd":"agentStart","aiCli":"{id}"}}"#)), None, "{id}");
|
||||
}
|
||||
assert_eq!(c(r#"{"cmd":"agentStart","aiCli":"codex","port":3000}"#), None);
|
||||
assert_eq!(
|
||||
parse_ipc(page, r#"{"cmd":"agentStart","aiCli":"codex"}"#, &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":"agentStart","aiCli":"codex"}"#,
|
||||
&loaded,
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipc_from_connect_page() {
|
||||
let page = "codemanapp://localhost/index.html";
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
@@ -249,7 +249,7 @@ pub fn agent(timeout: Duration) -> ureq::Agent {
|
||||
}
|
||||
|
||||
/// `probe` with the time the whole exchange may take.
|
||||
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
pub fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
|
||||
+627
-104
@@ -1,12 +1,13 @@
|
||||
//! 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, which
|
||||
//! other Codeman servers of this user run here, 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 +25,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.
|
||||
@@ -83,46 +94,65 @@ pub fn parse_service(job: &serde_json::Value) -> ServiceConfig {
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut port = var("CODEMAN_PORT");
|
||||
let mut base = var("CODEMAN_BASE_URL");
|
||||
let mut https = false;
|
||||
// Only what follows the `web` subcommand: node's own flags come before it.
|
||||
let after_web = args
|
||||
.iter()
|
||||
.position(|a| *a == "web")
|
||||
.map_or(&[][..], |i| &args[i + 1..]);
|
||||
let mut it = after_web.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
match *arg {
|
||||
"-p" | "--port" => port = it.next().map(|s| s.to_string()),
|
||||
"--base-url" => base = it.next().map(|s| s.to_string()),
|
||||
"--https" => https = true,
|
||||
// Options that take a value: skip it, so it is never read as a flag.
|
||||
"-H" | "--host" | "--title-hostname" => {
|
||||
it.next();
|
||||
}
|
||||
other => {
|
||||
if let Some(v) = other.strip_prefix("--port=") {
|
||||
port = Some(v.to_string());
|
||||
} else if let Some(v) = other.strip_prefix("--base-url=") {
|
||||
base = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let flags = WebFlags {
|
||||
port: var("CODEMAN_PORT"),
|
||||
base: var("CODEMAN_BASE_URL"),
|
||||
https: false,
|
||||
}
|
||||
.read(after_web);
|
||||
ServiceConfig {
|
||||
port: port
|
||||
port: flags
|
||||
.port
|
||||
.and_then(|p| p.trim().parse::<u16>().ok())
|
||||
.filter(|p| *p > 0)
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
base_path: normalize_base_path(base.as_deref().unwrap_or("")),
|
||||
https,
|
||||
base_path: normalize_base_path(flags.base.as_deref().unwrap_or("")),
|
||||
https: flags.https,
|
||||
username: var("CODEMAN_USERNAME"),
|
||||
password: var("CODEMAN_PASSWORD"),
|
||||
daemon: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What Codeman's `web` command takes from the arguments after `web`
|
||||
/// (src/cli.ts). The fields start as what the environment says, or empty.
|
||||
#[derive(Debug, Default)]
|
||||
struct WebFlags {
|
||||
port: Option<String>,
|
||||
base: Option<String>,
|
||||
https: bool,
|
||||
}
|
||||
|
||||
impl WebFlags {
|
||||
fn read(mut self, after_web: &[&str]) -> Self {
|
||||
let mut it = after_web.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
match *arg {
|
||||
"-p" | "--port" => self.port = it.next().map(|s| s.to_string()),
|
||||
"--base-url" => self.base = it.next().map(|s| s.to_string()),
|
||||
"--https" => self.https = true,
|
||||
// Options that take a value: skip it, so it is never read as a flag.
|
||||
"-H" | "--host" | "--title-hostname" => {
|
||||
it.next();
|
||||
}
|
||||
other => {
|
||||
if let Some(v) = other.strip_prefix("--port=") {
|
||||
self.port = Some(v.to_string());
|
||||
} else if let Some(v) = other.strip_prefix("--base-url=") {
|
||||
self.base = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Codeman's `normalizeBasePath`, plus its character check: a value Codeman
|
||||
/// would refuse at startup counts as the root here.
|
||||
fn normalize_base_path(input: &str) -> String {
|
||||
@@ -289,7 +319,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())
|
||||
@@ -315,6 +345,12 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> {
|
||||
/// `install.sh` clone's `dist/index.js web`, a source checkout's
|
||||
/// `src/index.ts web`), not one of its short-lived control commands.
|
||||
pub fn is_codeman_server(command: &str) -> bool {
|
||||
server_args(command).is_some()
|
||||
}
|
||||
|
||||
/// What follows `web` on a Codeman server's command line, or None for any
|
||||
/// other command.
|
||||
fn server_args(command: &str) -> Option<Vec<&str>> {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let is_entry = |w: &str| {
|
||||
let name = w.rsplit('/').next().unwrap_or(w);
|
||||
@@ -322,16 +358,18 @@ pub fn is_codeman_server(command: &str) -> bool {
|
||||
};
|
||||
// Any entry word that `web` follows: an earlier word may only look like
|
||||
// one (a folder named codeman in `tsx watch --cwd ~/codeman src/index.ts web`).
|
||||
(0..words.len()).any(|i| {
|
||||
is_entry(words[i])
|
||||
&& words.get(i + 1) == Some(&"web")
|
||||
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
|
||||
})
|
||||
(0..words.len())
|
||||
.find(|&i| {
|
||||
is_entry(words[i])
|
||||
&& words.get(i + 1) == Some(&"web")
|
||||
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
|
||||
})
|
||||
.map(|i| words[i + 2..].to_vec())
|
||||
}
|
||||
|
||||
/// 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<Option<u16>> {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
|
||||
@@ -341,7 +379,7 @@ pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
|
||||
}) {
|
||||
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::Run::Codeman.matches(command) {
|
||||
let port = words
|
||||
.iter()
|
||||
.position(|w| *w == "--port")
|
||||
@@ -428,14 +466,138 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool {
|
||||
.any(|l| l.port == port && matches!(l.host.as_str(), "*" | "127.0.0.1" | "0.0.0.0"))
|
||||
}
|
||||
|
||||
/// The address a listener is reached at: 127.0.0.1 for a loopback or
|
||||
/// all-interfaces bind, else its own address.
|
||||
fn listener_url(l: &Listener) -> Option<Url> {
|
||||
let host = match l.host.as_str() {
|
||||
"*" | "0.0.0.0" => "127.0.0.1",
|
||||
h => h,
|
||||
/// Where the app can try each of this user's Codeman servers, from their
|
||||
/// command lines (`procs`) and listening sockets. `default` is the address the
|
||||
/// app opens by default (local_url); it comes first, then the others by port.
|
||||
///
|
||||
/// One address per server and port: 127.0.0.1 when the server listens there
|
||||
/// or on every interface, `[::1]` when its only loopback socket is the IPv6
|
||||
/// one, else each address it is bound to. A socket beside a loopback one on
|
||||
/// the same port is left out (the Docker bridge listener, which serves hook
|
||||
/// endpoints only). `https` when the command line carries `--https`, and its
|
||||
/// `--base-url` is the path. `ps` does not show a base path kept in the
|
||||
/// environment, which is where install.sh's launchd job keeps it, so a server
|
||||
/// at the default address with no `--base-url` of its own is the default one.
|
||||
pub fn found_addresses(procs: &[(u32, String)], listeners: &[Listener], default: &Url) -> Vec<Url> {
|
||||
let mut urls: Vec<Url> = Vec::new();
|
||||
for (pid, command) in procs {
|
||||
let Some(args) = server_args(command) else {
|
||||
continue;
|
||||
};
|
||||
let flags = WebFlags::default().read(&args);
|
||||
let scheme = if flags.https { "https" } else { "http" };
|
||||
let base = flags.base.as_deref().map(normalize_base_path);
|
||||
let own: Vec<&Listener> = listeners.iter().filter(|l| l.pid == *pid).collect();
|
||||
for l in &own {
|
||||
let beside = |hosts: &[&str]| own.iter().any(|o| o.port == l.port && hosts.contains(&o.host.as_str()));
|
||||
let host = if beside(&["*", "0.0.0.0", "[::]", "127.0.0.1"]) {
|
||||
"127.0.0.1"
|
||||
} else if beside(&["[::1]"]) {
|
||||
"[::1]"
|
||||
} else {
|
||||
l.host.as_str()
|
||||
};
|
||||
let path = base.as_deref().unwrap_or("");
|
||||
let Ok(mut url) = Url::parse(&format!("{scheme}://{host}:{}{path}/", l.port)) else {
|
||||
continue;
|
||||
};
|
||||
if base.is_none() && policy::same_origin(&url, default) {
|
||||
url = default.clone();
|
||||
}
|
||||
if !urls.contains(&url) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
urls.sort_by_key(|u| (u != default, u.port_or_known_default(), u.to_string()));
|
||||
urls
|
||||
}
|
||||
|
||||
/// What the This Mac tab says about one found server.
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FoundState {
|
||||
/// Codeman answers there: Connect opens it.
|
||||
Running,
|
||||
/// Codeman answers there and wants a login.
|
||||
NeedsLogin,
|
||||
/// It serves HTTPS with a certificate the app refuses (Codeman's own
|
||||
/// self-signed one, from `codeman web --https`).
|
||||
Certificate,
|
||||
/// Codeman does not answer there.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// One of this user's Codeman servers on this Mac, for the This Mac tab's
|
||||
/// rows.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FoundServer {
|
||||
/// Where the app can try it (found_addresses).
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
pub state: FoundState,
|
||||
/// It is at the address the app opens by default (local_url).
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// The row for the server at `url`, from what it answered.
|
||||
fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer {
|
||||
let state = match answer {
|
||||
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => FoundState::Running,
|
||||
_ if answers(answer) => FoundState::NeedsLogin,
|
||||
// A Codeman started with --https closes a plain http connection.
|
||||
ProbeOutcome::TlsUntrusted { .. } | ProbeOutcome::MaybeHttps => FoundState::Certificate,
|
||||
_ => FoundState::Unreachable,
|
||||
};
|
||||
Url::parse(&format!("http://{host}:{}/", l.port)).ok()
|
||||
FoundServer {
|
||||
url: url.to_string(),
|
||||
version: match answer {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
},
|
||||
state,
|
||||
is_default: url == default,
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a found server gets to answer when the tab only lists it.
|
||||
const FOUND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ask the servers at `urls`, all at the same time, so the look takes as long
|
||||
/// as the slowest answer and no longer. Without a login: the one the app can
|
||||
/// read belongs to the default address (login_for).
|
||||
fn ask_found(urls: &[Url], patience: Duration) -> Vec<ProbeOutcome> {
|
||||
std::thread::scope(|scope| {
|
||||
let asking: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| scope.spawn(move || probe::probe_within(url, None, patience)))
|
||||
.collect();
|
||||
asking
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
a.join().unwrap_or_else(|_| ProbeOutcome::Failed {
|
||||
detail: "The check stopped.".into(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The address the This Mac tab is about, with its answer: the default one,
|
||||
/// or, when nothing answers there, the first found server that does (a
|
||||
/// `codeman web --port 4000` in a terminal, a bind to one network address).
|
||||
fn shown<'a>(
|
||||
default: &'a Url,
|
||||
answer: &'a ProbeOutcome,
|
||||
found: &'a [(Url, ProbeOutcome)],
|
||||
) -> (&'a Url, &'a ProbeOutcome) {
|
||||
if *answer == ProbeOutcome::Refused {
|
||||
if let Some((url, answer)) = found.iter().find(|(_, a)| answers(a)) {
|
||||
return (url, answer);
|
||||
}
|
||||
}
|
||||
(default, answer)
|
||||
}
|
||||
|
||||
/// What the This Mac tab shows.
|
||||
@@ -449,8 +611,8 @@ pub enum LocalState {
|
||||
/// A Codeman server of this user runs but listens nowhere yet: it is
|
||||
/// still starting (the LaunchAgent at login, a restart).
|
||||
Starting,
|
||||
/// A Codeman server of this user runs, but not at an address the app can
|
||||
/// open (bound to one network address, say): see `elsewhere`.
|
||||
/// A Codeman server of this user runs, but none answers at an address the
|
||||
/// app can open (HTTPS with Codeman's own certificate, say): see `found`.
|
||||
RunningElsewhere,
|
||||
/// Nothing answers, and Codeman is installed.
|
||||
Stopped,
|
||||
@@ -478,12 +640,19 @@ 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<u16>,
|
||||
/// For RunningElsewhere: the addresses that Codeman listens on.
|
||||
pub elsewhere: Vec<String>,
|
||||
/// 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,
|
||||
/// A coding agent is being added the same way. Not an install: it says
|
||||
/// nothing about Codeman, which may well be running.
|
||||
pub agent_install_in_app: bool,
|
||||
/// Every Codeman server of this user that listens on this Mac, the one at
|
||||
/// the default address first: the tab offers to connect to each.
|
||||
pub found: Vec<FoundServer>,
|
||||
/// The probe's outcome, for the connect page's explanations.
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
@@ -530,55 +699,44 @@ pub enum Depth {
|
||||
pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let service = read_service();
|
||||
let url = local_url(service.as_ref());
|
||||
let default = local_url(service.as_ref());
|
||||
let procs = list_processes();
|
||||
let servers = codeman_servers(&procs);
|
||||
let found = listeners(&servers);
|
||||
let listening = listeners(&servers);
|
||||
let install = running_install(&procs);
|
||||
let install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Codeman.matches(c));
|
||||
let agent_install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Agent.matches(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)) {
|
||||
login_for(&url, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
|
||||
let auth = if serves_loopback_port(&listening, default.port_or_known_default().unwrap_or(0)) {
|
||||
login_for(&default, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut outcome = probe::probe(&url, auth.as_deref());
|
||||
let mut url = url;
|
||||
let mut elsewhere = Vec::new();
|
||||
let answer = probe::probe(&default, auth.as_deref());
|
||||
|
||||
// Nothing at the usual address, but a Codeman server of this user runs:
|
||||
// find where (a `codeman web --port 4000` in a terminal, a bind to one
|
||||
// network address), so the tab never offers to start a second server on
|
||||
// the same sessions.
|
||||
if outcome == ProbeOutcome::Refused && !servers.is_empty() {
|
||||
let mut ports_seen = Vec::new();
|
||||
for l in &found {
|
||||
if ports_seen.contains(&(l.host.clone(), l.port)) {
|
||||
continue;
|
||||
}
|
||||
ports_seen.push((l.host.clone(), l.port));
|
||||
let Some(candidate) = listener_url(l) else {
|
||||
continue;
|
||||
};
|
||||
let answer = probe::probe(&candidate, None);
|
||||
if matches!(
|
||||
answer,
|
||||
ProbeOutcome::Ok { .. }
|
||||
| ProbeOutcome::NeedsLogin
|
||||
| ProbeOutcome::BadCredentials
|
||||
| ProbeOutcome::RateLimited { .. }
|
||||
| ProbeOutcome::PasswordChangeRequired
|
||||
) {
|
||||
url = candidate;
|
||||
outcome = answer;
|
||||
break;
|
||||
}
|
||||
elsewhere.push(format!("{}:{}", l.host, l.port));
|
||||
}
|
||||
if outcome == ProbeOutcome::Refused && elsewhere.is_empty() {
|
||||
elsewhere = servers.iter().map(|pid| format!("process {pid}")).collect();
|
||||
}
|
||||
// Every address one of this user's Codeman servers listens at. The one at
|
||||
// the default address has just answered; the others are asked now, once
|
||||
// each. With nothing at the default address, the first of them that
|
||||
// answers is what the tab opens, as it always was, so each gets the
|
||||
// probe's usual time; otherwise they are only listed, and get less.
|
||||
let patience = if answer == ProbeOutcome::Refused {
|
||||
probe::TIMEOUT
|
||||
} else {
|
||||
FOUND_TIMEOUT
|
||||
};
|
||||
let addresses = found_addresses(&procs, &listening, &default);
|
||||
let others: Vec<Url> = addresses.iter().filter(|u| **u != default).cloned().collect();
|
||||
let mut answered: Vec<(Url, ProbeOutcome)> = others.iter().cloned().zip(ask_found(&others, patience)).collect();
|
||||
// found_addresses puts the default address first.
|
||||
if addresses.first() == Some(&default) {
|
||||
answered.insert(0, (default.clone(), answer.clone()));
|
||||
}
|
||||
let found: Vec<FoundServer> = answered.iter().map(|(u, a)| found_server(u, a, &default)).collect();
|
||||
// The tab never offers to start a second server on the same sessions: it
|
||||
// opens the one that answers, or lists where they listen.
|
||||
let (url, outcome) = shown(&default, &answer, &answered);
|
||||
let (url, outcome) = (url.clone(), outcome.clone());
|
||||
|
||||
let app = home.join(".codeman/app");
|
||||
let install_complete = app.join(".install-complete").is_file();
|
||||
@@ -596,10 +754,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
Depth::Login => local_server::installed(false),
|
||||
Depth::Interactive => local_server::installed(true),
|
||||
};
|
||||
let state = local_state(&outcome, !servers.is_empty(), !found.is_empty(), installed);
|
||||
if state != LocalState::RunningElsewhere {
|
||||
elsewhere.clear();
|
||||
}
|
||||
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
|
||||
let version = match &outcome {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
@@ -614,7 +769,9 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
install_complete,
|
||||
install_running: install.is_some(),
|
||||
install_port: install.flatten(),
|
||||
elsewhere,
|
||||
install_in_app,
|
||||
agent_install_in_app,
|
||||
found,
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
@@ -624,7 +781,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<String> {
|
||||
pub fn already_running() -> Option<String> {
|
||||
let servers = codeman_servers(&list_processes());
|
||||
let pid = servers.first()?;
|
||||
let found = listeners(&servers);
|
||||
@@ -1389,9 +1546,375 @@ mod tests {
|
||||
assert!(serves_loopback_port(&found, 4000));
|
||||
assert!(!serves_loopback_port(&found, 3999));
|
||||
assert!(!serves_loopback_port(&found, 5000));
|
||||
assert_eq!(listener_url(&found[1]).unwrap().as_str(), "http://172.17.0.1:3999/");
|
||||
assert_eq!(listener_url(&found[2]).unwrap().as_str(), "http://127.0.0.1:4000/");
|
||||
assert_eq!(listener_url(&found[3]).unwrap().as_str(), "http://[::1]:5000/");
|
||||
// The same four sockets as the addresses the app can try, by port.
|
||||
assert_eq!(
|
||||
addresses(&procs, &found, "http://127.0.0.1:3000/"),
|
||||
[
|
||||
"http://127.0.0.1:3000/",
|
||||
"http://172.17.0.1:3999/",
|
||||
"http://127.0.0.1:4000/",
|
||||
"http://[::1]:5000/"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn server(pid: u32, flags: &str) -> (u32, String) {
|
||||
(pid, format!("node /opt/homebrew/bin/codeman web{flags}"))
|
||||
}
|
||||
|
||||
fn listener(pid: u32, host: &str, port: u16) -> Listener {
|
||||
Listener {
|
||||
pid,
|
||||
host: host.into(),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
/// found_addresses, as strings. The page sends one back in a `connect`,
|
||||
/// so each must be an address the connect takes as it is: the app saves
|
||||
/// a server, and finds its saved login, under that exact string.
|
||||
fn addresses(procs: &[(u32, String)], listeners: &[Listener], default: &str) -> Vec<String> {
|
||||
found_addresses(procs, listeners, &u(default))
|
||||
.iter()
|
||||
.map(|url| {
|
||||
assert_eq!(policy::normalize_server_url(url.as_str()).as_ref(), Ok(url));
|
||||
url.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_found_server_becomes_an_address_the_app_can_try() {
|
||||
let default = "http://127.0.0.1:3000/";
|
||||
let one = |flags: &str, listeners: &[Listener]| addresses(&[server(1, flags)], listeners, default);
|
||||
|
||||
// Every interface and IPv4 loopback are reached at 127.0.0.1.
|
||||
for host in ["*", "0.0.0.0", "[::]", "127.0.0.1"] {
|
||||
assert_eq!(
|
||||
one(" --port 4000", &[listener(1, host, 4000)]),
|
||||
["http://127.0.0.1:4000/"],
|
||||
"{host}"
|
||||
);
|
||||
}
|
||||
// IPv6 loopback keeps its own address only when it is the server's
|
||||
// only loopback socket.
|
||||
assert_eq!(one("", &[listener(1, "[::1]", 5000)]), ["http://[::1]:5000/"]);
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "[::1]", 5000), listener(1, "127.0.0.1", 5000)]),
|
||||
["http://127.0.0.1:5000/"]
|
||||
);
|
||||
// A dual-stack bind shows as two sockets, both `*`: one address.
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "*", 5000), listener(1, "*", 5000)]),
|
||||
["http://127.0.0.1:5000/"]
|
||||
);
|
||||
// A bind to one address stays that address.
|
||||
assert_eq!(
|
||||
one(" --host 192.168.1.5", &[listener(1, "192.168.1.5", 3000)]),
|
||||
["http://192.168.1.5:3000/"]
|
||||
);
|
||||
|
||||
// --https and --base-url come from the command line, in both spellings.
|
||||
assert_eq!(
|
||||
one(" --https --port 4000 --base-url /codeman/", &[listener(1, "*", 4000)]),
|
||||
["https://127.0.0.1:4000/codeman/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one(" -p 4000 --base-url=cm", &[listener(1, "127.0.0.1", 4000)]),
|
||||
["http://127.0.0.1:4000/cm/"]
|
||||
);
|
||||
// The value of --host is never read as a flag, and a base path Codeman
|
||||
// would refuse counts as none.
|
||||
assert_eq!(
|
||||
one(" --host --https -p 4000", &[listener(1, "127.0.0.1", 4000)]),
|
||||
["http://127.0.0.1:4000/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one(" -p 4000 --base-url /a?b", &[listener(1, "127.0.0.1", 4000)]),
|
||||
["http://127.0.0.1:4000/"]
|
||||
);
|
||||
// What comes before `web` is node's business.
|
||||
assert_eq!(
|
||||
addresses(
|
||||
&[(1, "node --https /Users/k/.codeman/app/dist/index.js web".to_string())],
|
||||
&[listener(1, "*", 4000)],
|
||||
default
|
||||
),
|
||||
["http://127.0.0.1:4000/"]
|
||||
);
|
||||
|
||||
// A server with two listeners. Beside a loopback socket on the same
|
||||
// port, another one is the same server (the Docker bridge listener);
|
||||
// on another port, or with no loopback socket, each is an address.
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "127.0.0.1", 3001), listener(1, "172.17.0.1", 3001)]),
|
||||
["http://127.0.0.1:3001/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "172.17.0.1", 3999), listener(1, "127.0.0.1", 3001)]),
|
||||
["http://127.0.0.1:3001/", "http://172.17.0.1:3999/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "192.168.1.5", 3001), listener(1, "100.64.0.7", 3001)]),
|
||||
["http://100.64.0.7:3001/", "http://192.168.1.5:3001/"]
|
||||
);
|
||||
|
||||
// Only the sockets of Codeman servers count: not a control command's,
|
||||
// another program's, or one whose process `ps` did not list.
|
||||
let procs = [
|
||||
server(1, " --port 4000"),
|
||||
(2, "node /opt/homebrew/bin/codeman attach w1".to_string()),
|
||||
(3, "python3 -m http.server 8000".to_string()),
|
||||
];
|
||||
let sockets = [
|
||||
listener(2, "*", 4100),
|
||||
listener(3, "*", 8000),
|
||||
listener(9, "*", 9000),
|
||||
listener(1, "*", 4000),
|
||||
];
|
||||
assert_eq!(addresses(&procs, &sockets, default), ["http://127.0.0.1:4000/"]);
|
||||
assert!(addresses(&procs, &sockets[..3], default).is_empty());
|
||||
// An address that makes no URL is left out.
|
||||
assert!(one("", &[listener(1, "not a host", 3001)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn found_addresses_start_with_the_default_one_then_go_by_port() {
|
||||
// install.sh's launchd job: a bare `web`, the base path in its environment.
|
||||
let default = "http://127.0.0.1:3001/codeman/";
|
||||
let procs = [
|
||||
(
|
||||
10,
|
||||
"node /x/tsx/cli.mjs /Users/k/codeman/src/index.ts web --port 5000".to_string(),
|
||||
),
|
||||
(
|
||||
20,
|
||||
"/opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web".to_string(),
|
||||
),
|
||||
server(30, " -p 4000 --https"),
|
||||
// The same address twice: a second process on the port of the first.
|
||||
server(40, " --port 5000"),
|
||||
];
|
||||
let sockets = [
|
||||
listener(10, "*", 5000),
|
||||
listener(20, "*", 3001),
|
||||
listener(30, "127.0.0.1", 4000),
|
||||
listener(40, "127.0.0.1", 5000),
|
||||
];
|
||||
assert_eq!(
|
||||
addresses(&procs, &sockets, default),
|
||||
[
|
||||
"http://127.0.0.1:3001/codeman/",
|
||||
"https://127.0.0.1:4000/",
|
||||
"http://127.0.0.1:5000/"
|
||||
]
|
||||
);
|
||||
// The same list whatever order `ps` and `lsof` gave.
|
||||
let mut reversed_procs = procs.to_vec();
|
||||
reversed_procs.reverse();
|
||||
let mut reversed_sockets = sockets.to_vec();
|
||||
reversed_sockets.reverse();
|
||||
assert_eq!(
|
||||
addresses(&reversed_procs, &reversed_sockets, default),
|
||||
addresses(&procs, &sockets, default)
|
||||
);
|
||||
|
||||
// On the default port, a command line with its own base path or
|
||||
// scheme is taken at its word: it may be another server than the job's.
|
||||
let on_3001 = |flags: &str| addresses(&[server(1, flags)], &[listener(1, "*", 3001)], default);
|
||||
assert_eq!(on_3001(" --port 3001"), [default]);
|
||||
assert_eq!(on_3001(" --port 3001 --base-url /codeman"), [default]);
|
||||
assert_eq!(
|
||||
on_3001(" --port 3001 --base-url /other"),
|
||||
["http://127.0.0.1:3001/other/"]
|
||||
);
|
||||
assert_eq!(on_3001(" --port 3001 --base-url /"), ["http://127.0.0.1:3001/"]);
|
||||
assert_eq!(on_3001(" --port 3001 --https"), ["https://127.0.0.1:3001/"]);
|
||||
// 127.0.0.1 only: a bind to another address is not the default one.
|
||||
assert_eq!(
|
||||
addresses(&[server(1, "")], &[listener(1, "192.168.1.5", 3001)], default),
|
||||
["http://192.168.1.5:3001/"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_found_server_row_says_what_answered() {
|
||||
let default = u("http://127.0.0.1:3000/");
|
||||
let at = u("http://127.0.0.1:4000/");
|
||||
let row = |answer: ProbeOutcome| found_server(&at, &answer, &default);
|
||||
assert_eq!(
|
||||
row(ProbeOutcome::Ok {
|
||||
version: "1.49.0".into()
|
||||
}),
|
||||
FoundServer {
|
||||
url: "http://127.0.0.1:4000/".into(),
|
||||
version: Some("1.49.0".into()),
|
||||
state: FoundState::Running,
|
||||
is_default: false,
|
||||
}
|
||||
);
|
||||
let state = |answer: ProbeOutcome| {
|
||||
let row = row(answer);
|
||||
assert_eq!(row.version, None);
|
||||
row.state
|
||||
};
|
||||
assert_eq!(state(ProbeOutcome::PasswordChangeRequired), FoundState::Running);
|
||||
for login in [
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::BadCredentials,
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: Some(60),
|
||||
},
|
||||
] {
|
||||
assert_eq!(state(login), FoundState::NeedsLogin);
|
||||
}
|
||||
for certificate in [
|
||||
ProbeOutcome::TlsUntrusted {
|
||||
detail: "invalid peer certificate: UnknownIssuer".into(),
|
||||
},
|
||||
ProbeOutcome::MaybeHttps,
|
||||
] {
|
||||
assert_eq!(state(certificate), FoundState::Certificate);
|
||||
}
|
||||
for silent in [
|
||||
ProbeOutcome::Refused,
|
||||
ProbeOutcome::Timeout,
|
||||
ProbeOutcome::Unreachable,
|
||||
ProbeOutcome::HostNotAllowed,
|
||||
ProbeOutcome::NotCodeman { status: 403 },
|
||||
ProbeOutcome::Failed { detail: "x".into() },
|
||||
] {
|
||||
assert_eq!(state(silent), FoundState::Unreachable);
|
||||
}
|
||||
assert!(found_server(&default, &ProbeOutcome::Refused, &default).is_default);
|
||||
|
||||
// As the connect page reads it.
|
||||
assert_eq!(
|
||||
serde_json::to_value(row(ProbeOutcome::Ok {
|
||||
version: "1.49.0".into()
|
||||
}))
|
||||
.unwrap(),
|
||||
json!({ "url": "http://127.0.0.1:4000/", "version": "1.49.0", "state": "running", "isDefault": false })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(found_server(&default, &ProbeOutcome::NeedsLogin, &default)).unwrap(),
|
||||
json!({ "url": "http://127.0.0.1:3000/", "version": null, "state": "needsLogin", "isDefault": true })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value([FoundState::Certificate, FoundState::Unreachable]).unwrap(),
|
||||
json!(["certificate", "unreachable"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tab_is_about_the_default_address_unless_nothing_answers_there() {
|
||||
let default = u("http://127.0.0.1:3000/");
|
||||
let ok = ProbeOutcome::Ok {
|
||||
version: "1.49.0".into(),
|
||||
};
|
||||
let refused = ProbeOutcome::Refused;
|
||||
let found = vec![
|
||||
(
|
||||
u("https://127.0.0.1:4000/"),
|
||||
ProbeOutcome::TlsUntrusted { detail: "x".into() },
|
||||
),
|
||||
(u("http://127.0.0.1:5000/"), ProbeOutcome::NeedsLogin),
|
||||
(u("http://127.0.0.1:6000/"), ok.clone()),
|
||||
];
|
||||
// Nothing at the default address: the first found server that
|
||||
// answers, one that wants a login included.
|
||||
assert_eq!(
|
||||
shown(&default, &refused, &found),
|
||||
(&found[1].0, &ProbeOutcome::NeedsLogin)
|
||||
);
|
||||
assert_eq!(shown(&default, &refused, &found[2..]), (&found[2].0, &ok));
|
||||
// None of them answers: the tab stays on the default address, and
|
||||
// local_state makes that RunningElsewhere.
|
||||
assert_eq!(shown(&default, &refused, &found[..1]), (&default, &refused));
|
||||
assert_eq!(shown(&default, &refused, &[]), (&default, &refused));
|
||||
// Whatever else the default address says is what the tab shows; the
|
||||
// servers found elsewhere are only listed.
|
||||
for answer in [
|
||||
ok.clone(),
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::NotCodeman { status: 404 },
|
||||
ProbeOutcome::Timeout,
|
||||
ProbeOutcome::MaybeHttps,
|
||||
] {
|
||||
assert_eq!(shown(&default, &answer, &found), (&default, &answer));
|
||||
}
|
||||
}
|
||||
|
||||
/// Against real sockets: every address is asked, at the same time, and
|
||||
/// the answers come back in the order of the addresses.
|
||||
#[test]
|
||||
fn found_servers_are_asked_together() {
|
||||
use std::io::Read;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// A server for one request: it reads it, and answers with `response`
|
||||
/// if `ready` says so.
|
||||
fn serve(response: String, ready: impl FnOnce() -> bool + Send + 'static) -> Url {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
let mut head = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => head.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
if ready() {
|
||||
let _ = s.write_all(response.as_bytes());
|
||||
}
|
||||
}
|
||||
});
|
||||
u(&format!("http://127.0.0.1:{port}/"))
|
||||
}
|
||||
fn response(status_line: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
// The first server answers only once the second has been asked. Asked
|
||||
// one after the other, it would hang up without an answer.
|
||||
let (asked, was_asked) = mpsc::channel::<()>();
|
||||
let first = serve(
|
||||
response("200 OK", r#"{"success":true,"data":{"version":"9.9.9"}}"#),
|
||||
move || was_asked.recv_timeout(Duration::from_secs(4)).is_ok(),
|
||||
);
|
||||
let second = serve(response("401 Unauthorized", "Unauthorized"), move || {
|
||||
asked.send(()).is_ok()
|
||||
});
|
||||
let closed = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
u(&format!("http://127.0.0.1:{}/", l.local_addr().unwrap().port()))
|
||||
};
|
||||
assert_eq!(
|
||||
ask_found(&[first, second, closed], probe::TIMEOUT),
|
||||
[
|
||||
ProbeOutcome::Ok {
|
||||
version: "9.9.9".into()
|
||||
},
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::Refused,
|
||||
]
|
||||
);
|
||||
|
||||
// One that does not answer in the time it is given.
|
||||
let slow = serve(String::new(), || {
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
false
|
||||
});
|
||||
assert_eq!(ask_found(&[slow], Duration::from_millis(300)), [ProbeOutcome::Timeout]);
|
||||
assert!(ask_found(&[], FOUND_TIMEOUT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -757,20 +757,27 @@ async function everyState(browser: Browser) {
|
||||
|
||||
await native({ type: 'localStatus', status: st16('notInstalled') });
|
||||
check(
|
||||
'not installed: Install and the network option, no Open/Start',
|
||||
(await visible('#local-install')) &&
|
||||
(await visible('#local-network-row')) &&
|
||||
!(await visible('#local-open')) &&
|
||||
!(await visible('#local-start')) &&
|
||||
(await page.textContent('#local-detail')).includes('Terminal')
|
||||
'not installed: Install, no Open/Start',
|
||||
(await visible('#local-install')) && !(await visible('#local-open')) && !(await visible('#local-start'))
|
||||
);
|
||||
await page.click('#local-network');
|
||||
// Install opens the graphical install (test/desktop-connect-install.test.ts walks its screens).
|
||||
// The Terminal route is what it offers when it cannot go on, here after a failed preparation.
|
||||
const installInTerminal = async () => {
|
||||
await page.click('#local-install');
|
||||
await native({ type: 'installPrepareFailed', message: 'Could not get the installer.' });
|
||||
await page.click('#wiz-terminal');
|
||||
};
|
||||
n0 = (await all()).length;
|
||||
await page.dblclick('#local-install');
|
||||
p = (await all()).slice(n0);
|
||||
check('a double click on Install asks for the install once', p.length === 1 && p[0].cmd === 'installPrepare', p);
|
||||
await native({ type: 'installPrepareFailed', message: 'Could not get the installer.' });
|
||||
n0 = (await all()).length;
|
||||
await page.dblclick('#wiz-terminal');
|
||||
p = (await all()).slice(n0);
|
||||
check(
|
||||
'Install posts installLocal once, with the network choice',
|
||||
p.length === 1 && p[0].cmd === 'installLocal' && p[0].network === true,
|
||||
'Install in Terminal instead posts installLocal once',
|
||||
p.length === 1 && p[0].cmd === 'installLocal' && p[0].network === false,
|
||||
p
|
||||
);
|
||||
check('Install disabled while Terminal opens', await page.isDisabled('#local-install'));
|
||||
@@ -779,7 +786,7 @@ async function everyState(browser: Browser) {
|
||||
'install failure shown as text, Install enabled again',
|
||||
(await page.textContent('#status')).includes('<b>no</b>') && !(await page.isDisabled('#local-install'))
|
||||
);
|
||||
await page.click('#local-install');
|
||||
await installInTerminal();
|
||||
await page.clock.install();
|
||||
await native({ type: 'installStarted', port: 3001 });
|
||||
check(
|
||||
@@ -825,7 +832,7 @@ async function everyState(browser: Browser) {
|
||||
|
||||
// A finished install whose service never answers offers Start after 90 s.
|
||||
await native({ type: 'localStatus', status: st16('notInstalled') });
|
||||
await page.click('#local-install');
|
||||
await installInTerminal();
|
||||
await native({ type: 'installStarted', port: 3000 });
|
||||
await native({ type: 'localStatus', status: st16('stopped', { installComplete: true }) });
|
||||
await page.clock.runFor(60000);
|
||||
@@ -841,7 +848,7 @@ async function everyState(browser: Browser) {
|
||||
);
|
||||
// Stop waiting by hand.
|
||||
await native({ type: 'localStatus', status: st16('notInstalled') });
|
||||
await page.click('#local-install');
|
||||
await installInTerminal();
|
||||
await native({ type: 'installStarted', port: 3000 });
|
||||
n0 = (await all()).length;
|
||||
await page.click('#local-stop');
|
||||
@@ -867,15 +874,85 @@ async function everyState(browser: Browser) {
|
||||
p.length === 1 && p[0].cmd === 'localStatus' && p[0].deep === true && p[0].quick === false,
|
||||
p
|
||||
);
|
||||
await native({ type: 'localStatus', status: st16('runningElsewhere', { elsewhere: ['192.168.1.5:3000'] }) });
|
||||
const elsewhere = [
|
||||
{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false },
|
||||
{ url: 'http://192.168.1.5:3000/', version: '1.49.0', state: 'running', isDefault: false },
|
||||
];
|
||||
await native({ type: 'localStatus', status: st16('runningElsewhere', { found: elsewhere }) });
|
||||
check(
|
||||
'a server running elsewhere: no Start, no Install, says where',
|
||||
'a server running elsewhere: no Start, no Install, a row for each address',
|
||||
!(await visible('#local-start')) &&
|
||||
!(await visible('#local-install')) &&
|
||||
!(await visible('#local-open')) &&
|
||||
(await page.textContent('#local-detail')).includes('192.168.1.5:3000') &&
|
||||
(await page.textContent('#local-found')).includes('http://192.168.1.5:3000/') &&
|
||||
(await page.locator('#local-found .found').count()) === 2 &&
|
||||
(await page.textContent('#local-title')).includes('already running')
|
||||
);
|
||||
const foundBox = await page.evaluate(() => {
|
||||
const r = (sel) => document.querySelector(sel).getBoundingClientRect();
|
||||
const card = r('.card');
|
||||
const rows = [...document.querySelectorAll('#local-found .found')].map((row) => row.getBoundingClientRect());
|
||||
return {
|
||||
order: [r('.local').bottom, rows[0].top, rows[1].bottom, r('#local-found-note').top, r('#local-check').top],
|
||||
inside: rows.every((row) => row.left >= card.left && row.right <= card.right),
|
||||
buttons: [...document.querySelectorAll('#local-found .found')].map((row) => {
|
||||
const b = row.querySelector('button');
|
||||
return b ? b.className : '';
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'the rows sit under the title, then the sentence about a second server, then Check again',
|
||||
foundBox.order.every((y, i) => i === 0 || y >= foundBox.order[i - 1]) && foundBox.inside,
|
||||
foundBox
|
||||
);
|
||||
check(
|
||||
'a server the app cannot open has no Connect; the first it can open is the main action',
|
||||
foundBox.buttons[0] === '' && foundBox.buttons[1] === 'primary',
|
||||
foundBox.buttons
|
||||
);
|
||||
n0 = (await all()).length;
|
||||
await page.locator('#local-found .found button').dblclick();
|
||||
p = (await all()).slice(n0);
|
||||
check(
|
||||
'Connect on a found server connects once, to its address',
|
||||
p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://192.168.1.5:3000/' && !('password' in p[0]),
|
||||
p
|
||||
);
|
||||
await native({
|
||||
type: 'probeResult',
|
||||
url: 'http://192.168.1.5:3000/',
|
||||
outcome: { kind: 'refused' },
|
||||
canStart: false,
|
||||
});
|
||||
await native({
|
||||
type: 'localStatus',
|
||||
status: st16('running', {
|
||||
version: '1.49.0',
|
||||
outcome: { kind: 'ok', version: '1.49.0' },
|
||||
found: [{ url: 'http://127.0.0.1:3000/', version: '1.49.0', state: 'running', isDefault: true }, elsewhere[1]],
|
||||
}),
|
||||
});
|
||||
const alsoBox = await page.evaluate(() => {
|
||||
const r = (sel) => document.querySelector(sel).getBoundingClientRect();
|
||||
return {
|
||||
open: r('#local-open').bottom,
|
||||
also: r('#local-also').top,
|
||||
rows: document.querySelectorAll('#local-also .found').length,
|
||||
head: document.querySelector('#local-also-head').textContent,
|
||||
button: document.querySelector('#local-also .found button').className,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'running with another server on this Mac: it is listed under Open, as a secondary action',
|
||||
(await visible('#local-open')) &&
|
||||
alsoBox.also >= alsoBox.open &&
|
||||
alsoBox.rows === 1 &&
|
||||
alsoBox.head === 'Also running on this Mac' &&
|
||||
alsoBox.button === 'secondary' &&
|
||||
!(await visible('#local-found')),
|
||||
alsoBox
|
||||
);
|
||||
await native({ type: 'localStatus', status: st16('stopped', { url: 'https://127.0.0.1:3000/', service: true }) });
|
||||
check(
|
||||
'stopped with HTTPS: no Start (the app refuses its certificate), explained',
|
||||
@@ -1051,7 +1128,7 @@ async function layoutAndLimits(browser: Browser) {
|
||||
installComplete: true,
|
||||
installRunning: false,
|
||||
installPort: null,
|
||||
elsewhere: [],
|
||||
found: [],
|
||||
outcome: { kind: 'ok', version: '1.48.0' },
|
||||
},
|
||||
s
|
||||
@@ -1288,7 +1365,7 @@ async function clicksKeysAndStates(browser: Browser) {
|
||||
installComplete: true,
|
||||
installRunning: false,
|
||||
installPort: null,
|
||||
elsewhere: [],
|
||||
found: [],
|
||||
outcome: { kind: 'ok', version: '1.48.0' },
|
||||
},
|
||||
s
|
||||
@@ -1418,6 +1495,24 @@ async function clicksKeysAndStates(browser: Browser) {
|
||||
'Return never starts an install',
|
||||
(await t.posted('installLocal')).length === 0 && (await t.page.locator('#local-install').isVisible())
|
||||
);
|
||||
await t.native(
|
||||
localStatus({
|
||||
state: 'runningElsewhere',
|
||||
version: null,
|
||||
outcome: { kind: 'refused' },
|
||||
found: [
|
||||
{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false },
|
||||
{ url: 'http://127.0.0.1:5000/', version: '1.49.0', state: 'running', isDefault: false },
|
||||
],
|
||||
})
|
||||
);
|
||||
await t.page.keyboard.press('Enter');
|
||||
check(
|
||||
'Return connects to the server found on this Mac when that is the main action',
|
||||
(await t.posted('connect')).length === 2 && (await t.posted('connect'))[1].url === 'http://127.0.0.1:5000/',
|
||||
await t.posted('connect')
|
||||
);
|
||||
await t.page.keyboard.press('Escape');
|
||||
await t.native(localStatus({ state: 'stopped', version: null, outcome: { kind: 'refused' } }));
|
||||
await t.page.focus('#local-check');
|
||||
await t.page.keyboard.press('Enter');
|
||||
|
||||
@@ -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,362 @@ 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<string, string> = {}) {
|
||||
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<string, string> = {}) {
|
||||
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 agent, one AI CLI and nothing else', () => {
|
||||
// The real subcommand in a real bash, with detection pointed at nothing and every
|
||||
// trusted command rewritten: LEAVES names the binary a stand-in install leaves
|
||||
// behind (on PATH, in the run's own folder), FOUND is a CLI already here.
|
||||
const DRIVER = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
set +e
|
||||
parse_flags "\${@:2}" || exit 9
|
||||
set -e
|
||||
bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT; export PATH="$bin:$PATH"
|
||||
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 [[ "\${KEEP_COMMANDS:-}" != "1" && -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then
|
||||
CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}; if [[ \${CLI_IDS[$k]} == \\"\${LEAVES:-}\\" ]]; then printf '#!/bin/sh\\n' > $bin/codeman-test-left-$k; chmod +x $bin/codeman-test-left-$k; fi"
|
||||
fi
|
||||
if [[ "\${CLI_IDS[$k]}" == "\${FOUND:-}" ]]; then
|
||||
printf '#!/bin/sh\n' > "$bin/codeman-test-found"; chmod +x "$bin/codeman-test-found"
|
||||
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-found
|
||||
fi
|
||||
if [[ "\${CLI_IDS[$k]}" == "\${LEAVES:-}" ]]; then
|
||||
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-left-$k
|
||||
fi
|
||||
k=$((k + 1))
|
||||
done
|
||||
if [[ -n "\${NO_NPM:-}" ]]; then check_npm() { return 1; }; fi
|
||||
CLI_DETECT_DONE=""
|
||||
agent_subcommand
|
||||
echo "REACHED THE END"
|
||||
`;
|
||||
|
||||
function agent(args: string[], env: Record<string, string> = {}) {
|
||||
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, CODEMAN_PROGRESS: '', ...env },
|
||||
});
|
||||
const stdout = result.stdout ?? '';
|
||||
const stderr = result.stderr ?? '';
|
||||
return { status: result.status, stdout, stderr, ran: [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]) };
|
||||
}
|
||||
|
||||
it('installs the one it was told to, and says done', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { LEAVES: 'codex' });
|
||||
expect(run.status, run.stderr).toBe(0);
|
||||
expect(run.ran).toEqual(['codex']);
|
||||
expect(run.stderr).toContain('@@codeman phase agent');
|
||||
expect(run.stderr).toContain('@@codeman phase done');
|
||||
});
|
||||
|
||||
it('never says done when that agent did not land, even with another one here', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'copilot' });
|
||||
expect(run.ran).toEqual(['codex']);
|
||||
expect(run.status).toBe(1);
|
||||
expect(run.stderr).toContain('@@codeman error Codex did not install.');
|
||||
expect(run.stderr).not.toContain('@@codeman phase done');
|
||||
expect(run.stdout).not.toContain('REACHED THE END');
|
||||
});
|
||||
|
||||
it('says the same with no other agent here, the way a Mac is after Skip for now', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex']);
|
||||
expect(run.ran).toEqual(['codex']);
|
||||
expect(run.status).toBe(1);
|
||||
expect(run.stderr).toContain('@@codeman error Codex did not install.');
|
||||
expect(run.stderr).not.toContain('re-run the installer');
|
||||
expect(run.stderr).not.toContain('@@codeman phase done');
|
||||
});
|
||||
|
||||
it('installs nothing for an agent that is here already, and that is a success', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'codex' });
|
||||
expect(run.status, run.stderr).toBe(0);
|
||||
expect(run.ran).toEqual([]);
|
||||
expect(run.stderr).toContain('@@codeman phase done');
|
||||
});
|
||||
|
||||
it('names what is wrong when npm cannot be found, before running anything', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { KEEP_COMMANDS: '1', NO_NPM: '1' });
|
||||
expect(run.status).toBe(1);
|
||||
expect(run.stderr).toContain('Codex installs with npm, and this run cannot find it');
|
||||
expect(run.stderr).toContain('npm install -g @openai/codex');
|
||||
expect(run.stderr).not.toContain('@@codeman phase done');
|
||||
});
|
||||
|
||||
it('refuses an agent it cannot install, and installs no other in its place', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'deepseek'], { KEEP_COMMANDS: '1' });
|
||||
expect(run.status).toBe(1);
|
||||
expect(run.stderr).toContain('DeepSeek cannot be installed automatically here');
|
||||
expect(run.stderr).not.toContain('@@codeman phase done');
|
||||
});
|
||||
|
||||
it('it takes the name as a plain word, as a person would type it', () => {
|
||||
const driver = `
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
set +e
|
||||
parse_flags "\${@:2}" || exit 9
|
||||
echo "sub=$SUBCOMMAND cli=$AI_CLI_CHOICE"
|
||||
`;
|
||||
const parsed = (...args: string[]) =>
|
||||
spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, ...args], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(parsed('agent', 'codex', '--no-admin').stdout).toContain('sub=agent cli=codex');
|
||||
expect(parsed('agent', '--ai-cli', 'codex').stdout).toContain('sub=agent cli=codex');
|
||||
expect(parsed('agent', 'codx').stderr).toContain('Unknown AI CLI: codx');
|
||||
expect(parsed('agent', 'shell').stderr).toContain('Unknown AI CLI: shell');
|
||||
expect(parsed('agent', 'codex', 'copilot').stderr).toContain('Unexpected argument: copilot');
|
||||
expect(parsed('codex').stderr).toContain('Unexpected argument: codex');
|
||||
});
|
||||
|
||||
it('wants a name, and none is not one', () => {
|
||||
expect(agent([]).stderr).toContain('Name the AI CLI to install');
|
||||
expect(agent(['--ai-cli', 'none']).stderr).toContain('Name the AI CLI to install');
|
||||
});
|
||||
|
||||
it('leaves Codeman alone: nothing in it touches the service, the source or the build', () => {
|
||||
const body = SOURCE.slice(
|
||||
SOURCE.indexOf('\nagent_subcommand() {'),
|
||||
SOURCE.indexOf('\n# Offer to install one AI CLI')
|
||||
);
|
||||
for (const word of ['launchctl', 'systemctl', 'setup_launchd', 'install_or_update_repo', 'npm run', 'sudo']) {
|
||||
expect(body, word).not.toContain(word);
|
||||
}
|
||||
expect(body).toContain('run_offered_cli "$idx"');
|
||||
});
|
||||
});
|
||||
|
||||
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<string, string>) {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user