mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a56ea4978 | ||
|
|
543be8a85b | ||
|
|
3503b6ae55 | ||
|
|
84b59567b1 | ||
|
|
82c31b6073 |
@@ -1,5 +1,31 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- In-app self-updater, plus the SSE-registry and security-doc changes since 0.9.3.
|
||||
|
||||
**New: update Codeman from the web UI (App Settings → Updates).** A "Check for updates" button asks the server to query GitHub for the latest tagged release (falling back to `git ls-remote`) and shows its release notes; "Update now" then runs the full `git checkout <tag>` → `npm install` → `npm run build` → restart cycle and streams live progress that survives the service restart (the browser polls a status file across the connection drop).
|
||||
- **Channel:** latest tagged release (e.g. `codeman@0.9.4`), not bleeding-edge master.
|
||||
- **Dirty working trees are auto-stashed** (`git stash`, left for you to `git stash pop`) instead of discarded.
|
||||
- **Cross-platform restart**, detected from the running process: systemd (`systemctl --user restart codeman-web`) on Linux, launchd (`launchctl kickstart`) on macOS, or a printed manual command otherwise.
|
||||
- **Survives its own restart:** the updater runs detached in a transient `systemd-run --user --scope` (Linux) or `setsid` session (macOS), so the restart it triggers cannot kill the build mid-flight.
|
||||
- **Safety:** build failure rolls back to the pre-update commit (never restarts into a half-built `dist/`); the pre-restart status marker is reconciled on boot with an update-id + freshness guard so a normal reboot is not misreported as a completed update; concurrent updates are rejected (409); the runner script is staged outside the repo so `git checkout` cannot corrupt it mid-run; release tags are strictly validated before reaching the shell; `CODEMAN_DISABLE_SELF_UPDATE=1` disables the feature; non-git (npm-global) installs are detected and pointed at `npm i -g aicodeman@latest`.
|
||||
- New endpoints: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`.
|
||||
|
||||
**Also in this release:**
|
||||
- Sync the frontend `SSE_EVENTS` registry (`constants.js`) with the backend `sse-events.ts` so every broadcast event has a matching frontend entry.
|
||||
- Expand `docs/security-architecture.md` with the trust model, CSP detail, and a source-file map.
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Installer security notice + clarify gesture control stays opt-in and default-off.
|
||||
- **Installer:** `install.sh` now prints the network-security notice as the final block of both the fresh install (one-line `curl … | bash`) and the update flow, so it stays visible to the user: Codeman binds `127.0.0.1` by default (no password needed), and the safe ways to reach it remotely (`tailscale serve` / tunnel, or `--host 0.0.0.0` + `CODEMAN_PASSWORD`), noting a non-loopback bind without a password still starts but warns loudly.
|
||||
- **Gesture control** is **disabled by default** and is enabled only by the per-user toggle at App Settings → Display → Input → Gesture Control (`gestureControlEnabled`, default `false`). Setting `CODEMAN_GESTURE=1` on the server only makes the feature _available_ (CSP widening + same-origin `/gesture/` assets); it does **not** turn the overlay on. There is no default-on path — the bundle is injected only when a user explicitly enables the setting.
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.9.2 (must match `package.json`)
|
||||
**Version**: 0.9.4 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -199,7 +199,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (37, incl. `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (37, incl. `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
## Adding Features
|
||||
|
||||
|
||||
@@ -19,6 +19,43 @@ an explicit, guided opt‑in.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Network binding model](#1-network-binding-model)
|
||||
2. [Authentication](#2-authentication)
|
||||
3. [Request‑origin trust & the tunnel caveat](#3-requestorigin-trust--the-tunnel-caveat)
|
||||
4. [Recommended remote‑access setups](#4-recommended-remoteaccess-setups)
|
||||
5. [File‑serving hardening](#5-fileserving-hardening)
|
||||
6. [tmux launch hardening](#6-tmux-launch-hardening-cod31)
|
||||
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
|
||||
8. [Multi‑instance isolation](#8-multiinstance-isolation)
|
||||
9. [Transport security headers](#9-transport-security-headers)
|
||||
10. [Quick reference](#10-quick-reference)
|
||||
|
||||
---
|
||||
|
||||
## Trust model
|
||||
|
||||
**The security boundary is the network bind plus authentication — not the code Codeman
|
||||
runs.** Because sessions launch with `--dangerously-skip-permissions`, the web UI is by
|
||||
design a remote‑code‑execution surface for whoever is allowed to reach it. Everything
|
||||
below exists to control *who* that is.
|
||||
|
||||
| Actor | Reaches the UI when… | Is granted |
|
||||
|-------|----------------------|------------|
|
||||
| Same‑machine user | Always (default loopback bind) | Full session control — the intended local‑use case. |
|
||||
| Authenticated remote client | Tunnel/LAN reachability **and** a valid password or session cookie | Full session control. |
|
||||
| Unauthenticated remote client | Only if you bind a non‑loopback host with no password | Full session control — the exact case every default and warning works to prevent. |
|
||||
| Clients behind a loopback‑connecting tunnel | A reverse tunnel terminates on `127.0.0.1` | Inherit `req.ip = 127.0.0.1`, so they hit the localhost‑only exemptions (§3) unless a password is set. |
|
||||
|
||||
**Explicitly out of scope.** Codeman is access control for the operator console, not a
|
||||
sandbox for the code that console runs. It does **not** defend against: a compromised
|
||||
local user account (loopback is trusted), malicious contents in a workspace you
|
||||
deliberately open, or the breadth of filesystem a session's `workingDir` is pointed at
|
||||
(§5).
|
||||
|
||||
---
|
||||
|
||||
## 1. Network binding model
|
||||
|
||||
| Setting | Default | Source |
|
||||
@@ -210,10 +247,13 @@ TOCTOU window.
|
||||
A workspace `.svg` served inline as `image/svg+xml` is a stored‑XSS vector (SVG
|
||||
can carry `<script>`, same‑origin = full session control). `file-raw` therefore
|
||||
serves `.svg` as `application/octet-stream` + `Content-Disposition: attachment` +
|
||||
`nosniff`. With global `nosniff` + CSP `default-src 'self'`, other text types
|
||||
(`.html`, `.xml`, …) that fall through to `octet-stream` are not rendered as HTML
|
||||
either. Trusted QR/welcome SVGs are injected from API JSON (`innerHTML`), not via
|
||||
`file-raw`, so they are unaffected.
|
||||
`nosniff`. The control here is the **`octet-stream` + `attachment` + `nosniff`
|
||||
combination**, which forces a download instead of a render — not the CSP: the
|
||||
policy's `script-src` allows `'unsafe-inline'` (§9), so a same‑origin HTML
|
||||
document *would* be able to run inline scripts if the browser ever rendered it.
|
||||
By the same combination, other text types (`.html`, `.xml`, …) that fall through
|
||||
to `octet-stream` are downloaded, not executed. Trusted QR/welcome SVGs are
|
||||
injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected.
|
||||
|
||||
### Download sensitive‑path blocklist
|
||||
|
||||
@@ -293,14 +333,31 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
## 9. Transport security headers
|
||||
|
||||
`registerSecurityHeaders` applies on every response:
|
||||
`registerSecurityHeaders` (`src/web/middleware/auth.ts`) applies on every response:
|
||||
|
||||
- `Content-Security-Policy: default-src 'self'` (widened only for `/gesture/`
|
||||
assets when `CODEMAN_GESTURE=1`, to load self‑hosted MediaPipe)
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options`
|
||||
- `Strict-Transport-Security` when served over HTTPS
|
||||
- CORS restricted to localhost origins
|
||||
- **`Content-Security-Policy`** — baseline `default-src 'self'`, with these
|
||||
deliberate widenings (so the policy is tighter than "self only" but every
|
||||
exception is enumerated and same‑origin‑first):
|
||||
- `script-src` / `style-src` / `font-src` also allow `https://cdn.jsdelivr.net`
|
||||
(CDN fallback for a few libraries). `script-src` and `style-src` additionally
|
||||
allow `'unsafe-inline'` — relevant to the SVG/HTML handling in §5, where the
|
||||
`octet-stream` + `nosniff` download (not the CSP) is what blocks execution.
|
||||
- `connect-src` allows `wss://api.deepgram.com` (streaming voice input).
|
||||
- `img-src` allows `data:` and `blob:` (inline / generated images, QR codes).
|
||||
- `frame-ancestors 'self'`.
|
||||
- **Gesture opt‑in (`CODEMAN_GESTURE=1`):** `script-src` gains
|
||||
`'wasm-unsafe-eval'` and a `worker-src 'self' blob:` directive is added, for
|
||||
self‑hosted MediaPipe. Its wasm runtime + model are same‑origin under
|
||||
`/gesture/`, so no extra `connect-src` entry is needed. OFF by default, so the
|
||||
production CSP is byte‑for‑byte unchanged.
|
||||
- **`X-Content-Type-Options: nosniff`** — blocks MIME sniffing (pairs with §5).
|
||||
- **`X-Frame-Options: SAMEORIGIN`** — clickjacking defense (mirrors
|
||||
`frame-ancestors 'self'`).
|
||||
- **`Strict-Transport-Security: max-age=31536000; includeSubDomains`** — only when
|
||||
served over HTTPS (`--https`).
|
||||
- **CORS** — `Access-Control-Allow-Origin` is reflected **only** for origins whose
|
||||
hostname is `localhost` / `127.0.0.1` / `::1`; any other origin gets no CORS
|
||||
headers. `OPTIONS` preflights are answered `204`.
|
||||
|
||||
---
|
||||
|
||||
@@ -317,3 +374,21 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
**Audit log:** session lifecycle and server start are recorded in
|
||||
`~/.codeman/session-lifecycle.jsonl`.
|
||||
|
||||
### Key source files
|
||||
|
||||
| Concern | File |
|
||||
|---------|------|
|
||||
| Bind‑host classification, env‑flag parsing | `src/web/network-auth-policy.ts` |
|
||||
| Start‑and‑warn policy | `src/web/server.ts` (`WebServer.start()`) |
|
||||
| Auth pipeline, rate limiting, security headers, CORS | `src/web/middleware/auth.ts` |
|
||||
| File‑path containment (realpath‑before‑check) | `src/web/route-helpers.ts` (`validateSessionFilePath`) |
|
||||
| File routes, caps, SVG handling, download blocklist | `src/web/routes/file-routes.ts` |
|
||||
| Instance/socket/data‑dir scoping | `src/config/instance.ts` |
|
||||
|
||||
---
|
||||
|
||||
> **Maintenance note:** the behaviours above were verified against the source on
|
||||
> 2026‑06‑09. When you change auth, the bind policy, CSP/headers, or the file
|
||||
> routes, update this document in the same change — several sections quote exact
|
||||
> values (caps, CSP directives, TTLs) that drift silently otherwise.
|
||||
|
||||
+20
@@ -99,6 +99,20 @@ die() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Security notice — printed at the very end of install/update so it is the last
|
||||
# thing the user sees (the default loopback bind + how to expose it safely).
|
||||
print_security_notice() {
|
||||
echo ""
|
||||
echo -e " ${YELLOW}${BOLD}Security:${NC}"
|
||||
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
|
||||
echo -e " To reach it from another device, do ONE of:"
|
||||
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
|
||||
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
|
||||
echo -e " A non-loopback bind without a password still starts, but warns loudly."
|
||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup on Failure
|
||||
# ============================================================================
|
||||
@@ -1363,6 +1377,10 @@ main() {
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Security notice — last informational block so it stays visible (when not
|
||||
# auto-launching below; if we exec, the server prints the same notice anyway).
|
||||
print_security_notice
|
||||
|
||||
# Run now in foreground (must be last — exec replaces the shell)
|
||||
if [[ "$launch_choice" == "1" ]]; then
|
||||
local profile
|
||||
@@ -1410,6 +1428,8 @@ update() {
|
||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
print_security_notice
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# self-update.sh — apply a Codeman release update from inside the running app.
|
||||
#
|
||||
# Spawned DETACHED by the web server (POST /api/system/update → src/web/self-update.ts).
|
||||
# It outlives the service restart it triggers, so it MUST run from a copy OUTSIDE
|
||||
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
|
||||
# checkout` rewrites the in-repo copy and bash reads scripts lazily.
|
||||
#
|
||||
# Reports progress by writing ~/.codeman/update-status.json atomically; the
|
||||
# browser polls GET /api/system/update/status across the restart drop. The
|
||||
# freshly-booted server reconciles the final "restarting" → "completed"/"failed".
|
||||
#
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a
|
||||
# manual command (foreground installs). Linux launches inside a transient
|
||||
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
#
|
||||
# Args (all from the server, never user input — tag is validated server-side):
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none>
|
||||
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
|
||||
# --log <path> [--prev-sha <sha>] [--stash]
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
REPO=""
|
||||
TAG=""
|
||||
SUPERVISOR="none"
|
||||
STATUS_FILE=""
|
||||
UPDATE_ID=""
|
||||
FROM_VERSION=""
|
||||
NODE="node"
|
||||
LOG="/dev/null"
|
||||
PREV_SHA=""
|
||||
DO_STASH=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--tag) TAG="$2"; shift 2 ;;
|
||||
--supervisor) SUPERVISOR="$2"; shift 2 ;;
|
||||
--status-file) STATUS_FILE="$2"; shift 2 ;;
|
||||
--update-id) UPDATE_ID="$2"; shift 2 ;;
|
||||
--from-version) FROM_VERSION="$2"; shift 2 ;;
|
||||
--node) NODE="$2"; shift 2 ;;
|
||||
--log) LOG="$2"; shift 2 ;;
|
||||
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
||||
--stash) DO_STASH=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# All output → the log file (the process is detached, no tty).
|
||||
exec >>"$LOG" 2>&1 || true
|
||||
echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO"
|
||||
|
||||
# Make node/npm/git reachable regardless of the (possibly minimal) service env.
|
||||
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
|
||||
STASH_REF=""
|
||||
MANUAL_CMD=""
|
||||
|
||||
# Write the status file atomically via node (valid JSON, preserves startedAt).
|
||||
write_status() {
|
||||
local phase="$1" message="$2" err="${3:-}"
|
||||
STATUS_FILE="$STATUS_FILE" UPDATE_ID="$UPDATE_ID" PHASE="$phase" MESSAGE="$message" \
|
||||
FROM_VERSION="$FROM_VERSION" TO_VERSION="$TO_VERSION" TO_TAG="$TAG" PREV_SHA="$PREV_SHA" \
|
||||
STASH_REF="$STASH_REF" SUPERVISOR="$SUPERVISOR" ERROR="$err" MANUAL_CMD="$MANUAL_CMD" \
|
||||
"$NODE" -e '
|
||||
const fs = require("fs");
|
||||
const f = process.env.STATUS_FILE;
|
||||
let started = 0;
|
||||
try { const cur = JSON.parse(fs.readFileSync(f, "utf8")); if (cur && cur.startedAt) started = cur.startedAt; } catch {}
|
||||
const s = {
|
||||
updateId: process.env.UPDATE_ID,
|
||||
phase: process.env.PHASE,
|
||||
message: process.env.MESSAGE,
|
||||
fromVersion: process.env.FROM_VERSION,
|
||||
startedAt: started,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (process.env.TO_VERSION) s.toVersion = process.env.TO_VERSION;
|
||||
if (process.env.TO_TAG) s.toTag = process.env.TO_TAG;
|
||||
if (process.env.PREV_SHA) s.prevSha = process.env.PREV_SHA;
|
||||
s.stashRef = process.env.STASH_REF || null;
|
||||
if (process.env.SUPERVISOR) s.supervisor = process.env.SUPERVISOR;
|
||||
if (process.env.ERROR) s.error = process.env.ERROR;
|
||||
if (process.env.MANUAL_CMD) s.manualRestartCommand = process.env.MANUAL_CMD;
|
||||
const tmp = f + ".tmp-" + process.pid;
|
||||
fs.writeFileSync(tmp, JSON.stringify(s, null, 2));
|
||||
fs.renameSync(tmp, f);
|
||||
' || echo "[self-update] WARN: status write failed ($phase)"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local msg="$1" err="${2:-}"
|
||||
echo "[self-update] FAILED: $msg ($err)"
|
||||
write_status "failed" "$msg" "$err"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Restore the previous commit + working build so the still-running server keeps
|
||||
# serving good code. We do NOT restart on failure.
|
||||
rollback_and_fail() {
|
||||
local msg="$1"
|
||||
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
|
||||
if [[ -n "$PREV_SHA" ]]; then
|
||||
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit >/dev/null 2>&1 || true
|
||||
npm run build >/dev/null 2>&1 || true
|
||||
fi
|
||||
fail "$msg — rolled back to the previous version" "$msg"
|
||||
}
|
||||
|
||||
cd "$REPO" || fail "Install directory not found" "cd $REPO"
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || fail "Not a git repository" "$REPO"
|
||||
|
||||
write_status "preparing" "Preparing update to v$TO_VERSION…"
|
||||
|
||||
# 1) Stash local changes (left for the user to pop — never auto-popped).
|
||||
if [[ "$DO_STASH" == "1" ]]; then
|
||||
write_status "stashing" "Stashing local changes…"
|
||||
STASH_MSG="codeman-pre-update-$UPDATE_ID"
|
||||
if git stash push -u -m "$STASH_MSG" >/dev/null 2>&1; then
|
||||
STASH_REF="$STASH_MSG"
|
||||
echo "[self-update] stashed local changes as $STASH_MSG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2) Fetch the target tag.
|
||||
write_status "fetching" "Fetching $TAG…"
|
||||
git fetch --tags --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null \
|
||||
|| git fetch --tags --force origin \
|
||||
|| fail "Could not fetch the release" "git fetch $TAG"
|
||||
|
||||
# 3) Check out the release tag (detached HEAD at the release).
|
||||
write_status "checkout" "Checking out $TAG…"
|
||||
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
|
||||
|
||||
# 4) Install dependencies.
|
||||
write_status "installing" "Installing dependencies…"
|
||||
npm install --no-fund --no-audit || rollback_and_fail "Dependency install failed"
|
||||
|
||||
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
||||
write_status "building" "Building…"
|
||||
npm run build || rollback_and_fail "Build failed"
|
||||
|
||||
# 6) Restart the service so the new code loads. Write the terminal pre-restart
|
||||
# marker FIRST so the freshly-booted server can reconcile it deterministically.
|
||||
write_status "restarting" "Restarting Codeman…"
|
||||
echo "[self-update] build OK, restarting via $SUPERVISOR"
|
||||
|
||||
case "$SUPERVISOR" in
|
||||
systemd)
|
||||
systemctl --user restart codeman-web.service \
|
||||
|| fail "Build succeeded but restart failed — run: systemctl --user restart codeman-web" "systemctl restart"
|
||||
;;
|
||||
launchd)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.codeman.web" 2>/dev/null || {
|
||||
PLIST="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
launchctl unload "$PLIST" 2>/dev/null || true
|
||||
launchctl load "$PLIST" 2>/dev/null \
|
||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
||||
}
|
||||
;;
|
||||
*)
|
||||
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
echo "[self-update] no supervisor — manual restart required"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[self-update] restart issued; done"
|
||||
exit 0
|
||||
@@ -66,3 +66,4 @@ export * from './teams.js';
|
||||
export * from './push.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @fileoverview Types for the in-app self-updater.
|
||||
*
|
||||
* Codeman can update itself from the web UI (App Settings → Updates). The flow
|
||||
* is driven by a detached `scripts/self-update.sh` that outlives the service
|
||||
* restart it triggers, and a status file at `~/.codeman/update-status.json`
|
||||
* (see `dataPath('update-status.json')`) that the browser polls across the
|
||||
* restart boundary.
|
||||
*
|
||||
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
|
||||
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
|
||||
*
|
||||
* @module types/update
|
||||
*/
|
||||
|
||||
/** Which init system supervises the running server (decides how we restart it). */
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
|
||||
/**
|
||||
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
|
||||
* `completed-needs-manual-restart` are terminal; the rest are in-flight.
|
||||
*/
|
||||
export type UpdatePhase =
|
||||
| 'idle'
|
||||
| 'queued'
|
||||
| 'preparing'
|
||||
| 'stashing'
|
||||
| 'fetching'
|
||||
| 'checkout'
|
||||
| 'installing'
|
||||
| 'building'
|
||||
| 'restarting'
|
||||
| 'completed'
|
||||
| 'completed-needs-manual-restart'
|
||||
| 'failed';
|
||||
|
||||
/** Persisted update progress, written atomically by the updater + boot reconcile. */
|
||||
export interface UpdateStatus {
|
||||
/** Nonce identifying this run; guards boot-reconcile against stale/foreign status. */
|
||||
updateId: string;
|
||||
phase: UpdatePhase;
|
||||
/** Human-readable one-liner for the UI. */
|
||||
message: string;
|
||||
/** Version the server was on when the update started. */
|
||||
fromVersion: string;
|
||||
/** Target version (parsed from the release tag). */
|
||||
toVersion?: string;
|
||||
/** Target git tag, e.g. `codeman@0.9.4`. */
|
||||
toTag?: string;
|
||||
/** Commit the repo was on before the update, for rollback. */
|
||||
prevSha?: string;
|
||||
/** Name of the stash holding local changes (when the tree was dirty), else null. */
|
||||
stashRef?: string | null;
|
||||
supervisor?: SupervisorKind;
|
||||
/** epoch ms — update start (freshness guard for boot reconcile). */
|
||||
startedAt: number;
|
||||
/** epoch ms — last write. */
|
||||
updatedAt: number;
|
||||
/** Populated on failure. */
|
||||
error?: string;
|
||||
/** Shown for the `none` supervisor — the command the user must run by hand. */
|
||||
manualRestartCommand?: string;
|
||||
}
|
||||
|
||||
/** Describes the running install — drives whether/how the Updates UI is shown. */
|
||||
export interface InstallInfo {
|
||||
installKind: InstallKind;
|
||||
installDir: string;
|
||||
/** Current git branch, or `HEAD` when detached (e.g. pinned to a release tag). */
|
||||
branch?: string;
|
||||
/** Uncommitted local changes present (true → updater will auto-stash). */
|
||||
dirty: boolean;
|
||||
supervisor: SupervisorKind;
|
||||
currentVersion: string;
|
||||
/** False when `CODEMAN_DISABLE_SELF_UPDATE=1`. */
|
||||
selfUpdateEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Result of "check for updates" — current vs. latest release. */
|
||||
export interface UpdateCheckResult {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
latestTag: string | null;
|
||||
updateAvailable: boolean;
|
||||
/** Release notes (markdown) when available from the GitHub API. */
|
||||
notes?: string | null;
|
||||
/** Link to the release page. */
|
||||
htmlUrl?: string | null;
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
error?: string;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts)
|
||||
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
|
||||
*
|
||||
* @dependency None (first in load order)
|
||||
@@ -241,27 +241,45 @@ const SSE_EVENTS = {
|
||||
SESSION_IDLE: 'session:idle',
|
||||
SESSION_WORKING: 'session:working',
|
||||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||||
SESSION_AUTO_COMPACT: 'session:autoCompact',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
SCHEDULED_UPDATED: 'scheduled:updated',
|
||||
SCHEDULED_COMPLETED: 'scheduled:completed',
|
||||
SCHEDULED_STOPPED: 'scheduled:stopped',
|
||||
SCHEDULED_LOG: 'scheduled:log',
|
||||
SCHEDULED_DELETED: 'scheduled:deleted',
|
||||
|
||||
// Respawn
|
||||
RESPAWN_STARTED: 'respawn:started',
|
||||
RESPAWN_STOPPED: 'respawn:stopped',
|
||||
RESPAWN_STATE_CHANGED: 'respawn:stateChanged',
|
||||
RESPAWN_CYCLE_STARTED: 'respawn:cycleStarted',
|
||||
RESPAWN_CYCLE_COMPLETED: 'respawn:cycleCompleted',
|
||||
RESPAWN_BLOCKED: 'respawn:blocked',
|
||||
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
|
||||
RESPAWN_STEP_SENT: 'respawn:stepSent',
|
||||
RESPAWN_STEP_COMPLETED: 'respawn:stepCompleted',
|
||||
RESPAWN_DETECTION_UPDATE: 'respawn:detectionUpdate',
|
||||
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
|
||||
RESPAWN_AI_CHECK_STARTED: 'respawn:aiCheckStarted',
|
||||
RESPAWN_AI_CHECK_COMPLETED: 'respawn:aiCheckCompleted',
|
||||
RESPAWN_AI_CHECK_FAILED: 'respawn:aiCheckFailed',
|
||||
RESPAWN_AI_CHECK_COOLDOWN: 'respawn:aiCheckCooldown',
|
||||
RESPAWN_PLAN_CHECK_STARTED: 'respawn:planCheckStarted',
|
||||
RESPAWN_PLAN_CHECK_COMPLETED: 'respawn:planCheckCompleted',
|
||||
RESPAWN_PLAN_CHECK_FAILED: 'respawn:planCheckFailed',
|
||||
RESPAWN_TIMER_STARTED: 'respawn:timerStarted',
|
||||
RESPAWN_TIMER_CANCELLED: 'respawn:timerCancelled',
|
||||
RESPAWN_TIMER_COMPLETED: 'respawn:timerCompleted',
|
||||
RESPAWN_ERROR: 'respawn:error',
|
||||
RESPAWN_ACTION_LOG: 'respawn:actionLog',
|
||||
RESPAWN_LOG: 'respawn:log',
|
||||
RESPAWN_ERROR: 'respawn:error',
|
||||
RESPAWN_CONFIG_UPDATED: 'respawn:configUpdated',
|
||||
|
||||
// Tasks
|
||||
TASK_CREATED: 'task:created',
|
||||
@@ -288,6 +306,12 @@ const SSE_EVENTS = {
|
||||
SESSION_BASH_TOOL_END: 'session:bashToolEnd',
|
||||
SESSION_BASH_TOOLS_UPDATE: 'session:bashToolsUpdate',
|
||||
|
||||
// Session: Plan
|
||||
SESSION_PLAN_TASK_UPDATE: 'session:planTaskUpdate',
|
||||
SESSION_PLAN_CHECKPOINT: 'session:planCheckpoint',
|
||||
SESSION_PLAN_ROLLBACK: 'session:planRollback',
|
||||
SESSION_PLAN_TASK_ADDED: 'session:planTaskAdded',
|
||||
|
||||
// Hooks (Claude Code hook events)
|
||||
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
|
||||
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
|
||||
@@ -338,6 +362,18 @@ const SSE_EVENTS = {
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
|
||||
// Teams (agent teams)
|
||||
TEAM_CREATED: 'team:created',
|
||||
TEAM_UPDATED: 'team:updated',
|
||||
TEAM_REMOVED: 'team:removed',
|
||||
TEAM_TASK_UPDATED: 'team:taskUpdated',
|
||||
|
||||
// Transcript
|
||||
TRANSCRIPT_COMPLETE: 'transcript:complete',
|
||||
TRANSCRIPT_PLAN_MODE: 'transcript:plan_mode',
|
||||
TRANSCRIPT_TOOL_START: 'transcript:tool_start',
|
||||
TRANSCRIPT_TOOL_END: 'transcript:tool_end',
|
||||
|
||||
// Clipboard
|
||||
CLIPBOARD_WRITE: 'clipboard:write',
|
||||
|
||||
|
||||
@@ -1078,6 +1078,24 @@
|
||||
<span id="tunnelUploadUrlDisplay" class="settings-item-value" style="cursor:pointer; text-decoration:underline; font-family:monospace; font-size:12px" title="Click to copy"></span>
|
||||
</div>
|
||||
|
||||
<!-- Updates Section -->
|
||||
<div class="settings-section-header">Updates</div>
|
||||
<div class="settings-item" title="Codeman version currently running">
|
||||
<span class="settings-item-label">Current Version</span>
|
||||
<span class="settings-item-value" id="updateCurrentVersion" style="font-family:monospace">—</span>
|
||||
</div>
|
||||
<div class="settings-item" id="updateCheckRow" title="Check GitHub for a newer Codeman release">
|
||||
<span class="settings-item-label">Check for Updates</span>
|
||||
<button class="btn-toolbar btn-sm" id="updateCheckBtn" onclick="app.checkForUpdate()">Check now</button>
|
||||
</div>
|
||||
<div id="updateResult" style="display:none; padding:4px 2px 8px; font-size:13px; color:var(--text-secondary)"></div>
|
||||
<div class="settings-item" id="updateActionRow" style="display:none">
|
||||
<span class="settings-item-label" id="updateActionLabel">Update available</span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="updateNowBtn" onclick="app.startSelfUpdate()">Update now</button>
|
||||
</div>
|
||||
<div id="updateNotes" style="display:none; max-height:160px; overflow:auto; padding:8px 10px; margin:4px 0 8px; font-size:12px; line-height:1.45; white-space:pre-wrap; word-break:break-word; background:rgba(127,127,127,0.08); border:1px solid var(--border); border-radius:6px"></div>
|
||||
<div id="updateProgress" style="display:none; padding:8px 10px; margin:4px 0 8px; font-size:13px; border:1px solid var(--border); border-radius:6px"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab-Switch Tab -->
|
||||
|
||||
@@ -430,6 +430,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
providerEl.textContent = providerName;
|
||||
providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : '');
|
||||
|
||||
// Updates section — show current version, reset transient result/progress UI.
|
||||
this._initUpdatesSection();
|
||||
|
||||
// Reset to first tab and wire up tab switching
|
||||
this.switchSettingsTab('settings-display');
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
@@ -465,6 +468,177 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Self-Update (App Settings → Updates). Backend: src/web/self-update.ts.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Friendly label for an in-flight update phase. */
|
||||
_updatePhaseText(phase) {
|
||||
return {
|
||||
queued: 'Queued…',
|
||||
preparing: 'Preparing…',
|
||||
stashing: 'Stashing local changes…',
|
||||
fetching: 'Fetching release…',
|
||||
checkout: 'Checking out release…',
|
||||
installing: 'Installing dependencies…',
|
||||
building: 'Building…',
|
||||
restarting: 'Restarting Codeman…',
|
||||
}[phase] || phase;
|
||||
},
|
||||
|
||||
/** Populate the version row and clear transient UI when the modal opens. */
|
||||
_initUpdatesSection() {
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl) verEl.textContent = (this.$('versionDisplay')?.textContent || '').trim() || '—';
|
||||
for (const id of ['updateResult', 'updateActionRow', 'updateNotes', 'updateProgress']) {
|
||||
const el = this.$(id);
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
this._updateCheck = null;
|
||||
},
|
||||
|
||||
_setUpdateResult(html) {
|
||||
const el = this.$('updateResult');
|
||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||
},
|
||||
|
||||
_setUpdateProgress(html) {
|
||||
const el = this.$('updateProgress');
|
||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||
},
|
||||
|
||||
/** Manual "Check for updates" — asks the server to query GitHub. */
|
||||
async checkForUpdate() {
|
||||
const btn = this.$('updateCheckBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
|
||||
const data = await this._apiJson('/api/system/update/check');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Check now'; }
|
||||
|
||||
const actionRow = this.$('updateActionRow');
|
||||
const notes = this.$('updateNotes');
|
||||
if (actionRow) actionRow.style.display = 'none';
|
||||
if (notes) notes.style.display = 'none';
|
||||
|
||||
if (!data) {
|
||||
this._setUpdateResult('Could not check for updates. Try again later.');
|
||||
return;
|
||||
}
|
||||
this._updateCheck = data;
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
|
||||
|
||||
if (data.installKind && data.installKind !== 'git') {
|
||||
this._setUpdateResult(
|
||||
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data.selfUpdateEnabled === false) {
|
||||
this._setUpdateResult('In-app updates are disabled on this server (CODEMAN_DISABLE_SELF_UPDATE=1).');
|
||||
return;
|
||||
}
|
||||
if (data.error && !data.updateAvailable) {
|
||||
this._setUpdateResult(escapeHtml(data.error));
|
||||
return;
|
||||
}
|
||||
if (data.updateAvailable && data.latestVersion) {
|
||||
this._setUpdateResult(
|
||||
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> (current v${escapeHtml(data.currentVersion || '')})`
|
||||
);
|
||||
const label = this.$('updateActionLabel');
|
||||
if (label) label.textContent = `Update to v${data.latestVersion}`;
|
||||
if (actionRow) actionRow.style.display = 'flex';
|
||||
const nowBtn = this.$('updateNowBtn');
|
||||
if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Update now'; }
|
||||
if (notes && data.notes) {
|
||||
notes.style.display = 'block';
|
||||
notes.textContent = data.notes;
|
||||
}
|
||||
} else {
|
||||
this._setUpdateResult(`You're up to date (v${escapeHtml(data.currentVersion || '')}).`);
|
||||
}
|
||||
},
|
||||
|
||||
/** Start the update, then poll status across the service restart. */
|
||||
async startSelfUpdate() {
|
||||
const target = this._updateCheck?.latestVersion ? `v${this._updateCheck.latestVersion}` : 'the latest release';
|
||||
if (!confirm(`Update Codeman to ${target}? The server will restart and this page will reload.`)) return;
|
||||
|
||||
const btn = this.$('updateNowBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
|
||||
const res = await this._apiPost('/api/system/update', {});
|
||||
if (!res || !res.ok) {
|
||||
let msg = 'Failed to start the update.';
|
||||
try { const j = await res.json(); if (j?.error?.message) msg = j.error.message; } catch {}
|
||||
this._setUpdateProgress(`<span style="color:var(--danger,#e5534b)">${escapeHtml(msg)}</span>`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Update now'; }
|
||||
return;
|
||||
}
|
||||
const actionRow = this.$('updateActionRow');
|
||||
if (actionRow) actionRow.style.display = 'none';
|
||||
const notes = this.$('updateNotes');
|
||||
if (notes) notes.style.display = 'none';
|
||||
this._setUpdateProgress('Starting update…');
|
||||
this._pollUpdateStatus();
|
||||
},
|
||||
|
||||
_stopUpdatePolling() {
|
||||
if (this._updatePollTimer) { clearInterval(this._updatePollTimer); this._updatePollTimer = null; }
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll the status file every 1.5s. Survives the connection drop while the
|
||||
* server restarts (fetch throws → "restarting"), then reads the reconciled
|
||||
* terminal state from the freshly-booted server.
|
||||
*/
|
||||
_pollUpdateStatus() {
|
||||
this._stopUpdatePolling();
|
||||
const terminal = new Set(['completed', 'completed-needs-manual-restart', 'failed', 'idle']);
|
||||
const poll = async () => {
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch('/api/system/update/status');
|
||||
if (res.ok) data = await res.json();
|
||||
} catch { /* server restarting — keep polling */ }
|
||||
|
||||
if (!data) {
|
||||
this._setUpdateProgress('↻ Restarting Codeman…');
|
||||
return;
|
||||
}
|
||||
if (!terminal.has(data.phase)) {
|
||||
this._setUpdateProgress(`↻ ${escapeHtml(this._updatePhaseText(data.phase))}`);
|
||||
return;
|
||||
}
|
||||
this._stopUpdatePolling();
|
||||
if (data.phase === 'completed') {
|
||||
let html = `<span style="color:var(--success,#3fb950)">✓ Updated to v${escapeHtml(data.toVersion || '')}. Reloading…</span>`;
|
||||
if (data.stashRef) {
|
||||
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code>${escapeHtml(data.stashRef)}</code> — run <code>git stash pop</code> to restore.</span>`;
|
||||
}
|
||||
this._setUpdateProgress(html);
|
||||
setTimeout(() => location.reload(), 2500);
|
||||
} else if (data.phase === 'completed-needs-manual-restart') {
|
||||
this._setUpdateProgress(
|
||||
`Update staged. Restart Codeman to apply:<br><code>${escapeHtml(data.manualRestartCommand || 'restart codeman web')}</code>`
|
||||
);
|
||||
} else if (data.phase === 'failed') {
|
||||
let html = `<span style="color:var(--danger,#e5534b)">✗ ${escapeHtml(data.message || 'Update failed')}.</span>`;
|
||||
if (data.error) html += `<br><span style="color:var(--text-secondary)">${escapeHtml(data.error)}</span>`;
|
||||
html += `<br><span style="color:var(--text-secondary)">The previous version is still running.</span>`;
|
||||
if (data.stashRef) {
|
||||
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code>${escapeHtml(data.stashRef)}</code>.</span>`;
|
||||
}
|
||||
this._setUpdateProgress(html);
|
||||
const nowBtn = this.$('updateNowBtn');
|
||||
const actionRow = this.$('updateActionRow');
|
||||
if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Try again'; }
|
||||
if (actionRow) actionRow.style.display = 'flex';
|
||||
}
|
||||
};
|
||||
poll();
|
||||
this._updatePollTimer = setInterval(poll, 1500);
|
||||
},
|
||||
|
||||
async loadTunnelStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
@@ -293,6 +294,39 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Self-Update (App Settings → Updates)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Install info + whether a newer release exists. Manual, user-triggered.
|
||||
app.get('/api/system/update/check', async () => {
|
||||
const check = await checkForUpdate();
|
||||
const info = getInstallInfo();
|
||||
return { ...info, ...check };
|
||||
});
|
||||
|
||||
// Poll target for update progress — survives the restart the update triggers.
|
||||
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
|
||||
|
||||
// Kick off a detached update to the latest release. Returns immediately; the
|
||||
// browser then polls /api/system/update/status across the service restart.
|
||||
app.post('/api/system/update', async (_req, reply) => {
|
||||
const result = await startUpdate();
|
||||
if (result.ok) {
|
||||
return { success: true, updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
|
||||
}
|
||||
const map = {
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
|
||||
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
|
||||
} as const;
|
||||
const m = map[result.code];
|
||||
return reply.code(m.http).send(createErrorResponse(m.api, result.message));
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* @fileoverview Server-side logic for the in-app self-updater.
|
||||
*
|
||||
* Powers App Settings → Updates. Codeman is installed as a git clone and run
|
||||
* under systemd (Linux) or launchd (macOS); updating means `git checkout <release
|
||||
* tag> && npm install && npm run build && restart-the-service`. The hard part is
|
||||
* that the update restarts the very process performing it, so the actual work
|
||||
* runs in a DETACHED `scripts/self-update.sh` that outlives the restart, writing
|
||||
* progress to `dataPath('update-status.json')` which the browser polls across the
|
||||
* connection drop.
|
||||
*
|
||||
* Channel: latest tagged RELEASE (tags look like `codeman@0.9.3`). Dirty trees
|
||||
* are auto-stashed (stash left for the user). Detection is manual (a button).
|
||||
*
|
||||
* Split into PURE helpers (semver/tag parsing, reconcile decision) that are unit
|
||||
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
|
||||
* `reconcileUpdateOnBoot`) that touch git/network/fs.
|
||||
*
|
||||
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
|
||||
* `src/web/routes/system-routes.ts`.
|
||||
*
|
||||
* @module web/self-update
|
||||
*/
|
||||
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
InstallInfo,
|
||||
InstallKind,
|
||||
SupervisorKind,
|
||||
UpdateCheckResult,
|
||||
UpdatePhase,
|
||||
UpdateStatus,
|
||||
} from '../types/update.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json') as { version: string };
|
||||
|
||||
/** systemd unit name (matches install.sh + scripts/codeman-web.service). */
|
||||
const SYSTEMD_UNIT = 'codeman-web.service';
|
||||
/** launchd agent label (matches install.sh setup_launchd_service). */
|
||||
const LAUNCHD_LABEL = 'com.codeman.web';
|
||||
/** Path to the persisted update status file. */
|
||||
const STATUS_FILE = dataPath('update-status.json');
|
||||
/** Network/git timeout for the "check" path (longer than EXEC_TIMEOUT_MS — ls-remote hits the network). */
|
||||
const CHECK_TIMEOUT_MS = 12_000;
|
||||
/** How long after `startedAt` a non-terminal status is treated as abandoned on boot. */
|
||||
const RECONCILE_STALE_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Phases that mean "an update is currently running". */
|
||||
const IN_FLIGHT_PHASES: ReadonlySet<UpdatePhase> = new Set<UpdatePhase>([
|
||||
'queued',
|
||||
'preparing',
|
||||
'stashing',
|
||||
'fetching',
|
||||
'checkout',
|
||||
'installing',
|
||||
'building',
|
||||
'restarting',
|
||||
]);
|
||||
|
||||
export function isInFlight(status: UpdateStatus | null | undefined): boolean {
|
||||
return !!status && IN_FLIGHT_PHASES.has(status.phase);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers (unit tested — no IO)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ParsedVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
/** Non-empty for prereleases like `0.9.3-rc1`. */
|
||||
prerelease: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a semver out of a release tag. Accepts `codeman@0.9.3`, `aicodeman@0.9.3`,
|
||||
* `v0.9.3`, and bare `0.9.3` (with optional `-prerelease`). Returns null if no
|
||||
* `X.Y.Z` is present.
|
||||
*/
|
||||
export function parseVersionFromTag(tag: string): ParsedVersion | null {
|
||||
const m = tag.trim().match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\s*$/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
major: parseInt(m[1], 10),
|
||||
minor: parseInt(m[2], 10),
|
||||
patch: parseInt(m[3], 10),
|
||||
prerelease: m[4] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Compare two parsed versions. Returns >0 if a>b, <0 if a<b, 0 if equal. A release outranks a prerelease of the same X.Y.Z. */
|
||||
export function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
if (a.patch !== b.patch) return a.patch - b.patch;
|
||||
// Equal core: a release (no prerelease) is greater than a prerelease.
|
||||
if (a.prerelease === b.prerelease) return 0;
|
||||
if (!a.prerelease) return 1;
|
||||
if (!b.prerelease) return -1;
|
||||
return a.prerelease < b.prerelease ? -1 : 1;
|
||||
}
|
||||
|
||||
/** True when `latest` is a strictly newer STABLE version than `current`. */
|
||||
export function isNewerStableVersion(current: string, latest: string): boolean {
|
||||
const c = parseVersionFromTag(current);
|
||||
const l = parseVersionFromTag(latest);
|
||||
if (!c || !l) return false;
|
||||
if (l.prerelease) return false; // never offer a prerelease as an update
|
||||
return compareVersions(l, c) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* From a list of `refs/tags/...` (or bare tag names), pick the highest STABLE
|
||||
* release tag we recognize. Skips prereleases and unrecognized tags.
|
||||
*/
|
||||
export function pickLatestStableTag(tagRefs: string[]): { tag: string; version: string } | null {
|
||||
let best: { tag: string; parsed: ParsedVersion } | null = null;
|
||||
for (const raw of tagRefs) {
|
||||
// Accept `refs/tags/codeman@0.9.3`, dereferenced `...^{}`, or bare tag names.
|
||||
const tag = raw
|
||||
.replace(/^.*refs\/tags\//, '')
|
||||
.replace(/\^\{\}$/, '')
|
||||
.trim();
|
||||
if (!tag) continue;
|
||||
if (!/^(codeman|aicodeman)@\d+\.\d+\.\d+$/.test(tag) && !/^v?\d+\.\d+\.\d+$/.test(tag)) continue;
|
||||
const parsed = parseVersionFromTag(tag);
|
||||
if (!parsed || parsed.prerelease) continue;
|
||||
if (!best || compareVersions(parsed, best.parsed) > 0) {
|
||||
best = { tag, parsed };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
return { tag: best.tag, version: `${best.parsed.major}.${best.parsed.minor}.${best.parsed.patch}` };
|
||||
}
|
||||
|
||||
/** Tags must match this before they're ever passed to the shell. */
|
||||
export function isValidReleaseTag(tag: string): boolean {
|
||||
return /^(codeman|aicodeman)@\d+\.\d+\.\d+$/.test(tag);
|
||||
}
|
||||
|
||||
/** Derive `{owner, repo}` from a GitHub SSH or HTTPS remote URL. */
|
||||
export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: string } | null {
|
||||
const m = remoteUrl.trim().match(/github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/);
|
||||
if (!m) return null;
|
||||
return { owner: m[1], repo: m[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE boot-time reconcile decision. Given the persisted status, the version the
|
||||
* freshly-booted process is actually running, and `now`, return the status to
|
||||
* persist — or null to leave it untouched.
|
||||
*
|
||||
* Rules (see plan "Hardening"):
|
||||
* - Terminal phases → untouched.
|
||||
* - Only the `restarting` marker (written right before the updater triggers our
|
||||
* restart) flips to completed/failed by comparing running version vs. target.
|
||||
* - Other in-flight phases are owned by the still-running updater scope — leave
|
||||
* them alone so a normal/crash restart mid-update isn't misreported.
|
||||
* - A backstop staleness guard fails any in-flight status older than the window.
|
||||
*/
|
||||
export function reconcileStatusDecision(
|
||||
status: UpdateStatus | null,
|
||||
runningVersion: string,
|
||||
now: number
|
||||
): UpdateStatus | null {
|
||||
if (!status) return null;
|
||||
if (!IN_FLIGHT_PHASES.has(status.phase)) return null;
|
||||
|
||||
if (status.phase === 'restarting') {
|
||||
if (status.toVersion && runningVersion === status.toVersion) {
|
||||
return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now };
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
phase: 'failed',
|
||||
message: 'Restarted but version did not change',
|
||||
error: `expected ${status.toVersion ?? '?'}, running ${runningVersion}`,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// Not the restart marker: only intervene if clearly abandoned.
|
||||
if (now - status.startedAt > RECONCILE_STALE_MS) {
|
||||
return {
|
||||
...status,
|
||||
phase: 'failed',
|
||||
message: 'Update did not complete',
|
||||
error: `abandoned during "${status.phase}"`,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status file IO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read the persisted status; tolerant of a missing/torn file (returns null). */
|
||||
export function readUpdateStatus(): UpdateStatus | null {
|
||||
try {
|
||||
if (!existsSync(STATUS_FILE)) return null;
|
||||
return JSON.parse(readFileSync(STATUS_FILE, 'utf-8')) as UpdateStatus;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the status atomically (temp + rename — readers never see a torn file). */
|
||||
export function writeUpdateStatusAtomic(status: UpdateStatus): void {
|
||||
const tmp = `${STATUS_FILE}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(status, null, 2));
|
||||
renameSync(tmp, STATUS_FILE);
|
||||
}
|
||||
|
||||
/** Reconcile the status file on server boot (call once, early in start()). */
|
||||
export function reconcileUpdateOnBoot(now = Date.now()): void {
|
||||
const status = readUpdateStatus();
|
||||
const next = reconcileStatusDecision(status, APP_VERSION, now);
|
||||
if (next) writeUpdateStatusAtomic(next);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Environment probing (git / supervisor / install kind)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Run a command, returning trimmed stdout, or null on any error. */
|
||||
function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null {
|
||||
try {
|
||||
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function commandExists(cmd: string): boolean {
|
||||
return tryExec('sh', ['-c', `command -v ${cmd}`]) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the repo root from this module's location. Compiled to
|
||||
* `dist/web/self-update.js` (or `src/web/self-update.ts` under tsx) → two levels
|
||||
* up is the package root that holds `package.json` and `.git`. Matches the
|
||||
* `require('../../package.json')` resolution in `server.ts`.
|
||||
*/
|
||||
export function resolveInstallDir(): string {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(moduleDir, '..', '..');
|
||||
if (existsSync(join(root, 'package.json'))) return root;
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function detectInstallKind(dir: string): InstallKind {
|
||||
if (existsSync(join(dir, '.git'))) return 'git';
|
||||
// Global npm install ships only dist/ (no src/, no .git).
|
||||
if (!existsSync(join(dir, 'src'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which init system supervises us. Detection happens HERE (in the running
|
||||
* server, which has a rich env) and the result is passed to the updater script —
|
||||
* the detached child must not re-probe with a stripped-down environment.
|
||||
*/
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
return 'none';
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
// INVOCATION_ID is set by systemd for service processes; confirm with is-active.
|
||||
if (process.env.INVOCATION_ID && tryExec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]) === 'active') {
|
||||
return 'systemd';
|
||||
}
|
||||
if (tryExec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]) === 'active') return 'systemd';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function isSelfUpdateEnabled(): boolean {
|
||||
return process.env.CODEMAN_DISABLE_SELF_UPDATE !== '1';
|
||||
}
|
||||
|
||||
/** Inspect the running install: kind, dir, branch, dirtiness, supervisor, version. */
|
||||
export function getInstallInfo(): InstallInfo {
|
||||
const installDir = resolveInstallDir();
|
||||
const installKind = detectInstallKind(installDir);
|
||||
let branch: string | undefined;
|
||||
let dirty = false;
|
||||
if (installKind === 'git') {
|
||||
branch = tryExec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], installDir) ?? undefined;
|
||||
const porcelain = tryExec('git', ['status', '--porcelain'], installDir);
|
||||
dirty = !!porcelain && porcelain.length > 0;
|
||||
}
|
||||
return {
|
||||
installKind,
|
||||
installDir,
|
||||
branch,
|
||||
dirty,
|
||||
supervisor: detectSupervisor(),
|
||||
currentVersion: APP_VERSION,
|
||||
selfUpdateEnabled: isSelfUpdateEnabled(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update check (network)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchLatestReleaseFromGitHub(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<{ tag: string; version: string; notes: string | null; htmlUrl: string | null } | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
|
||||
headers: { 'User-Agent': 'codeman-self-update', Accept: 'application/vnd.github+json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { tag_name?: string; body?: string; html_url?: string };
|
||||
if (!data.tag_name) return null;
|
||||
const parsed = parseVersionFromTag(data.tag_name);
|
||||
if (!parsed || parsed.prerelease) return null;
|
||||
return {
|
||||
tag: data.tag_name,
|
||||
version: `${parsed.major}.${parsed.minor}.${parsed.patch}`,
|
||||
notes: data.body ?? null,
|
||||
htmlUrl: data.html_url ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLatestTagViaGit(installDir: string): { tag: string; version: string } | null {
|
||||
const out = tryExec('git', ['ls-remote', '--tags', 'origin'], installDir, CHECK_TIMEOUT_MS);
|
||||
if (!out) return null;
|
||||
return pickLatestStableTag(out.split('\n').filter(Boolean));
|
||||
}
|
||||
|
||||
/** Check the configured remote for a newer release than the running version. */
|
||||
export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
const info = getInstallInfo();
|
||||
const checkedAt = Date.now();
|
||||
const base: UpdateCheckResult = {
|
||||
currentVersion: info.currentVersion,
|
||||
latestVersion: null,
|
||||
latestTag: null,
|
||||
updateAvailable: false,
|
||||
notes: null,
|
||||
htmlUrl: null,
|
||||
checkedAt,
|
||||
source: 'none',
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — self-update is unavailable.' };
|
||||
}
|
||||
|
||||
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
|
||||
const gh = remote ? parseGitHubRepo(remote) : null;
|
||||
|
||||
if (gh) {
|
||||
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
|
||||
if (rel) {
|
||||
return {
|
||||
...base,
|
||||
latestVersion: rel.version,
|
||||
latestTag: rel.tag,
|
||||
notes: rel.notes,
|
||||
htmlUrl: rel.htmlUrl,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
|
||||
source: 'github-api',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
|
||||
const viaGit = fetchLatestTagViaGit(info.installDir);
|
||||
if (viaGit) {
|
||||
return {
|
||||
...base,
|
||||
latestVersion: viaGit.version,
|
||||
latestTag: viaGit.tag,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
|
||||
source: 'git-ls-remote',
|
||||
};
|
||||
}
|
||||
|
||||
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Start an update
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type StartUpdateResult =
|
||||
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
|
||||
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
|
||||
|
||||
/**
|
||||
* Copy the updater script OUT of the repo before running it. The script lives in
|
||||
* the very repo it's about to `git checkout`, and bash reads scripts lazily — so
|
||||
* running the in-repo copy risks executing torn/old-tag bytes after checkout.
|
||||
* Run a snapshot under ~/.codeman instead (git never touches it).
|
||||
*/
|
||||
function stageRunner(installDir: string): string | null {
|
||||
const src = join(installDir, 'scripts', 'self-update.sh');
|
||||
if (!existsSync(src)) return null;
|
||||
const runner = dataPath('self-update-runner.sh');
|
||||
copyFileSync(src, runner);
|
||||
chmodSync(runner, 0o755);
|
||||
return runner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the updater so it OUTLIVES the service restart it triggers.
|
||||
* - Linux + systemd: a transient `--scope` cgroup, independent of the
|
||||
* codeman-web service lifecycle (survives `systemctl restart` regardless of
|
||||
* the unit's KillMode). Inherits our env so node/npm/git stay on PATH.
|
||||
* - Everything else: `setsid` into a new session (escapes launchd's process-group
|
||||
* kill); plain detached spawn as the last resort.
|
||||
*/
|
||||
function launchDetached(runner: string, args: string[]): void {
|
||||
const useScope = process.platform === 'linux' && !!process.env.XDG_RUNTIME_DIR && commandExists('systemd-run');
|
||||
let cmd: string;
|
||||
let cmdArgs: string[];
|
||||
if (useScope) {
|
||||
cmd = 'systemd-run';
|
||||
cmdArgs = ['--user', '--scope', '--collect', '--quiet', 'bash', runner, ...args];
|
||||
} else if (commandExists('setsid')) {
|
||||
cmd = 'setsid';
|
||||
cmdArgs = ['bash', runner, ...args];
|
||||
} else {
|
||||
cmd = 'bash';
|
||||
cmdArgs = [runner, ...args];
|
||||
}
|
||||
const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore', env: process.env });
|
||||
child.on('error', () => {
|
||||
// Surface the failure in the status file so the UI doesn't hang on "queued".
|
||||
const status = readUpdateStatus();
|
||||
if (status && isInFlight(status)) {
|
||||
writeUpdateStatusAtomic({
|
||||
...status,
|
||||
phase: 'failed',
|
||||
message: 'Could not launch the updater process',
|
||||
error: `spawn ${cmd} failed`,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, snapshot the current commit, write the initial status, and spawn the
|
||||
* detached updater. Returns immediately — progress is reported via the status file.
|
||||
*/
|
||||
export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
const info = getInstallInfo();
|
||||
if (!info.selfUpdateEnabled) {
|
||||
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
|
||||
}
|
||||
if (info.installKind !== 'git') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'not-git',
|
||||
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
if (isInFlight(existing)) {
|
||||
return { ok: false, code: 'in-flight', message: 'An update is already in progress.' };
|
||||
}
|
||||
|
||||
const check = await checkForUpdate();
|
||||
if (!check.latestTag || !check.updateAvailable) {
|
||||
return { ok: false, code: 'up-to-date', message: 'Already up to date.' };
|
||||
}
|
||||
if (!isValidReleaseTag(check.latestTag)) {
|
||||
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
|
||||
}
|
||||
|
||||
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
|
||||
const runner = stageRunner(info.installDir);
|
||||
if (!runner) {
|
||||
return { ok: false, code: 'error', message: 'scripts/self-update.sh not found in the install.' };
|
||||
}
|
||||
|
||||
const updateId = randomUUID();
|
||||
const now = Date.now();
|
||||
const status: UpdateStatus = {
|
||||
updateId,
|
||||
phase: 'queued',
|
||||
message: `Preparing update to v${check.latestVersion}…`,
|
||||
fromVersion: info.currentVersion,
|
||||
toVersion: check.latestVersion ?? undefined,
|
||||
toTag: check.latestTag,
|
||||
prevSha: prevSha ?? undefined,
|
||||
stashRef: null,
|
||||
supervisor: info.supervisor,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
writeUpdateStatusAtomic(status);
|
||||
|
||||
const logFile = join(tmpdir(), `codeman-update-${updateId}.log`);
|
||||
const args = [
|
||||
'--repo',
|
||||
info.installDir,
|
||||
'--tag',
|
||||
check.latestTag,
|
||||
'--supervisor',
|
||||
info.supervisor,
|
||||
'--status-file',
|
||||
STATUS_FILE,
|
||||
'--update-id',
|
||||
updateId,
|
||||
'--from-version',
|
||||
info.currentVersion,
|
||||
'--node',
|
||||
process.execPath,
|
||||
'--log',
|
||||
logFile,
|
||||
];
|
||||
if (prevSha) args.push('--prev-sha', prevSha);
|
||||
if (info.dirty) args.push('--stash');
|
||||
|
||||
launchDetached(runner, args);
|
||||
return { ok: true, updateId, toTag: check.latestTag, toVersion: check.latestVersion };
|
||||
}
|
||||
|
||||
/** Current status for the polling endpoint; null collapses to an explicit idle. */
|
||||
export function getUpdateStatusForApi(): UpdateStatus {
|
||||
const status = readUpdateStatus();
|
||||
if (status) return status;
|
||||
return {
|
||||
updateId: '',
|
||||
phase: 'idle',
|
||||
message: '',
|
||||
fromVersion: APP_VERSION,
|
||||
startedAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,8 @@ import {
|
||||
type RespawnWiringDeps,
|
||||
} from './respawn-event-wiring.js';
|
||||
|
||||
import { reconcileUpdateOnBoot } from './self-update.js';
|
||||
|
||||
// Load version from package.json
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json');
|
||||
@@ -1665,6 +1667,13 @@ export class WebServer extends EventEmitter {
|
||||
lifecycleLog.log({ event: 'server_started', sessionId: '*' });
|
||||
await lifecycleLog.trimIfNeeded();
|
||||
|
||||
// If a self-update restarted us into this process, finalize its status file
|
||||
// (flip the persisted "restarting" marker → completed/failed based on the
|
||||
// version we actually booted). No-op on a normal boot. See web/self-update.ts.
|
||||
if (!this.testMode) {
|
||||
reconcileUpdateOnBoot();
|
||||
}
|
||||
|
||||
// Restore mux sessions BEFORE accepting connections
|
||||
// This prevents race conditions where clients connect before state is ready
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* ~117 event constants organized by category:
|
||||
* 120 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
@@ -13,7 +13,7 @@
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
* - **Tasks** (4): created, completed, failed, updated
|
||||
* - **Mux** (4): created, killed, died, statsUpdated
|
||||
* - **Respawn** (17): stateChanged, cycleStarted, aiCheck*, timer*, log, ...
|
||||
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
|
||||
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
|
||||
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
|
||||
* - **Teams** (4): created, updated, removed, taskUpdated
|
||||
@@ -22,7 +22,9 @@
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image** (1): detected
|
||||
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
|
||||
* - **Cases** (2): created, linked
|
||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||
* - **Clipboard** (1): write
|
||||
* - **Cases** (4): created, linked, deleted, order-changed
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the self-updater's pure logic: release-tag/semver
|
||||
* parsing, "update available" computation, and the boot-time reconcile state
|
||||
* machine. No IO, no tmux, no port — safe to run individually.
|
||||
*
|
||||
* npm test -- test/self-update.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseVersionFromTag,
|
||||
compareVersions,
|
||||
isNewerStableVersion,
|
||||
pickLatestStableTag,
|
||||
isValidReleaseTag,
|
||||
parseGitHubRepo,
|
||||
reconcileStatusDecision,
|
||||
} from '../src/web/self-update.js';
|
||||
import type { UpdateStatus } from '../src/types/update.js';
|
||||
|
||||
describe('parseVersionFromTag', () => {
|
||||
it('parses the codeman@ / aicodeman@ / v / bare forms', () => {
|
||||
expect(parseVersionFromTag('codeman@0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3, prerelease: '' });
|
||||
expect(parseVersionFromTag('aicodeman@1.2.3')).toMatchObject({ major: 1, minor: 2, patch: 3 });
|
||||
expect(parseVersionFromTag('v0.10.0')).toMatchObject({ major: 0, minor: 10, patch: 0 });
|
||||
expect(parseVersionFromTag('0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3 });
|
||||
});
|
||||
|
||||
it('captures a prerelease suffix', () => {
|
||||
expect(parseVersionFromTag('codeman@0.9.3-rc1')).toMatchObject({ patch: 3, prerelease: 'rc1' });
|
||||
});
|
||||
|
||||
it('returns null when there is no X.Y.Z', () => {
|
||||
expect(parseVersionFromTag('codeman@latest')).toBeNull();
|
||||
expect(parseVersionFromTag('garbage')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareVersions', () => {
|
||||
const v = (s: string) => parseVersionFromTag(s)!;
|
||||
it('orders by major/minor/patch', () => {
|
||||
expect(compareVersions(v('0.10.0'), v('0.9.3'))).toBeGreaterThan(0);
|
||||
expect(compareVersions(v('0.9.3'), v('0.10.0'))).toBeLessThan(0);
|
||||
expect(compareVersions(v('1.0.0'), v('0.99.99'))).toBeGreaterThan(0);
|
||||
expect(compareVersions(v('0.9.3'), v('0.9.3'))).toBe(0);
|
||||
});
|
||||
|
||||
it('ranks a release above a prerelease of the same core', () => {
|
||||
expect(compareVersions(v('0.9.3'), v('0.9.3-rc1'))).toBeGreaterThan(0);
|
||||
expect(compareVersions(v('0.9.3-rc1'), v('0.9.3'))).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNewerStableVersion', () => {
|
||||
it('true only for a strictly newer stable release', () => {
|
||||
expect(isNewerStableVersion('0.9.3', '0.9.4')).toBe(true);
|
||||
expect(isNewerStableVersion('0.9.3', '0.10.0')).toBe(true);
|
||||
});
|
||||
it('false for same/older', () => {
|
||||
expect(isNewerStableVersion('0.9.3', '0.9.3')).toBe(false);
|
||||
expect(isNewerStableVersion('0.9.4', '0.9.3')).toBe(false);
|
||||
});
|
||||
it('never offers a prerelease as an update', () => {
|
||||
expect(isNewerStableVersion('0.9.3', '0.9.4-rc1')).toBe(false);
|
||||
});
|
||||
it('false on unparseable input', () => {
|
||||
expect(isNewerStableVersion('0.9.3', 'nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickLatestStableTag', () => {
|
||||
it('picks the highest stable tag from ls-remote-style refs', () => {
|
||||
const refs = [
|
||||
'deadbeef\trefs/tags/codeman@0.8.2',
|
||||
'cafef00d\trefs/tags/codeman@0.9.3',
|
||||
'abc123\trefs/tags/codeman@0.10.0',
|
||||
'abc123\trefs/tags/codeman@0.10.0^{}', // dereferenced dup
|
||||
];
|
||||
expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.10.0', version: '0.10.0' });
|
||||
});
|
||||
|
||||
it('skips prereleases and unrecognized tags', () => {
|
||||
const refs = ['x\trefs/tags/codeman@0.9.3', 'y\trefs/tags/codeman@0.9.4-rc1', 'z\trefs/tags/some-random-tag'];
|
||||
expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.9.3', version: '0.9.3' });
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(pickLatestStableTag([])).toBeNull();
|
||||
expect(pickLatestStableTag(['refs/tags/nightly', 'refs/heads/master'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidReleaseTag', () => {
|
||||
it('accepts only codeman@/aicodeman@ X.Y.Z (shell-injection guard)', () => {
|
||||
expect(isValidReleaseTag('codeman@0.9.4')).toBe(true);
|
||||
expect(isValidReleaseTag('aicodeman@1.0.0')).toBe(true);
|
||||
expect(isValidReleaseTag('v0.9.4')).toBe(false);
|
||||
expect(isValidReleaseTag('codeman@0.9.4; rm -rf /')).toBe(false);
|
||||
expect(isValidReleaseTag('codeman@latest')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGitHubRepo', () => {
|
||||
it('handles SSH and HTTPS remotes', () => {
|
||||
expect(parseGitHubRepo('git@github.com:Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
|
||||
expect(parseGitHubRepo('https://github.com/Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
|
||||
expect(parseGitHubRepo('https://github.com/Ark0N/Codeman')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
|
||||
});
|
||||
it('returns null for non-GitHub remotes', () => {
|
||||
expect(parseGitHubRepo('https://gitlab.com/x/y.git')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileStatusDecision (boot handoff state machine)', () => {
|
||||
const NOW = 1_000_000_000_000;
|
||||
const base = (over: Partial<UpdateStatus>): UpdateStatus => ({
|
||||
updateId: 'u1',
|
||||
phase: 'restarting',
|
||||
message: '',
|
||||
fromVersion: '0.9.3',
|
||||
toVersion: '0.9.4',
|
||||
startedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 5_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('no status / terminal status → untouched', () => {
|
||||
expect(reconcileStatusDecision(null, '0.9.4', NOW)).toBeNull();
|
||||
expect(reconcileStatusDecision(base({ phase: 'completed' }), '0.9.4', NOW)).toBeNull();
|
||||
expect(reconcileStatusDecision(base({ phase: 'failed' }), '0.9.4', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('restarting + running version matches target → completed', () => {
|
||||
const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.4', NOW);
|
||||
expect(out?.phase).toBe('completed');
|
||||
expect(out?.updatedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('restarting + version unchanged → failed', () => {
|
||||
const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.3', NOW);
|
||||
expect(out?.phase).toBe('failed');
|
||||
expect(out?.error).toContain('0.9.4');
|
||||
});
|
||||
|
||||
it('a fresh non-restart in-flight phase is left for the live updater', () => {
|
||||
expect(reconcileStatusDecision(base({ phase: 'building' }), '0.9.3', NOW)).toBeNull();
|
||||
expect(reconcileStatusDecision(base({ phase: 'installing' }), '0.9.3', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('a stale (abandoned) in-flight phase is failed by the backstop', () => {
|
||||
const stale = base({ phase: 'building', startedAt: NOW - 20 * 60 * 1000 });
|
||||
const out = reconcileStatusDecision(stale, '0.9.3', NOW);
|
||||
expect(out?.phase).toBe('failed');
|
||||
expect(out?.error).toContain('building');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user