mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Codeman itself always lived under $HOME; administrator rights were only spent on what it needs first (system packages for Node.js, tmux and git, Homebrew on a Mac). An account without those rights could not install it: the installer died in ensure_sudo, or in Homebrew's own installer. install.sh now has a no-admin route, taken by itself when the account has no administrator rights (no sudo binary; outside sudo/wheel/admin and `sudo -n true` fails; a refused sudo password; a Mac account outside the admin group with no writable Homebrew) and forced with --no-admin or CODEMAN_NO_ADMIN=1: - Node.js: the official tarball from nodejs.org, checked against the SHASUMS256.txt published beside it, into ~/.codeman/runtime/node. - tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin. - Both are linked from ~/.local/bin (never over a file that is not ours), and the runtime is on PATH and on the LaunchAgent's PATH. - git is never installed. Without a working git the source comes as an archive, the install is marked with .install-source, and it updates by re-running the installer: the new tree is built beside the old one and swapped in only after the build succeeded. A later re-run on a machine that has gained git turns it back into a clone. - Nothing on this route runs sudo, Homebrew or `xcode-select --install`, and the route is never decided by `sudo -v`. What it cannot supply is checked before the first download: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below 15 (the tmux build's deployment target). Along the way: - check_git no longer trusts `command -v git`. On a Mac without the Xcode Command Line Tools /usr/bin/git is a stub that opens Apple's install dialog; it is judged by `xcode-select -p` and never executed. Any other git is asked for its version. - scripts/git-hooks.mjs answers "not a checkout" without running git, for the same reason: postinstall calls it on every npm install. - The in-app updater knows the install kind `archive` and points such an install at the installer instead of `npm i -g aicodeman@latest`. - download() and download_to_stdout() resolve DOWNLOADER themselves. Only main() used to set it, so `install.sh cloudflared` died on `set -u` with the message swallowed by the step log. - The AI CLI menu leaves out a Homebrew-only install command where there is no Homebrew. Verified with the real installer: a bare Ubuntu 24.04 container as an unprivileged user (install, a PTY round trip through the API, update by re-run, cloudflared, uninstall), the same without a compiler (stops before any download), with a broken then working git, with sudo present but not usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git, the compilers, launchctl and tailscale replaced by tripwires. Not verified on a real standard macOS account: that account was an administrator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
287 lines
14 KiB
YAML
287 lines
14 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [master, main]
|
|
pull_request:
|
|
|
|
jobs:
|
|
ci:
|
|
name: Typecheck & Lint
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 22
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Check package-lock.json version sync
|
|
run: npm run check:lockfile
|
|
|
|
- name: Type check
|
|
run: npm run typecheck
|
|
|
|
- name: Lint
|
|
run: npm run lint
|
|
|
|
- name: Frontend JS syntax check
|
|
run: npm run check:frontend-syntax
|
|
|
|
# Asks `vitest list` what CI would actually collect, rather than matching
|
|
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
|
# (config/test-suites.ts) passes locally and dies in the test job with
|
|
# "browserType.launch: Executable doesn't exist".
|
|
- name: Browser-test exclusion check
|
|
run: npm run check:browser-excludes
|
|
|
|
- name: Format check
|
|
run: npm run format:check
|
|
|
|
# install.sh reaches users through `curl | bash` with nothing between it and
|
|
# them, and until now nothing in this repo checked it at all: no shellcheck,
|
|
# no bats, and the vitest gate is Node-only.
|
|
- name: install.sh syntax
|
|
run: bash -n install.sh
|
|
|
|
# macOS ships bash 3.2 and this runner has bash 5, so the constructs that
|
|
# actually break a Mac install are invisible here without a container. This
|
|
# step is what catches them — in particular expanding an EMPTY array under
|
|
# `set -u`, which bash 3.2 treats as an unbound variable and `bash -n`
|
|
# cannot see because it is a runtime error, not a syntax one.
|
|
- name: install.sh runs on bash 3.2 (macOS's version)
|
|
run: |
|
|
set -euo pipefail
|
|
docker run --rm -v "$PWD":/w -w /w bash:3.2 bash -n /w/install.sh
|
|
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 bash:3.2 bash -c '
|
|
set -euo pipefail
|
|
. /w/install.sh
|
|
detect_all_clis
|
|
# `shell` declares no binaries, so its offset/length window is length 0.
|
|
# Iterating it is the empty-array case; reaching here means it did not abort.
|
|
echo "bash $BASH_VERSION: ${#CLI_IDS[@]} CLIs, $CLI_FOUND_COUNT found"
|
|
cli_catalog_names >/dev/null
|
|
cli_catalog_print_install_hints >/dev/null
|
|
# The install menu with nothing installed and the user answering "s":
|
|
# skipping must warn and continue, never trip the "failed to install"
|
|
# gate (it did once, aborting the install before the clone).
|
|
has_tty() { return 0; }
|
|
headless_guard() { return 0; }
|
|
read_reply() { eval "$1=s"; }
|
|
NONINTERACTIVE=0
|
|
k=0; while [[ $k -lt ${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="no-such-cli-$k"; k=$((k + 1)); done
|
|
k=0; while [[ $k -lt ${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/$k"; k=$((k + 1)); done
|
|
CLI_DETECT_DONE=""; detect_all_clis
|
|
offer_ai_cli_install >/dev/null 2>&1
|
|
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
|
|
'
|
|
# 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
|
|
# expansion aborts the whole installer under `set -u`. The step above cannot
|
|
# reach that branch: this image HAS `timeout`, and with no `dsh` on PATH the
|
|
# probe is never called at all. So hide `timeout` and call it directly.
|
|
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 bash:3.2 bash -c '
|
|
set -euo pipefail
|
|
. /w/install.sh
|
|
printf "#!/bin/sh\necho \"DeepSeek Harness 0.1\"\n" > /tmp/dsh
|
|
printf "#!/bin/sh\necho \"dancer shell (Debian dsh)\"\n" > /tmp/not-dsh
|
|
chmod 755 /tmp/dsh /tmp/not-dsh
|
|
# A PATH the probe can still work on, minus the binary under test.
|
|
mkdir -p /tmp/nobin
|
|
for b in grep sh; do ln -sf "$(command -v $b)" "/tmp/nobin/$b"; done
|
|
export PATH=/tmp/nobin
|
|
if command -v timeout >/dev/null 2>&1; then
|
|
echo "timeout is still on PATH, so this is NOT exercising the empty-array branch" >&2
|
|
exit 1
|
|
fi
|
|
dsh_banner_probe /tmp/dsh
|
|
if dsh_banner_probe /tmp/not-dsh; then
|
|
echo "identity probe accepted a foreign dsh" >&2
|
|
exit 1
|
|
fi
|
|
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
|
|
'
|
|
# Installer v2: the question phase runs before the build, and every decision it
|
|
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
|
|
# default, the occupied-:443 menu and the rename question with canned answers,
|
|
# so a bash-4 construct or a flipped default in any of them fails here, not on a
|
|
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
|
|
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
|
|
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
|
|
set -euo pipefail
|
|
mkdir -p /tmp/h
|
|
. /w/install.sh
|
|
parse_flags --tailscale --service --name Build-Box --port 4000
|
|
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
|
|
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
|
|
has_tty() { return 0; }
|
|
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
|
|
systemctl() { return 0; }
|
|
LAUNCH_PRESET=""; NONINTERACTIVE=0
|
|
choose_launch_mode linux >/dev/null 2>&1
|
|
[[ "$LAUNCH_CHOICE" == "2" ]]
|
|
check_tailscale() { return 0; }
|
|
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
|
|
ts_backend_state() { printf Running; }
|
|
ts_dns_name() { printf box.tail.ts.net; }
|
|
ts_serve_443_target_port() { printf 8080; }
|
|
ts_serve_find_port_mapping() { :; }
|
|
ts_serve_port_used() { return 1; }
|
|
detect_tailscale_serve_url() { :; }
|
|
tailscale_choose_mapping >/dev/null 2>&1
|
|
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
|
|
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
|
|
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
|
|
[[ -z "$RENAMED" ]]
|
|
# A flag re-run keeps the password the unit already carries (and so
|
|
# never writes the unauthenticated ack), and the hand-start line the
|
|
# done screen prints carries every non-default value.
|
|
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
|
|
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
|
|
choose_network_binding >/dev/null 2>&1
|
|
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
|
|
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
|
|
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
|
|
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
|
|
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
|
|
'
|
|
# No-admin route: the helpers that decide what gets downloaded and whether it is
|
|
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
|
|
# stop-before-download check, and an archive install plus its replacement, all
|
|
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
|
|
# runs for real: a machine the route cannot serve must stop BEFORE a download.
|
|
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
|
|
set -euo pipefail
|
|
mkdir -p /tmp/h /tmp/src/Codeman-1/src
|
|
. /w/install.sh
|
|
sha=$(printf "%064d" 7)
|
|
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
|
|
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
|
|
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
|
|
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
|
|
fi
|
|
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
|
|
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
|
|
done
|
|
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
|
|
verify_sha256 /tmp/f "$good"
|
|
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
|
|
echo "accepted a wrong or empty checksum" >&2; exit 1
|
|
fi
|
|
is_musl_libc
|
|
NO_ADMIN=1; NONINTERACTIVE=1
|
|
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
|
|
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
|
|
check_build_tools() { return 0; }
|
|
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
|
|
echo "did not stop on a musl system" >&2; exit 1
|
|
fi
|
|
case "$out" in
|
|
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
|
|
*musl*) ;;
|
|
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
|
|
esac
|
|
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
|
|
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
|
|
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
|
|
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
|
|
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
|
|
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
|
|
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
|
|
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
|
|
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
|
|
grep -q 1.0.0 "$INSTALL_DIR/package.json"
|
|
touch "$INSTALL_DIR/from-the-old-build"
|
|
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
|
|
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
|
|
finish_source_swap
|
|
grep -q 2.0.0 "$INSTALL_DIR/package.json"
|
|
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
|
|
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
|
|
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
|
|
'
|
|
|
|
- name: CLI catalogue artifacts are in sync with stock.ts
|
|
run: npm run generate:cli-catalog -- --check
|
|
|
|
- name: Server boot smoke test
|
|
run: |
|
|
set -u
|
|
if ! command -v tmux >/dev/null; then
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y tmux
|
|
fi
|
|
npx tsx src/index.ts web --port 3151 > /tmp/boot.log 2>&1 &
|
|
SERVER_PID=$!
|
|
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS http://localhost:3151/api/status -o /dev/null; then
|
|
echo "Server booted in ${i}s"
|
|
exit 0
|
|
fi
|
|
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
|
echo "Server exited before becoming ready. Logs:"
|
|
cat /tmp/boot.log
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "Server did not respond on /api/status within 30s. Logs:"
|
|
cat /tmp/boot.log
|
|
exit 1
|
|
|
|
test:
|
|
name: Unit & integration tests
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 22
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Install tmux
|
|
run: |
|
|
if ! command -v tmux >/dev/null; then
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y tmux
|
|
fi
|
|
|
|
- name: Run unit & integration tests
|
|
# Excludes the suites that need chromium, per-machine PNG baselines or a
|
|
# quiet machine — see config/test-suites.ts for the list and the reason
|
|
# behind each entry. Identical to what `npm test` runs locally.
|
|
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
|
|
run: npm run test:ci
|
|
|
|
- name: Run xterm-zerolag-input package tests
|
|
# Layers 1-3 of the predictive-echo suites (unit laws, fixture replay,
|
|
# seeded fuzz): deterministic, no browser, no live server. Depends on
|
|
# the ROOT `npm ci` above — workspaces hoist the package's vitest into
|
|
# the root node_modules; do not add a separate install here.
|
|
run: npx vitest run
|
|
working-directory: packages/xterm-zerolag-input
|
|
|
|
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
|
|
# that job is informational and never gates a push or a PR.
|
|
# Note: three suites are excluded from CI, each with its own local runner:
|
|
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
|
# codex binary for codex-predictive-echo)
|
|
# npm run test:mobile the above plus environment-specific PNG baselines
|
|
# npm run test:perf wall-clock benchmarks; need an otherwise idle machine
|
|
# config/test-suites.ts holds the globs; the configs derive from it so the
|
|
# exclusions here and those runners cannot drift apart. Everything else runs in
|
|
# the `test` job above, which is the same thing `npm test` runs.
|