mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
merge the agent chooser (feat/graphical-installer) into the integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
da2f12fdbd
12 files changed
+1016
-124
No files matched your search
@@ -93,12 +93,22 @@ jobs:
|
||||
done
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
|
||||
# What the desktop app runs: preflight on stdout, and only JSON there.
|
||||
out=$(bash /w/install.sh preflight --no-admin)
|
||||
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]]
|
||||
# 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\"" ]]
|
||||
[[ "$(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
|
||||
|
||||
@@ -1303,7 +1303,7 @@ 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. ⚠️ `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...`).
|
||||
**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 checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli` 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.
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ 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 --`:
|
||||
@@ -97,6 +98,10 @@ existing install, `--port` and `--password` re-run the setup so the service file
|
||||
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
|
||||
|
||||
+43
-1
@@ -882,6 +882,44 @@ install_offered_cli() {
|
||||
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. install_offered_cli alone cannot end this run: it is fatal only
|
||||
# when NO CLI is left, and here another one usually is, so a failed install would
|
||||
# report success. 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
|
||||
|
||||
install_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
|
||||
@@ -4664,6 +4702,7 @@ Subcommands
|
||||
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,
|
||||
@@ -4713,13 +4752,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|preflight)
|
||||
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 ;;
|
||||
@@ -4757,6 +4798,7 @@ case "$SUBCOMMAND" in
|
||||
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
|
||||
|
||||
@@ -28,7 +28,7 @@ cannot do.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. 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. |
|
||||
| **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 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). |
|
||||
| **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. |
|
||||
@@ -167,6 +167,18 @@ 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
|
||||
@@ -215,6 +227,14 @@ and the Dock icon bounces.
|
||||
|
||||
## 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.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
.choice input { accent-color: var(--accent-2); margin: 2px 0 0; flex: none; }
|
||||
.choice b { display: block; font-weight: 600; }
|
||||
.choice small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
|
||||
.choice:has(input:disabled) { border-color: var(--line); background: none; color: var(--muted); }
|
||||
#wiz-reach .choice + .choice { margin-top: 6px; }
|
||||
#wiz-password { margin-top: 10px; }
|
||||
#wiz-agent-note { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
|
||||
@@ -293,6 +294,7 @@
|
||||
<button class="secondary" id="local-stop" type="button" hidden>Stop waiting</button>
|
||||
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
|
||||
</div>
|
||||
<button class="wiz-link" id="local-agents" type="button" hidden>Add a coding agent</button>
|
||||
</section>
|
||||
|
||||
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
|
||||
@@ -383,7 +385,7 @@
|
||||
wizBack: $('wiz-back'), wizSkip: $('wiz-skip'), wizNext: $('wiz-next'), wizCancel: $('wiz-cancel'),
|
||||
wizTerminal: $('wiz-terminal'),
|
||||
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
|
||||
localStop: $('local-stop'), localCheck: $('local-check'),
|
||||
localStop: $('local-stop'), localCheck: $('local-check'), localAgents: $('local-agents'),
|
||||
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
|
||||
};
|
||||
var manual = /[?&]manual=1/.test(location.search);
|
||||
@@ -401,7 +403,7 @@
|
||||
var state = {
|
||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||
tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false,
|
||||
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null,
|
||||
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false,
|
||||
reopen: false, startingLooks: 0, startingTimer: 0
|
||||
};
|
||||
// While an install runs: how often to look, how long a finished install may
|
||||
@@ -737,6 +739,7 @@
|
||||
els.localStart.disabled = state.busy || tooOld;
|
||||
els.localInstall.disabled = state.busy || tooOld || state.opening;
|
||||
els.localCheck.disabled = state.busy || state.localChecking;
|
||||
els.localAgents.disabled = state.busy || tooOld || state.opening;
|
||||
els.sshInstall.disabled = state.busy || state.opening;
|
||||
}
|
||||
|
||||
@@ -830,18 +833,22 @@
|
||||
els.localInstall.hidden = !show.install;
|
||||
els.localStop.hidden = !show.stop;
|
||||
els.localCheck.hidden = !show.check;
|
||||
// Codeman is here, in whatever state: adding an agent asks nothing of it.
|
||||
els.localAgents.hidden = !s || !s.installed || !!state.wait || state.localChecking || (s.installRunning && !show.open);
|
||||
syncLocalButtons();
|
||||
}
|
||||
|
||||
function onLocalStatus(s) {
|
||||
var wanted = state.wantAgents;
|
||||
state.wantAgents = false;
|
||||
state.localChecking = false;
|
||||
state.local = s || null;
|
||||
// The graphical install is running without this page (it was reloaded, or
|
||||
// the app was quit and opened again): show it from its first line.
|
||||
if (s && s.installInApp && !state.gui) {
|
||||
state.gui = newWizard('installing');
|
||||
// A graphical run is going without this page (it was reloaded, or the app
|
||||
// was quit and opened again): show it from its first line.
|
||||
if (s && (s.installInApp || s.agentInstallInApp) && !state.gui) {
|
||||
state.gui = newWizard('installing', s.installInApp ? 'install' : 'agent');
|
||||
state.gui.port = s.installPort || 3000;
|
||||
post({ cmd: 'installAttach' });
|
||||
post({ cmd: s.installInApp ? 'installAttach' : 'agentAttach' });
|
||||
renderWizard();
|
||||
return;
|
||||
}
|
||||
@@ -858,6 +865,8 @@
|
||||
}
|
||||
if (followStarting(s)) return;
|
||||
renderLocal();
|
||||
if (wanted && s && s.installed && !state.gui) openAgentChooser();
|
||||
else if (state.gui && state.gui.step === 'added') renderWizard();
|
||||
}
|
||||
|
||||
// A Codeman that is still starting (its process runs, but listens nowhere
|
||||
@@ -924,10 +933,12 @@
|
||||
post({ cmd: 'installLocal', network: network });
|
||||
}
|
||||
|
||||
function newWizard(step) {
|
||||
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
|
||||
// that has Codeman, on the same screens, with no question about Codeman.
|
||||
function newWizard(step, mode) {
|
||||
return {
|
||||
step: step, plan: null, agent: '', reach: 'local', port: 3000, message: '', phase: '',
|
||||
phaseSince: 0, seen: {}, log: [], ok: false
|
||||
step: step, mode: mode || 'install', plan: null, agent: '', reach: 'local', port: 3000, message: '', phase: '',
|
||||
phaseSince: 0, seen: {}, log: [], ok: false, noAgent: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -938,6 +949,22 @@
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
function openAgentChooser() {
|
||||
if (state.busy || tooOld || state.opening) return;
|
||||
state.gui = newWizard('preparing', 'agent');
|
||||
post({ cmd: 'agentPrepare' });
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
// The menu's way in. It can arrive before the first look at this Mac, and a
|
||||
// Mac with no Codeman gets This Mac's Install instead.
|
||||
function addAgent() {
|
||||
selectTab('local');
|
||||
if (state.gui) return;
|
||||
if (!state.local || state.localChecking) state.wantAgents = true;
|
||||
else if (state.local.installed) openAgentChooser();
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
clearInterval(state.clock);
|
||||
state.gui = null;
|
||||
@@ -948,10 +975,11 @@
|
||||
function retryWizard() {
|
||||
var reach = state.gui ? state.gui.reach : 'local';
|
||||
var agent = state.gui ? state.gui.agent : '';
|
||||
state.gui = newWizard('preparing');
|
||||
var mode = state.gui ? state.gui.mode : 'install';
|
||||
state.gui = newWizard('preparing', mode);
|
||||
state.gui.reach = reach;
|
||||
state.gui.agent = agent;
|
||||
post({ cmd: 'installPrepare' });
|
||||
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
|
||||
renderWizard();
|
||||
}
|
||||
|
||||
@@ -969,13 +997,23 @@
|
||||
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
|
||||
}
|
||||
|
||||
// The first step the installer's answer calls for. The agent step exists
|
||||
// only when this Mac has no coding agent at all.
|
||||
// A run this page did not start has no plan, so no name for the agent.
|
||||
function agentName() {
|
||||
var c = agentById(state.gui.agent);
|
||||
return c ? c.label : 'a coding agent';
|
||||
}
|
||||
|
||||
// The first step the installer's answer calls for. In an install the agent
|
||||
// step exists only when this Mac has no coding agent at all.
|
||||
function onInstallPlan(plan) {
|
||||
var g = state.gui;
|
||||
if (!g || g.step !== 'preparing') return;
|
||||
g.plan = plan;
|
||||
if (plan.blocker) {
|
||||
if (g.mode === 'agent') {
|
||||
// `blocker` says why Codeman cannot be installed, which is not the question.
|
||||
if (!agentById(g.agent)) g.agent = offeredAgents().length ? offeredAgents()[0].id : '';
|
||||
g.step = 'agent';
|
||||
} else if (plan.blocker) {
|
||||
g.step = 'blocked';
|
||||
} else if (!installedAgents().length && offeredAgents().length) {
|
||||
// Kept across Try again; otherwise the first of the catalogue's order.
|
||||
@@ -990,7 +1028,8 @@
|
||||
function renderAgents() {
|
||||
var g = state.gui;
|
||||
els.wizAgents.textContent = '';
|
||||
offeredAgents().forEach(function (c) {
|
||||
var rows = g.mode === 'agent' ? offeredAgents().concat(installedAgents()) : offeredAgents();
|
||||
rows.forEach(function (c) {
|
||||
var label = document.createElement('label');
|
||||
label.className = 'choice';
|
||||
var input = document.createElement('input');
|
||||
@@ -998,14 +1037,21 @@
|
||||
input.name = 'agent';
|
||||
input.value = c.id;
|
||||
input.checked = c.id === g.agent;
|
||||
input.disabled = c.installed;
|
||||
input.addEventListener('change', function () {
|
||||
g.agent = c.id;
|
||||
renderAgentNote();
|
||||
renderWizardButtons();
|
||||
});
|
||||
var name = document.createElement('b');
|
||||
name.textContent = c.label;
|
||||
var text = document.createElement('span');
|
||||
text.appendChild(name);
|
||||
if (c.installed) {
|
||||
var here = document.createElement('small');
|
||||
here.textContent = 'Installed';
|
||||
text.appendChild(here);
|
||||
}
|
||||
label.appendChild(input);
|
||||
label.appendChild(text);
|
||||
els.wizAgents.appendChild(label);
|
||||
@@ -1064,6 +1110,7 @@
|
||||
var g = state.gui;
|
||||
var plan = g.plan;
|
||||
var chosen = agentById(g.agent);
|
||||
if (g.mode === 'agent') return [{ id: 'agent', label: 'Installing ' + agentName() }];
|
||||
var rows = [];
|
||||
PHASES.forEach(function (id) {
|
||||
var label = {
|
||||
@@ -1152,6 +1199,14 @@
|
||||
if (!g) return;
|
||||
appendLog(msg.log);
|
||||
clearInterval(state.clock);
|
||||
if (msg.ok && g.mode === 'agent') {
|
||||
g.ok = true;
|
||||
g.step = 'added';
|
||||
renderWizard();
|
||||
// Whether there is a Codeman to open from here.
|
||||
checkLocal('quick');
|
||||
return;
|
||||
}
|
||||
if (msg.ok) {
|
||||
g.ok = true;
|
||||
g.step = 'finishing';
|
||||
@@ -1163,9 +1218,11 @@
|
||||
return;
|
||||
}
|
||||
if (msg.cancelled) {
|
||||
var stopped = g.mode === 'agent' ? 'Stopped installing ' + agentName() + '.' : 'Install cancelled.';
|
||||
var again = g.mode === 'agent' ? '' : 'Install again to pick up where it stopped.';
|
||||
closeWizard();
|
||||
checkLocal('login');
|
||||
setStatus('', 'Install cancelled.', 'Install again to pick up where it stopped.');
|
||||
setStatus('', stopped, again);
|
||||
return;
|
||||
}
|
||||
g.step = 'failed';
|
||||
@@ -1182,14 +1239,29 @@
|
||||
els.wizard.hidden = !g;
|
||||
if (!g) return;
|
||||
var step = g.step;
|
||||
var copy = {
|
||||
var name = agentName();
|
||||
var installingLead = 'You can close this window. The install keeps going, and the app picks it up when you open it again.';
|
||||
var copy = g.mode === 'agent' ? {
|
||||
preparing: ['Looking at this Mac', 'Getting the list of coding agents and checking which ones are here.'],
|
||||
prepareFailed: ['Could not get the list of coding agents', ''],
|
||||
agent: ['Add a coding agent', offeredAgents().length
|
||||
? 'Pick one to install on this Mac. It needs no password, and Codeman keeps running.'
|
||||
: installedAgents().length
|
||||
? 'Every coding agent this app can install is already on this Mac.'
|
||||
: 'This app cannot install a coding agent on this Mac.'],
|
||||
installing: ['Installing ' + name, installingLead],
|
||||
added: [name === 'a coding agent' ? 'The coding agent is installed' : name + ' is installed',
|
||||
'Codeman notices it within a few minutes: reload Codeman if it does not show up. You sign in the first time you start it.'],
|
||||
failed: ['The install stopped', 'Its output is below. Try again, or follow the instructions on the agent’s own site.']
|
||||
}[step] : {
|
||||
preparing: ['Looking at this Mac', 'Getting the installer and checking what is already here.'],
|
||||
blocked: ['This Mac needs something first', ''],
|
||||
prepareFailed: ['Could not get the installer', ''],
|
||||
agent: ['Choose a coding agent', 'Codeman runs coding agents, and this Mac has none yet. Pick one to install with Codeman. You can add more later.'],
|
||||
reach: ['Who can reach Codeman?', ''],
|
||||
installing: ['Installing Codeman', 'You can close this window. The install keeps going, and the app picks it up when you open it again.'],
|
||||
installing: ['Installing Codeman', installingLead],
|
||||
finishing: ['Starting Codeman', 'Codeman opens here as soon as it answers.'],
|
||||
ready: ['Codeman is ready', 'It has no coding agent yet, and sessions need one to run. You can add one now, or later from the Codeman menu.'],
|
||||
failed: ['The install stopped', 'Its full log is in ~/.codeman/install.log. Try again picks up where it stopped.']
|
||||
}[step];
|
||||
els.wizSpinner.hidden = !(step === 'preparing' || step === 'finishing');
|
||||
@@ -1204,7 +1276,7 @@
|
||||
|
||||
els.wizAgentStep.hidden = step !== 'agent';
|
||||
els.wizReachStep.hidden = step !== 'reach';
|
||||
var running = step === 'installing' || step === 'finishing' || step === 'failed';
|
||||
var running = step === 'installing' || step === 'finishing' || step === 'failed' || step === 'added';
|
||||
els.wizRail.hidden = !running;
|
||||
els.wizDetails.hidden = !running;
|
||||
|
||||
@@ -1222,26 +1294,43 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
renderWizardButtons();
|
||||
els.wizCancel.disabled = false;
|
||||
|
||||
var checked = step === 'agent' && els.wizAgents.querySelector('input:checked');
|
||||
if (checked) checked.focus();
|
||||
else if (!els.wizNext.hidden) els.wizNext.focus();
|
||||
else if (!els.wizBack.hidden) els.wizBack.focus();
|
||||
else els.wizTitle.focus();
|
||||
}
|
||||
|
||||
function localAnswers() {
|
||||
return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin');
|
||||
}
|
||||
|
||||
function renderWizardButtons() {
|
||||
var g = state.gui;
|
||||
var step = g.step;
|
||||
var agentMode = g.mode === 'agent';
|
||||
var chosen = agentById(g.agent);
|
||||
var next = {
|
||||
agent: 'Continue', reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal'
|
||||
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
|
||||
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
|
||||
ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : ''
|
||||
}[step];
|
||||
els.wizNext.hidden = !next;
|
||||
els.wizNext.textContent = next || '';
|
||||
// Not while preparing: a second prepare would clear the folder of the first.
|
||||
els.wizBack.hidden = !(step === 'blocked' || step === 'prepareFailed' || step === 'agent' || step === 'reach');
|
||||
els.wizSkip.hidden = step !== 'agent';
|
||||
els.wizBack.hidden = !(step === 'blocked' || step === 'prepareFailed' || step === 'agent' || step === 'reach' ||
|
||||
(agentMode && (step === 'failed' || step === 'added')));
|
||||
els.wizBack.textContent = step === 'added' ? 'Done' : 'Back';
|
||||
var aside = step === 'ready' ? 'Add a coding agent'
|
||||
: step === 'added' && offeredAgents().length > 1 ? 'Add another'
|
||||
: step === 'agent' && !agentMode ? 'Skip for now' : '';
|
||||
els.wizSkip.hidden = !aside;
|
||||
els.wizSkip.textContent = aside;
|
||||
els.wizCancel.hidden = step !== 'installing';
|
||||
els.wizCancel.disabled = false;
|
||||
els.wizTerminal.hidden = !(step === 'reach' || step === 'failed' || step === 'prepareFailed');
|
||||
|
||||
if (step === 'agent') {
|
||||
var checked = els.wizAgents.querySelector('input:checked');
|
||||
if (checked) checked.focus();
|
||||
} else if (step === 'reach' || step === 'blocked' || step === 'failed' || step === 'prepareFailed') {
|
||||
els.wizNext.focus();
|
||||
} else {
|
||||
els.wizTitle.focus();
|
||||
}
|
||||
els.wizTerminal.hidden = agentMode || !(step === 'reach' || step === 'failed' || step === 'prepareFailed');
|
||||
}
|
||||
|
||||
function syncReach() {
|
||||
@@ -1274,23 +1363,43 @@
|
||||
if (!installedAgents().length && agentById(g.agent)) msg.aiCli = g.agent;
|
||||
els.wizPw.value = '';
|
||||
els.wizPw2.value = '';
|
||||
// Skipped: the first thing to say once Codeman answers.
|
||||
g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0;
|
||||
beginRun();
|
||||
post(msg);
|
||||
}
|
||||
|
||||
function startAgent() {
|
||||
var g = state.gui;
|
||||
if (!agentById(g.agent)) return;
|
||||
beginRun();
|
||||
post({ cmd: 'agentStart', aiCli: g.agent });
|
||||
}
|
||||
|
||||
function beginRun() {
|
||||
var g = state.gui;
|
||||
g.step = 'installing';
|
||||
g.phase = '';
|
||||
g.seen = {};
|
||||
g.log = [];
|
||||
renderLog();
|
||||
renderWizard();
|
||||
post(msg);
|
||||
}
|
||||
|
||||
function wizardNext() {
|
||||
var g = state.gui;
|
||||
if (!g) return;
|
||||
if (g.step === 'agent') {
|
||||
if (g.step === 'agent' && g.mode === 'agent') {
|
||||
startAgent();
|
||||
} else if (g.step === 'agent') {
|
||||
g.step = 'reach';
|
||||
renderWizard();
|
||||
} else if (g.step === 'reach') {
|
||||
startInstall();
|
||||
} else if (g.step === 'ready' || g.step === 'added') {
|
||||
closeWizard();
|
||||
selectTab('local');
|
||||
openLocal();
|
||||
} else if (g.step === 'failed' || g.step === 'prepareFailed') {
|
||||
retryWizard();
|
||||
} else if (g.step === 'blocked') {
|
||||
@@ -1338,6 +1447,14 @@
|
||||
// where something else may answer when the install took another port.
|
||||
var ours = portOf(s.url) === w.port;
|
||||
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
|
||||
var g = state.gui;
|
||||
if (g && g.step === 'finishing' && g.noAgent) {
|
||||
clearTimeout(state.waitTimer);
|
||||
state.wait = null;
|
||||
g.step = 'ready';
|
||||
renderWizard();
|
||||
return;
|
||||
}
|
||||
endWait();
|
||||
selectTab('local');
|
||||
renderLocal();
|
||||
@@ -1412,6 +1529,7 @@
|
||||
}
|
||||
if (auto) connect();
|
||||
else if (state.tab === 'remote') els.url.focus();
|
||||
if (msg.addAgent) addAgent();
|
||||
break;
|
||||
case 'servers':
|
||||
state.servers = msg.servers || [];
|
||||
@@ -1463,6 +1581,9 @@
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
break;
|
||||
case 'addAgent':
|
||||
addAgent();
|
||||
break;
|
||||
case 'installPlan':
|
||||
onInstallPlan(msg.plan);
|
||||
break;
|
||||
@@ -1572,12 +1693,18 @@
|
||||
els.localOpen.addEventListener('click', openLocal);
|
||||
els.localStart.addEventListener('click', startLocal);
|
||||
els.localInstall.addEventListener('click', openWizard);
|
||||
els.localAgents.addEventListener('click', openAgentChooser);
|
||||
els.wizNext.addEventListener('click', wizardNext);
|
||||
els.wizBack.addEventListener('click', wizardBack);
|
||||
els.wizSkip.addEventListener('click', function () {
|
||||
if (!state.gui) return;
|
||||
state.gui.agent = '';
|
||||
state.gui.step = 'reach';
|
||||
var g = state.gui;
|
||||
if (!g) return;
|
||||
if (g.step === 'ready' || g.step === 'added') {
|
||||
openAgentChooser();
|
||||
return;
|
||||
}
|
||||
g.agent = '';
|
||||
g.step = 'reach';
|
||||
renderWizard();
|
||||
});
|
||||
els.wizCancel.addEventListener('click', function () {
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
//! connect page that was reloaded, or an app that was quit and opened again,
|
||||
//! finds the running install in `ps` and replays it. That is why this module
|
||||
//! keeps no state about a run but the file and the process.
|
||||
//!
|
||||
//! Adding a coding agent to a Mac that has Codeman is the same machinery with a
|
||||
//! smaller job: `install.sh agent <id>`. It is a [`Run`] of its own, in a folder
|
||||
//! of its own, because everything else here reads a running install as "Codeman
|
||||
//! is being installed", and because it touches no service, so it may run next to
|
||||
//! a live server, which `refuse_start` exists to prevent for an install.
|
||||
|
||||
use std::fs::{self, DirBuilder, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
@@ -35,15 +41,17 @@ use serde_json::{json, Value};
|
||||
use crate::local_server;
|
||||
use crate::setup;
|
||||
|
||||
/// The folder under the per-user temporary folder that holds a run. A fixed
|
||||
/// name, so an app that was opened again finds it.
|
||||
const RUN_DIR: &str = "codeman-install";
|
||||
const SCRIPT_FILE: &str = "install.sh";
|
||||
const OUTPUT_FILE: &str = "install.out";
|
||||
|
||||
/// The `install.sh preflight` schema this app understands.
|
||||
const PREFLIGHT_SCHEMA: u32 = 1;
|
||||
|
||||
/// What the app asks the installer. `--no-admin` here and on an agent run: it
|
||||
/// decides which agents count as installable, so the two must agree, or the page
|
||||
/// offers one the run refuses.
|
||||
const PREFLIGHT_ARGS: [&str; 2] = ["preflight", "--no-admin"];
|
||||
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -65,6 +73,46 @@ const PROTOCOL_PREFIX: &str = "@@codeman ";
|
||||
/// Set by Cancel, so `follow` can tell a cancelled run from a failed one.
|
||||
static CANCELLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The two things the app runs the installer for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Run {
|
||||
/// Install Codeman (`start`).
|
||||
Codeman,
|
||||
/// Add one coding agent (`start_agent`).
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// The folder under the per-user temporary folder that holds the run. A
|
||||
/// fixed name, so an app that was opened again finds it.
|
||||
fn folder(self) -> &'static str {
|
||||
match self {
|
||||
Run::Codeman => "codeman-install",
|
||||
Run::Agent => "codeman-agent",
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(self) -> PathBuf {
|
||||
std::env::temp_dir().join(self.folder())
|
||||
}
|
||||
|
||||
/// Whether a command line is this run. `preflight` from the same file is
|
||||
/// not one: it has no `--progress`.
|
||||
pub fn matches(self, command: &str) -> bool {
|
||||
let suffix = format!("/{}/{SCRIPT_FILE}", self.folder());
|
||||
let mut words = command.split_whitespace();
|
||||
words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress")
|
||||
}
|
||||
|
||||
/// A process of this run, if it is running.
|
||||
pub fn process(self) -> Option<u32> {
|
||||
setup::list_processes()
|
||||
.into_iter()
|
||||
.find(|(_, command)| self.matches(command))
|
||||
.map(|(pid, _)| pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `install.sh preflight` found. Fields the installer adds later are
|
||||
/// ignored here, and `schema` changes when one changes meaning.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -108,6 +156,15 @@ pub struct Plan {
|
||||
pub ai_cli: Option<String>,
|
||||
}
|
||||
|
||||
/// `install.sh`'s arguments for adding one agent. The id is in the shape
|
||||
/// `policy::is_cli_id` allows, and the installer checks it against its catalogue.
|
||||
pub fn agent_args(id: &str) -> Vec<String> {
|
||||
["agent", id, "--no-admin", "--progress"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `install.sh`'s flags for a graphical install. The password is not among
|
||||
/// them: anything here can be read in `ps`. Always `--service`, like the
|
||||
/// Terminal route: a LaunchAgent runs Codeman, so it keeps running when the app
|
||||
@@ -133,10 +190,15 @@ pub fn install_args(plan: &Plan) -> Vec<String> {
|
||||
args
|
||||
}
|
||||
|
||||
/// What a graphical install adds to the environment. There is no terminal, so
|
||||
/// `CODEMAN_NONINTERACTIVE` is what lets `headless_guard` go ahead.
|
||||
/// There is no terminal, so `CODEMAN_NONINTERACTIVE` is what lets
|
||||
/// `headless_guard` go ahead.
|
||||
fn no_terminal_env() -> Vec<(String, String)> {
|
||||
vec![("CODEMAN_NONINTERACTIVE".to_string(), "1".to_string())]
|
||||
}
|
||||
|
||||
/// What a graphical install adds to the environment.
|
||||
pub fn install_env(plan: &Plan) -> Vec<(String, String)> {
|
||||
let mut env = vec![("CODEMAN_NONINTERACTIVE".to_string(), "1".to_string())];
|
||||
let mut env = no_terminal_env();
|
||||
if let Some(password) = &plan.network_password {
|
||||
env.push(("CODEMAN_PASSWORD".to_string(), password.clone()));
|
||||
}
|
||||
@@ -160,22 +222,10 @@ pub fn parse_preflight(stdout: &str) -> Result<Preflight, String> {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether a command line is an install this module started. `preflight` from
|
||||
/// the same file is not one: it has no `--progress`.
|
||||
pub fn is_install_run(command: &str) -> bool {
|
||||
let suffix = format!("/{RUN_DIR}/{SCRIPT_FILE}");
|
||||
let mut words = command.split_whitespace();
|
||||
words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress")
|
||||
}
|
||||
|
||||
fn run_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(RUN_DIR)
|
||||
}
|
||||
|
||||
/// A fresh folder for a run, for this user only. The per-user temporary folder
|
||||
/// of macOS is private already; the mode covers a `TMPDIR` that is not.
|
||||
fn fresh_run_dir() -> Result<PathBuf, String> {
|
||||
let dir = run_dir();
|
||||
fn fresh_run_dir(run: Run) -> Result<PathBuf, String> {
|
||||
let dir = run.dir();
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
@@ -230,7 +280,7 @@ fn download_installer(script: &Path) -> Result<(), String> {
|
||||
fn ask_installer(script: &Path, env: &[(String, String)]) -> Result<Preflight, String> {
|
||||
let mut cmd = Command::new("/bin/bash");
|
||||
cmd.arg(script)
|
||||
.args(["preflight", "--no-admin"])
|
||||
.args(PREFLIGHT_ARGS)
|
||||
.envs(env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(home());
|
||||
let out = local_server::exec_bounded(
|
||||
@@ -249,23 +299,39 @@ pub fn prepare() -> Result<Preflight, String> {
|
||||
if let Some(why) = refuse_start() {
|
||||
return Err(why);
|
||||
}
|
||||
fetch_and_ask()
|
||||
fetch_and_ask(Run::Codeman)
|
||||
}
|
||||
|
||||
fn fetch_and_ask() -> Result<Preflight, String> {
|
||||
let dir = fresh_run_dir()?;
|
||||
/// The same for adding an agent, where a running Codeman is the normal case.
|
||||
pub fn prepare_agent() -> Result<Preflight, String> {
|
||||
if let Some(why) = refuse_second_run() {
|
||||
return Err(why);
|
||||
}
|
||||
fetch_and_ask(Run::Agent)
|
||||
}
|
||||
|
||||
fn fetch_and_ask(run: Run) -> Result<Preflight, String> {
|
||||
let dir = fresh_run_dir(run)?;
|
||||
let script = dir.join(SCRIPT_FILE);
|
||||
download_installer(&script)?;
|
||||
ask_installer(&script, &installer_env())
|
||||
}
|
||||
|
||||
/// Why no install may start now: one runs already, or a Codeman server does and
|
||||
/// the installer's service step would replace it under its sessions.
|
||||
fn refuse_start() -> Option<String> {
|
||||
if setup::install_in_app().is_some() {
|
||||
/// One run at a time: both may install into the same runtime folder.
|
||||
fn refuse_second_run() -> Option<String> {
|
||||
if Run::Codeman.process().is_some() {
|
||||
return Some("Codeman is already being installed.".to_string());
|
||||
}
|
||||
setup::already_running()
|
||||
if Run::Agent.process().is_some() {
|
||||
return Some("A coding agent is already being installed.".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Why no install may start now: a run is going, or a Codeman server is and the
|
||||
/// installer's service step would replace it under its sessions.
|
||||
fn refuse_start() -> Option<String> {
|
||||
refuse_second_run().or_else(setup::already_running)
|
||||
}
|
||||
|
||||
/// Run the installer `prepare` fetched. It outlives the app (see the module
|
||||
@@ -276,11 +342,20 @@ pub fn start(plan: &Plan) -> Result<(), String> {
|
||||
if let Some(why) = refuse_start() {
|
||||
return Err(why);
|
||||
}
|
||||
spawn_run(plan)
|
||||
spawn_run(Run::Codeman, &install_args(plan), &install_env(plan))
|
||||
}
|
||||
|
||||
fn spawn_run(plan: &Plan) -> Result<(), String> {
|
||||
let dir = run_dir();
|
||||
/// Add one coding agent. Runs the same way as an install, so it outlives the
|
||||
/// app too, and leaves a running Codeman alone.
|
||||
pub fn start_agent(id: &str) -> Result<(), String> {
|
||||
if let Some(why) = refuse_second_run() {
|
||||
return Err(why);
|
||||
}
|
||||
spawn_run(Run::Agent, &agent_args(id), &no_terminal_env())
|
||||
}
|
||||
|
||||
fn spawn_run(run: Run, args: &[String], env: &[(String, String)]) -> Result<(), String> {
|
||||
let dir = run.dir();
|
||||
let script = dir.join(SCRIPT_FILE);
|
||||
if !script.is_file() {
|
||||
return Err("The installer is gone from the temporary folder. Try again.".to_string());
|
||||
@@ -298,9 +373,9 @@ fn spawn_run(plan: &Plan) -> Result<(), String> {
|
||||
CANCELLED.store(false, Ordering::SeqCst);
|
||||
let mut child = Command::new("/bin/bash")
|
||||
.arg(&script)
|
||||
.args(install_args(plan))
|
||||
.args(args)
|
||||
.envs(installer_env())
|
||||
.envs(install_env(plan))
|
||||
.envs(env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(home())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(output)
|
||||
@@ -314,8 +389,8 @@ fn spawn_run(plan: &Plan) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The process group of `pid`. `setup::install_in_app` may answer with one of
|
||||
/// the installer's subshells, which `ps` shows with the installer's own command
|
||||
/// The process group of `pid`. `Run::process` may answer with one of the
|
||||
/// installer's subshells, which `ps` shows with the installer's own command
|
||||
/// line, so its pid is not always the group's.
|
||||
fn group_of(pid: u32) -> Option<u32> {
|
||||
let out = Command::new("/bin/ps")
|
||||
@@ -345,14 +420,17 @@ fn signal_group(group: u32, signal: &str) {
|
||||
/// Stop the running install and whatever it started. What it left behind is
|
||||
/// picked up by the next run: `install.sh` re-runs a partial install in full.
|
||||
pub fn cancel() {
|
||||
let Some(group) = setup::install_in_app().and_then(group_of) else {
|
||||
let running = [Run::Codeman, Run::Agent]
|
||||
.into_iter()
|
||||
.find_map(|run| Some((run, run.process().and_then(group_of)?)));
|
||||
let Some((run, group)) = running else {
|
||||
return;
|
||||
};
|
||||
CANCELLED.store(true, Ordering::SeqCst);
|
||||
signal_group(group, "TERM");
|
||||
let deadline = Instant::now() + CANCEL_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
if setup::install_in_app().is_none() {
|
||||
if run.process().is_none() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
@@ -482,8 +560,8 @@ fn read_more(file: &mut Option<File>, path: &Path, offset: &mut u64) -> Vec<u8>
|
||||
/// of the file, so what it wrote just before it ended is never missed. `done`
|
||||
/// ends the run by itself: a subshell of the installer that lingers looks just
|
||||
/// like the installer, and must not keep the page waiting.
|
||||
pub fn follow(stale: impl Fn() -> bool, send: impl Fn(Value)) {
|
||||
let path = run_dir().join(OUTPUT_FILE);
|
||||
pub fn follow(run: Run, stale: impl Fn() -> bool, send: impl Fn(Value)) {
|
||||
let path = run.dir().join(OUTPUT_FILE);
|
||||
let mut file = None;
|
||||
let mut offset = 0;
|
||||
let mut progress = Progress::default();
|
||||
@@ -492,14 +570,14 @@ pub fn follow(stale: impl Fn() -> bool, send: impl Fn(Value)) {
|
||||
if stale() {
|
||||
return;
|
||||
}
|
||||
let running = setup::install_in_app().is_some();
|
||||
let running = run.process().is_some();
|
||||
let log = progress.feed(&read_more(&mut file, &path, &mut offset));
|
||||
if !running || progress.done {
|
||||
let mut log = log;
|
||||
log.extend(progress.finish());
|
||||
let cancelled = CANCELLED.load(Ordering::SeqCst);
|
||||
if progress.done {
|
||||
let _ = fs::remove_dir_all(run_dir());
|
||||
let _ = fs::remove_dir_all(run.dir());
|
||||
}
|
||||
send(progress.finished_message(cancelled, log));
|
||||
return;
|
||||
@@ -544,6 +622,25 @@ mod tests {
|
||||
assert_eq!(at(&install_args(&p)), "codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_list_and_the_agent_run_use_the_same_route() {
|
||||
// `cli_offerable` leaves Homebrew entries out only under --no-admin, so
|
||||
// without it on both the page could offer what the run then refuses.
|
||||
let args = agent_args("codex");
|
||||
assert!(args.iter().any(|a| a == "--no-admin"));
|
||||
assert!(PREFLIGHT_ARGS.contains(&"--no-admin"));
|
||||
assert_eq!(args[..2], ["agent".to_string(), "codex".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_run_changes_nothing_but_the_agent() {
|
||||
let args = agent_args("codex");
|
||||
for flag in ["--service", "--local", "--lan", "--port", "--ai-cli"] {
|
||||
assert!(!args.iter().any(|a| a == flag), "{flag}");
|
||||
}
|
||||
assert!(args.iter().any(|a| a == "--progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_goes_in_the_environment_and_never_in_the_arguments() {
|
||||
let mut p = plan();
|
||||
@@ -596,12 +693,21 @@ mod tests {
|
||||
#[test]
|
||||
fn only_a_real_run_counts_as_an_install() {
|
||||
let script = "/var/folders/ab/T/codeman-install/install.sh";
|
||||
assert!(is_install_run(&format!(
|
||||
assert!(Run::Codeman.matches(&format!(
|
||||
"/bin/bash {script} --no-admin --progress --service --local --port 3000"
|
||||
)));
|
||||
assert!(!is_install_run(&format!("/bin/bash {script} preflight --no-admin")));
|
||||
assert!(!is_install_run("/bin/bash /tmp/other/install.sh --progress"));
|
||||
assert!(!is_install_run("vim /tmp/codeman-install/notes.txt --progress"));
|
||||
assert!(!Run::Codeman.matches(&format!("/bin/bash {script} preflight --no-admin")));
|
||||
assert!(!Run::Codeman.matches("/bin/bash /tmp/other/install.sh --progress"));
|
||||
assert!(!Run::Codeman.matches("vim /tmp/codeman-install/notes.txt --progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_run_is_never_taken_for_an_install() {
|
||||
let command = "/bin/bash /var/folders/ab/T/codeman-agent/install.sh agent codex --no-admin --progress";
|
||||
assert!(Run::Agent.matches(command));
|
||||
assert!(!Run::Codeman.matches(command));
|
||||
assert_eq!(setup::install_in_progress(command), None);
|
||||
assert!(!Run::Agent.matches("/bin/bash /var/folders/ab/T/codeman-agent/install.sh preflight --no-admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -707,17 +813,19 @@ mod tests {
|
||||
network_password: Some("not-in-ps".to_string()),
|
||||
ai_cli: None,
|
||||
};
|
||||
let stand_in = |body: &str| {
|
||||
let dir = fresh_run_dir().unwrap();
|
||||
let stand_in_as = |run: Run, body: &str| {
|
||||
let dir = fresh_run_dir(run).unwrap();
|
||||
fs::write(dir.join(SCRIPT_FILE), format!("#!/bin/bash\n{body}\n")).unwrap();
|
||||
spawn_run(&plan).unwrap();
|
||||
spawn_run(run, &install_args(&plan), &install_env(&plan)).unwrap();
|
||||
dir
|
||||
};
|
||||
let collect = |stop_after: Option<usize>| {
|
||||
let stand_in = |body: &str| stand_in_as(Run::Codeman, body);
|
||||
let collect_from = |run: Run, stop_after: Option<usize>| {
|
||||
let got = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let (seen, sink) = (got.clone(), got.clone());
|
||||
let calls = AtomicUsize::new(0);
|
||||
follow(
|
||||
run,
|
||||
move || {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
stop_after.is_some_and(|n| seen.lock().unwrap().len() >= n)
|
||||
@@ -727,6 +835,7 @@ mod tests {
|
||||
let out = got.lock().unwrap().clone();
|
||||
out
|
||||
};
|
||||
let collect = |stop_after: Option<usize>| collect_from(Run::Codeman, stop_after);
|
||||
|
||||
// A reader that goes away (the page reloaded, the app quit) leaves the
|
||||
// install running, and the next reader gets it from the first line.
|
||||
@@ -740,7 +849,7 @@ echo "@@codeman phase done" >&2"#,
|
||||
let first = collect(Some(1));
|
||||
assert_eq!(first[0]["phase"], json!("source"));
|
||||
assert_eq!(first[0]["log"], json!(["==> first"]));
|
||||
assert!(setup::install_in_app().is_some(), "the install stopped with its reader");
|
||||
assert!(Run::Codeman.process().is_some(), "the install stopped with its reader");
|
||||
|
||||
fs::write(dir.join("go"), "").unwrap();
|
||||
let replay = collect(None);
|
||||
@@ -763,7 +872,7 @@ wait"#
|
||||
let sleeper = |procs: Vec<(u32, String)>| procs.iter().any(|(_, c)| c == &format!("sleep {marker}"));
|
||||
assert!(sleeper(setup::list_processes()), "the stand-in did not start");
|
||||
cancel();
|
||||
assert!(setup::install_in_app().is_none());
|
||||
assert!(Run::Codeman.process().is_none());
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(!sleeper(setup::list_processes()), "a child outlived Cancel");
|
||||
let done = collect(None);
|
||||
@@ -795,6 +904,37 @@ exit 1"#,
|
||||
assert_eq!(last["error"], json!("npm said no"));
|
||||
assert!(dir.join(OUTPUT_FILE).is_file());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
// An agent run is its own run: an install sees none, a second run of
|
||||
// either kind is refused, and Cancel reaches it all the same.
|
||||
let dir = stand_in_as(
|
||||
Run::Agent,
|
||||
r#"echo "@@codeman phase agent" >&2
|
||||
echo "==> Installing Codex..."
|
||||
while true; do sleep 1; done"#,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(Run::Agent.process().is_some());
|
||||
assert!(Run::Codeman.process().is_none());
|
||||
assert!(start_agent("codex").unwrap_err().contains("coding agent"));
|
||||
assert!(prepare_agent().unwrap_err().contains("coding agent"));
|
||||
assert!(start(&plan).unwrap_err().contains("coding agent"));
|
||||
let first = collect_from(Run::Agent, Some(1));
|
||||
assert_eq!(first[0]["phase"], json!("agent"));
|
||||
assert_eq!(first[0]["log"], json!(["==> Installing Codex..."]));
|
||||
cancel();
|
||||
assert!(Run::Agent.process().is_none());
|
||||
assert_eq!(collect_from(Run::Agent, None).last().unwrap()["cancelled"], json!(true));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
// Done ends it and leaves nothing, like an install.
|
||||
let dir = stand_in_as(
|
||||
Run::Agent,
|
||||
r#"echo "@@codeman phase agent" >&2
|
||||
echo "@@codeman phase done" >&2"#,
|
||||
);
|
||||
assert_eq!(collect_from(Run::Agent, None).last().unwrap()["ok"], json!(true));
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
/// A whole real install, driven down the same path as the page: build with
|
||||
@@ -805,16 +945,16 @@ exit 1"#,
|
||||
#[test]
|
||||
#[ignore = "installs Codeman: see the comment"]
|
||||
fn a_real_install_under_a_throwaway_home() {
|
||||
let found = fetch_and_ask().unwrap();
|
||||
let found = fetch_and_ask(Run::Codeman).unwrap();
|
||||
println!("preflight: {}", serde_json::to_string(&found).unwrap());
|
||||
let ai_cli = std::env::var("CODEMAN_E2E_AI_CLI").ok();
|
||||
let port = std::env::var("CODEMAN_E2E_PORT").unwrap().parse().unwrap();
|
||||
spawn_run(&Plan {
|
||||
let plan = Plan {
|
||||
port,
|
||||
network_password: None,
|
||||
ai_cli,
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
spawn_run(Run::Codeman, &install_args(&plan), &install_env(&plan)).unwrap();
|
||||
let ok = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let result = ok.clone();
|
||||
let started = Instant::now();
|
||||
@@ -822,6 +962,7 @@ exit 1"#,
|
||||
let cancelling = cancel_at.is_some();
|
||||
let asked = AtomicBool::new(false);
|
||||
follow(
|
||||
Run::Codeman,
|
||||
|| false,
|
||||
move |msg| {
|
||||
let at = started.elapsed().as_secs();
|
||||
@@ -845,6 +986,36 @@ exit 1"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// A real agent, down the same path as the page, under a throwaway HOME
|
||||
/// whose login PATH holds that HOME's Node.js: with the machine's own, npm
|
||||
/// would write into its real global prefix. `CODEMAN_E2E_AI_CLI` names it,
|
||||
/// and `CODEMAN_E2E_CANCEL=1` cancels it once it has started.
|
||||
#[test]
|
||||
#[ignore = "installs a coding agent: see the comment"]
|
||||
fn a_real_agent_under_a_throwaway_home() {
|
||||
let id = std::env::var("CODEMAN_E2E_AI_CLI").unwrap();
|
||||
let found = prepare_agent().unwrap();
|
||||
println!("preflight: {}", serde_json::to_string(&found.clis).unwrap());
|
||||
start_agent(&id).unwrap();
|
||||
let ok = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let result = ok.clone();
|
||||
let cancelling = std::env::var("CODEMAN_E2E_CANCEL").is_ok();
|
||||
let asked = AtomicBool::new(false);
|
||||
follow(
|
||||
Run::Agent,
|
||||
|| false,
|
||||
move |msg| {
|
||||
if msg["type"] == "installFinished" {
|
||||
result.store(msg["ok"] == json!(true), Ordering::SeqCst);
|
||||
} else if cancelling && !asked.swap(true, Ordering::SeqCst) {
|
||||
std::thread::spawn(cancel);
|
||||
}
|
||||
println!("{}", serde_json::to_string(&msg).unwrap());
|
||||
},
|
||||
);
|
||||
assert_eq!(ok.load(Ordering::SeqCst), !cancelling, "the agent did not end as asked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_path_is_filled_in() {
|
||||
let env = with_path(vec![("HOME".to_string(), "/Users/a".to_string())]);
|
||||
|
||||
@@ -102,6 +102,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";
|
||||
@@ -290,6 +291,9 @@ struct App {
|
||||
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)>,
|
||||
@@ -375,6 +379,7 @@ fn main() {
|
||||
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)),
|
||||
};
|
||||
|
||||
@@ -411,6 +416,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(),
|
||||
@@ -1110,6 +1116,7 @@ impl App {
|
||||
fn on_menu(&mut self, id: &str) {
|
||||
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();
|
||||
@@ -1274,7 +1281,7 @@ impl App {
|
||||
match gui_install::start(&plan) {
|
||||
Ok(()) => {
|
||||
send(json!({ "type": "installRunning", "port": plan.port }));
|
||||
gui_install::follow(stale, send);
|
||||
gui_install::follow(gui_install::Run::Codeman, stale, send);
|
||||
}
|
||||
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
|
||||
}
|
||||
@@ -1283,18 +1290,40 @@ impl App {
|
||||
}
|
||||
ConnectCmd::InstallAttach => {
|
||||
if choosing {
|
||||
let proxy = self.proxy.clone();
|
||||
let stale = self.next_install_reader();
|
||||
std::thread::spawn(move || {
|
||||
gui_install::follow(stale, |msg| {
|
||||
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
|
||||
});
|
||||
});
|
||||
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) {
|
||||
@@ -1328,6 +1357,32 @@ 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;
|
||||
@@ -1345,6 +1400,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,
|
||||
@@ -1353,6 +1409,7 @@ impl App {
|
||||
"version": VERSION,
|
||||
"starting": self.start_running(),
|
||||
"notice": notice,
|
||||
"addAgent": add_agent,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -620,7 +620,16 @@ pub enum ConnectCmd {
|
||||
},
|
||||
/// 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 {
|
||||
@@ -686,6 +695,12 @@ enum RawConnect {
|
||||
},
|
||||
InstallAttach,
|
||||
InstallCancel,
|
||||
AgentPrepare,
|
||||
AgentStart {
|
||||
#[serde(rename = "aiCli")]
|
||||
ai_cli: String,
|
||||
},
|
||||
AgentAttach,
|
||||
InstallRemote {
|
||||
target: String,
|
||||
},
|
||||
@@ -785,6 +800,9 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
|
||||
}
|
||||
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 }
|
||||
}
|
||||
@@ -1715,6 +1733,44 @@ mod tests {
|
||||
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";
|
||||
|
||||
@@ -351,7 +351,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.")) || gui_install::is_install_run(command) {
|
||||
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")
|
||||
@@ -362,15 +362,6 @@ pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The process of the graphical install running on this Mac, if one does. It
|
||||
/// leads its process group.
|
||||
pub fn install_in_app() -> Option<u32> {
|
||||
list_processes()
|
||||
.into_iter()
|
||||
.find(|(_, command)| gui_install::is_install_run(command))
|
||||
.map(|(pid, _)| pid)
|
||||
}
|
||||
|
||||
fn codeman_servers(procs: &[(u32, String)]) -> Vec<u32> {
|
||||
procs
|
||||
.iter()
|
||||
@@ -504,6 +495,9 @@ pub struct LocalStatus {
|
||||
/// 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,
|
||||
/// For RunningElsewhere: the addresses that Codeman listens on.
|
||||
pub elsewhere: Vec<String>,
|
||||
/// The probe's outcome, for the connect page's explanations.
|
||||
@@ -557,7 +551,8 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
let servers = codeman_servers(&procs);
|
||||
let found = listeners(&servers);
|
||||
let install = running_install(&procs);
|
||||
let install_in_app = procs.iter().any(|(_, c)| gui_install::is_install_run(c));
|
||||
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)) {
|
||||
@@ -638,6 +633,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
install_running: install.is_some(),
|
||||
install_port: install.flatten(),
|
||||
install_in_app,
|
||||
agent_install_in_app,
|
||||
elsewhere,
|
||||
outcome,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ const NOT_INSTALLED = {
|
||||
|
||||
let dom: JSDOM | null = null;
|
||||
|
||||
function open() {
|
||||
/** `first`: what the app says to a page that just loaded, with no look at this Mac yet. */
|
||||
function open(first?: Record<string, unknown>) {
|
||||
const sent: Sent[] = [];
|
||||
dom = new JSDOM(HTML, {
|
||||
url: 'codemanapp://localhost/index.html',
|
||||
@@ -77,8 +78,8 @@ function open() {
|
||||
const rail = () =>
|
||||
Array.from(doc.querySelectorAll('#wiz-rail li')).map((li) => [li.querySelector('span')?.textContent, li.className]);
|
||||
const sentOf = (cmd: string) => sent.filter((m) => m.cmd === cmd);
|
||||
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0' });
|
||||
native({ type: 'localStatus', status: NOT_INSTALLED });
|
||||
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0', ...first });
|
||||
if (!first) native({ type: 'localStatus', status: NOT_INSTALLED });
|
||||
return { sent, sentOf, doc, $, input, native, click, visible, rail };
|
||||
}
|
||||
|
||||
@@ -445,3 +446,292 @@ describe('an install that was already running', () => {
|
||||
expect(t.$('local-title').textContent).toContain('Terminal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding a coding agent to a Mac that has Codeman', () => {
|
||||
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
|
||||
const STOPPED = { ...NOT_INSTALLED, state: 'stopped', installed: true, installComplete: true };
|
||||
const AGENTS = {
|
||||
clis: [
|
||||
cli('claude', 'Claude Code', { installed: true, installable: false }),
|
||||
cli('codex', 'Codex'),
|
||||
cli('copilot', 'GitHub Copilot'),
|
||||
cli('deepseek', 'DeepSeek', { installable: false }),
|
||||
],
|
||||
};
|
||||
|
||||
function chooser(status: Record<string, unknown> = RUNNING, over: Record<string, unknown> = AGENTS) {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status });
|
||||
t.click('local-agents');
|
||||
t.native({ type: 'installPlan', plan: plan({ needs: { node: false, tmux: false, git: false }, ...over }) });
|
||||
return t;
|
||||
}
|
||||
|
||||
function installing(status: Record<string, unknown> = RUNNING) {
|
||||
const t = chooser(status);
|
||||
t.click('wiz-next');
|
||||
t.native({ type: 'installUpdate', phase: 'agent', log: ['Installing Codex...'] });
|
||||
return t;
|
||||
}
|
||||
|
||||
it('is offered whenever Codeman is here, and never when it is not', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||||
expect(t.visible('local-agents')).toBe(false);
|
||||
for (const status of [
|
||||
RUNNING,
|
||||
STOPPED,
|
||||
{ ...RUNNING, state: 'needsLogin' },
|
||||
{ ...RUNNING, state: 'runningElsewhere', elsewhere: ['127.0.0.1:4000'] },
|
||||
]) {
|
||||
t.native({ type: 'localStatus', status });
|
||||
expect(t.visible('local-agents'), String(status.state)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('is not offered while an install in Terminal still runs', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: { ...STOPPED, installRunning: true } });
|
||||
expect(t.visible('local-agents')).toBe(false);
|
||||
});
|
||||
|
||||
it('asks the app for the agent route, never the install route', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
t.click('local-agents');
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||||
expect(t.sentOf('installPrepare')).toHaveLength(0);
|
||||
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
|
||||
expect(t.visible('wiz-back')).toBe(false); // A second prepare would clear the first one's folder.
|
||||
});
|
||||
|
||||
it('lists what can be added first, what is here after it, and leaves out the rest', () => {
|
||||
const t = chooser();
|
||||
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
|
||||
const rows = [...t.$('wiz-agents').querySelectorAll('label')];
|
||||
expect(rows.map((r) => r.querySelector('b')?.textContent)).toEqual(['Codex', 'GitHub Copilot', 'Claude Code']);
|
||||
const inputs = rows.map((r) => r.querySelector('input') as HTMLInputElement);
|
||||
expect(inputs.map((i) => i.disabled)).toEqual([false, false, true]);
|
||||
expect(rows[2].textContent).toContain('Installed');
|
||||
expect(inputs[0].checked).toBe(true);
|
||||
});
|
||||
|
||||
it('asks nothing about Codeman, whatever the installer says about installing it', () => {
|
||||
const t = chooser(RUNNING, { ...AGENTS, blocker: 'macOS is too old for the tmux download.' });
|
||||
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
|
||||
expect(t.visible('wiz-reach-step')).toBe(false);
|
||||
expect(t.visible('wiz-skip')).toBe(false);
|
||||
expect(t.visible('wiz-terminal')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends the one agent chosen, and names it on the button', () => {
|
||||
const t = chooser();
|
||||
expect(t.$('wiz-next').textContent).toBe('Install Codex');
|
||||
const copilot = t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement;
|
||||
copilot.click();
|
||||
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
|
||||
t.click('wiz-next');
|
||||
expect(t.sentOf('agentStart')).toEqual([{ cmd: 'agentStart', aiCli: 'copilot' }]);
|
||||
expect(t.sentOf('installStart')).toHaveLength(0);
|
||||
expect(t.$('wiz-title').textContent).toBe('Installing GitHub Copilot');
|
||||
});
|
||||
|
||||
it('draws one row, for that agent', () => {
|
||||
const t = installing();
|
||||
expect(t.rail()).toEqual([['Installing Codex', 'now']]);
|
||||
expect(t.visible('wiz-cancel')).toBe(true);
|
||||
});
|
||||
|
||||
it('ends on what happened, and opens Codeman only when asked', () => {
|
||||
const t = installing();
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
|
||||
expect(t.$('wiz-lead').textContent).toContain('within a few minutes');
|
||||
expect(t.$('wiz-lead').textContent).toContain('reload');
|
||||
expect(t.rail()).toEqual([['Installing Codex', 'done']]);
|
||||
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
|
||||
expect(t.sentOf('connect')).toHaveLength(0);
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
|
||||
expect(t.$('wiz-next').textContent).toBe('Open Codeman');
|
||||
expect(t.$('wiz-back').textContent).toBe('Done');
|
||||
t.click('wiz-next');
|
||||
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: RUNNING.url })]);
|
||||
expect(t.visible('wizard')).toBe(false);
|
||||
});
|
||||
|
||||
it('has nothing to open when Codeman is stopped, and Done goes back to This Mac', () => {
|
||||
const t = installing(STOPPED);
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
t.native({ type: 'localStatus', status: STOPPED });
|
||||
expect(t.visible('wiz-next')).toBe(false);
|
||||
t.click('wiz-back');
|
||||
expect(t.visible('wizard')).toBe(false);
|
||||
expect(t.visible('local-start')).toBe(true);
|
||||
expect(t.visible('local-agents')).toBe(true);
|
||||
});
|
||||
|
||||
it('offers another only when there is another, and asks the installer again', () => {
|
||||
const t = installing();
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
expect(t.$('wiz-skip').textContent).toBe('Add another');
|
||||
t.click('wiz-skip');
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(2);
|
||||
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
|
||||
|
||||
const last = chooser(RUNNING, { clis: [cli('codex', 'Codex')] });
|
||||
last.click('wiz-next');
|
||||
last.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
expect(last.visible('wiz-skip')).toBe(false);
|
||||
});
|
||||
|
||||
it('says so when there is nothing left to add', () => {
|
||||
const t = chooser(RUNNING, { clis: [cli('claude', 'Claude Code', { installed: true, installable: false })] });
|
||||
expect(t.$('wiz-lead').textContent).toContain('already on this Mac');
|
||||
expect(t.visible('wiz-next')).toBe(false);
|
||||
expect(t.visible('wiz-back')).toBe(true);
|
||||
t.click('wiz-back');
|
||||
expect(t.visible('wizard')).toBe(false);
|
||||
});
|
||||
|
||||
it('a failure shows the reason, and Try again asks again with the same choice', () => {
|
||||
const t = chooser();
|
||||
(t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement).click();
|
||||
t.click('wiz-next');
|
||||
t.native({
|
||||
type: 'installFinished',
|
||||
ok: false,
|
||||
cancelled: false,
|
||||
error: 'GitHub Copilot did not install.',
|
||||
log: ['npm ERR! nope'],
|
||||
});
|
||||
expect(t.$('wiz-title').textContent).toBe('The install stopped');
|
||||
expect(t.$('wiz-message').textContent).toBe('GitHub Copilot did not install.');
|
||||
expect(t.$('wiz-log').textContent).toContain('npm ERR! nope');
|
||||
expect(t.visible('wiz-terminal')).toBe(false);
|
||||
expect(t.visible('wiz-back')).toBe(true);
|
||||
t.click('wiz-next');
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(2);
|
||||
t.native({ type: 'installPlan', plan: plan(AGENTS) });
|
||||
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
|
||||
});
|
||||
|
||||
it('a list it could not get says so, with no Terminal route to Codeman', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
t.click('local-agents');
|
||||
t.native({ type: 'installPrepareFailed', message: 'No network.' });
|
||||
expect(t.$('wiz-title').textContent).toBe('Could not get the list of coding agents');
|
||||
expect(t.$('wiz-message').textContent).toBe('No network.');
|
||||
expect(t.visible('wiz-terminal')).toBe(false);
|
||||
t.click('wiz-next');
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Cancel goes through the same command and leaves This Mac as it was', () => {
|
||||
const t = installing();
|
||||
t.click('wiz-cancel');
|
||||
expect(t.sentOf('installCancel')).toHaveLength(1);
|
||||
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
|
||||
expect(t.visible('wizard')).toBe(false);
|
||||
expect(t.$('status-text').textContent).toContain('Stopped installing Codex.');
|
||||
});
|
||||
|
||||
it('is picked up after the app was reopened, as an agent run and never as an install', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
|
||||
expect(t.sentOf('agentAttach')).toHaveLength(1);
|
||||
expect(t.sentOf('installAttach')).toHaveLength(0);
|
||||
expect(t.$('wiz-title').textContent).toBe('Installing a coding agent');
|
||||
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
|
||||
expect(t.rail()).toEqual([['Installing a coding agent', 'now']]);
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
expect(t.$('wiz-title').textContent).toBe('The coding agent is installed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the menu item that adds a coding agent', () => {
|
||||
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
|
||||
|
||||
it('opens the chooser on a page that is up', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
t.native({ type: 'addAgent' });
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('waits for the first look when it left a Codeman page to get here', () => {
|
||||
const t = open({ servers: [{ url: 'http://example.org:3000/' }], addAgent: true });
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(0);
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||||
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('leaves a Mac without Codeman on This Mac, with Install', () => {
|
||||
const t = open({ addAgent: true });
|
||||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(0);
|
||||
expect(t.visible('wizard')).toBe(false);
|
||||
expect(t.visible('local-install')).toBe(true);
|
||||
});
|
||||
|
||||
it('never replaces a run on screen', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
|
||||
t.native({ type: 'addAgent' });
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after Skip for now', () => {
|
||||
function skipped() {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||||
t.click('local-install');
|
||||
t.native({ type: 'installPlan', plan: plan() });
|
||||
t.click('wiz-skip');
|
||||
t.click('wiz-next');
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
t.native({
|
||||
type: 'localStatus',
|
||||
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
|
||||
});
|
||||
return t;
|
||||
}
|
||||
|
||||
it('stops at Codeman being ready instead of opening it with nothing to run', () => {
|
||||
const t = skipped();
|
||||
expect(t.$('wiz-title').textContent).toBe('Codeman is ready');
|
||||
expect(t.$('wiz-lead').textContent).toContain('no coding agent');
|
||||
expect(t.sentOf('connect')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Open Codeman opens it anyway', () => {
|
||||
const t = skipped();
|
||||
t.click('wiz-next');
|
||||
expect(t.sentOf('connect')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Add a coding agent goes to the chooser', () => {
|
||||
const t = skipped();
|
||||
expect(t.$('wiz-skip').textContent).toBe('Add a coding agent');
|
||||
t.click('wiz-skip');
|
||||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an install that got an agent still opens Codeman by itself', () => {
|
||||
const t = open();
|
||||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||||
t.click('local-install');
|
||||
t.native({ type: 'installPlan', plan: plan() });
|
||||
t.click('wiz-next');
|
||||
t.click('wiz-next');
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
t.native({
|
||||
type: 'localStatus',
|
||||
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
|
||||
});
|
||||
expect(t.sentOf('connect')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -539,6 +539,124 @@ describe('install.sh preflight', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('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('install_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)),
|
||||
|
||||
Reference in new issue
Block a user