diff --git a/scripts/self-update.sh b/scripts/self-update.sh
new file mode 100755
index 00000000..8dbc79f8
--- /dev/null
+++ b/scripts/self-update.sh
@@ -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
--tag --supervisor
+# --status-file --update-id --from-version --node
+# --log [--prev-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:-}"
+ 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
diff --git a/src/types/index.ts b/src/types/index.ts
index 6420817a..22a7369c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -66,3 +66,4 @@ export * from './teams.js';
export * from './push.js';
export * from './plan.js';
export * from './orchestrator.js';
+export * from './update.js';
diff --git a/src/types/update.ts b/src/types/update.ts
new file mode 100644
index 00000000..adefc953
--- /dev/null
+++ b/src/types/update.ts
@@ -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;
+}
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 9d98137a..54c1c424 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -1078,6 +1078,24 @@
+
+
Updates
+
+ Current Version
+ —
+
+
+ Check for Updates
+
+
+
+
+ Update available
+
+
+
+
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 8f1cc2a6..8d714538 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -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 npm i -g aicodeman@latest.`
+ );
+ 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: v${escapeHtml(data.latestVersion)} (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(`${escapeHtml(msg)}`);
+ 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 = `✓ Updated to v${escapeHtml(data.toVersion || '')}. Reloading…`;
+ if (data.stashRef) {
+ html += ` Local changes stashed as ${escapeHtml(data.stashRef)} — run git stash pop to restore.`;
+ }
+ this._setUpdateProgress(html);
+ setTimeout(() => location.reload(), 2500);
+ } else if (data.phase === 'completed-needs-manual-restart') {
+ this._setUpdateProgress(
+ `Update staged. Restart Codeman to apply: ${escapeHtml(data.manualRestartCommand || 'restart codeman web')}`
+ );
+ } else if (data.phase === 'failed') {
+ let html = `✗ ${escapeHtml(data.message || 'Update failed')}.`;
+ if (data.error) html += ` ${escapeHtml(data.error)}`;
+ html += ` The previous version is still running.`;
+ if (data.stashRef) {
+ html += ` Local changes stashed as ${escapeHtml(data.stashRef)}.`;
+ }
+ 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');
diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts
index 2c72f63e..a472f599 100644
--- a/src/web/routes/system-routes.ts
+++ b/src/web/routes/system-routes.ts
@@ -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)
// ═══════════════════════════════════════════════════════════════
diff --git a/src/web/self-update.ts b/src/web/self-update.ts
new file mode 100644
index 00000000..cd7a505e
--- /dev/null
+++ b/src/web/self-update.ts
@@ -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 && 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 = new Set([
+ '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 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 {
+ 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 {
+ 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,
+ };
+}
diff --git a/src/web/server.ts b/src/web/server.ts
index e9e6131b..6b776cde 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -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
diff --git a/test/self-update.test.ts b/test/self-update.test.ts
new file mode 100644
index 00000000..16d2bf40
--- /dev/null
+++ b/test/self-update.test.ts
@@ -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 => ({
+ 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');
+ });
+});