mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6234aae919 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Keep your laptop awake while Codeman runs. App Settings > System > Power has "Keep this computer awake" (off by default) and "Only on AC power" (on by default), with a live status line. On Linux it stops lid-close suspend while you are logged in to the desktop; on macOS it stops idle sleep, and lid-close sleep too with the optional root helper. The installer asks on laptops, and `install.sh keep-awake` turns it on for an existing install.
|
||||
@@ -293,8 +293,6 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
**Keep awake** (`keepAwakeEnabled`, SYNCED, default OFF; `keepAwakeAcOnly` default ON; pure `src/keep-awake.ts` + IO `src/keep-awake-manager.ts`, status `GET /api/system/keep-awake`): holds an OS sleep lock while the server runs, so a closed laptop keeps its agents working. ⚠️ Linux needs the LOW-level `handle-lid-switch` lock (logind's default `LidSwitchIgnoreInhibited=yes` ignores a `sleep` lock for the lid, and a user's own `sleep` lock never blocks that user's desktop suspending), and polkit grants it only while someone is logged in to the desktop: `denied` is a retried state, never an error. ⚠️ The lock must die with the server: `systemd-inhibit` runs `cat` on a stdin pipe (the unit's `KillMode=process` would orphan a `sleep infinity` holding it forever), macOS runs `caffeinate -w <pid>`. ⚠️ macOS lid close needs `pmset -a disablesleep`, applied by the optional root helper `scripts/keep-awake-macos.sh`, which `install.sh` copies to a ROOT-OWNED path (never run it from the user-writable install dir); it follows a heartbeat request file it ignores after 2 minutes, and undoes only a disablesleep it set. Reconcile from `merged` in `PUT /api/settings`; a non-admin's value is dropped in multi-user mode. Tests: `test/keep-awake*.test.ts`, `test/routes/system-routes-keep-awake.test.ts`, `test/install-sh-keep-awake.test.ts`.
|
||||
|
||||
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
@@ -736,7 +736,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
|
||||
</p>
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
@@ -860,10 +860,6 @@ Delivery goes through the same egress guard as web tabs (refused on the resolved
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
## Keep awake
|
||||
|
||||
`GET /api/system/keep-awake` reports the sleep lock behind the `keepAwakeEnabled` setting (switched through `PUT /api/settings`, together with `keepAwakeAcOnly`): `{ enabled, acOnly, platform: linux|macos|unsupported, state, onAc, lidHelper, detail }`. `state` is `off` \| `paused-battery` \| `starting` \| `active` \| `denied` (Linux refused the lock because no one is logged in to a desktop; retried every minute) \| `unavailable` \| `failed` (retried). `onAc` is `null` when unknown or not read; `lidHelper` is `installed` \| `missing` on macOS and `null` elsewhere. In multi-user mode a non-admin's `keepAwake*` values in `PUT /api/settings` are dropped (the rest of the save goes through).
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
@@ -57,11 +57,6 @@ unattended. You can leave while it builds. What it asks you:
|
||||
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
|
||||
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
|
||||
instead, or not at all.
|
||||
5. **On a laptop only: keep it awake while Codeman runs?** Closing the lid suspends the
|
||||
machine, and every agent session freezes until it wakes. Yes turns on App Settings >
|
||||
System > **Power** (only while plugged in). On a Mac it also offers a small root helper,
|
||||
because macOS sleeps on lid close whatever an app asks; it needs your admin password
|
||||
once. The default is no, and `--yes` never turns it on.
|
||||
|
||||
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
|
||||
scan with your phone, and the two commands you need to manage the service.
|
||||
@@ -80,7 +75,6 @@ install.sh uninstall # remove (offers to undo a rename it performed)
|
||||
install.sh tailscale # retrofit Tailscale access onto an existing install
|
||||
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
|
||||
install.sh keep-awake # keep this machine awake while Codeman runs (macOS: adds the lid helper)
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
|
||||
@@ -181,14 +181,8 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||
tools with their versions and install hints (admin only in multi-user mode). The **Power**
|
||||
group keeps the machine Codeman runs on awake while Codeman runs (`keepAwakeEnabled`, off by
|
||||
default; **Only on AC power**, `keepAwakeAcOnly`, on by default), and shows what the lock is
|
||||
doing right now. On Linux it blocks lid-close suspend while someone is logged in to the
|
||||
desktop (the desktop's own Automatic Suspend timer is separate). On macOS it blocks idle
|
||||
sleep, and lid-close sleep too once the root helper from `install.sh keep-awake` is
|
||||
installed. Admin only in multi-user mode. In multi-user mode, the **Users** administration
|
||||
entry is injected here.
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
mode, the **Users** administration entry is injected here.
|
||||
|
||||
## Session Options
|
||||
|
||||
|
||||
+28
-258
@@ -7,9 +7,8 @@
|
||||
#
|
||||
# The flow: look at what is already on the machine, ask at most three
|
||||
# questions (how the dashboard is reached, optionally what to call this
|
||||
# machine on your tailnet, whether to run Codeman as a service; on a laptop,
|
||||
# a follow-up on keeping it awake), then do all the work unattended and end on
|
||||
# the URL, with a QR code for your phone.
|
||||
# machine on your tailnet, whether to run Codeman as a service), then do all
|
||||
# the work unattended and end on the URL, with a QR code for your phone.
|
||||
#
|
||||
# Flags (each has an environment-variable twin, listed below):
|
||||
# --tailscale | --lan | --local How the dashboard is reached (question 1)
|
||||
@@ -49,7 +48,6 @@
|
||||
# install.sh name [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
# install.sh status - Print the URLs, the QR code and how to manage the service
|
||||
# install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel
|
||||
# install.sh keep-awake - Keep this machine awake while Codeman runs (macOS: adds the lid helper)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -131,18 +129,6 @@ SUBCOMMAND_ARG=""
|
||||
# alone) | empty (no service manager here).
|
||||
LAUNCH_CHOICE="3"
|
||||
SERVICE_TYPE=""
|
||||
# Follow-up to question 3, laptops only (choose_keep_awake): KEEP_AWAKE=1
|
||||
# writes keepAwakeEnabled into settings.json, KEEP_AWAKE_LID_HELPER=1 installs
|
||||
# the macOS root helper that covers lid-close sleep. Empty = leave as is.
|
||||
KEEP_AWAKE=""
|
||||
KEEP_AWAKE_LID_HELPER=""
|
||||
# Where laptop detection looks. Variables only so the test harness can point
|
||||
# them at a fake tree.
|
||||
POWER_SUPPLY_ROOT="/sys/class/power_supply"
|
||||
LID_BUTTON_ROOT="/proc/acpi/button/lid"
|
||||
KEEP_AWAKE_HELPER_DIR="/Library/Application Support/Codeman"
|
||||
KEEP_AWAKE_HELPER_PLIST="/Library/LaunchDaemons/com.codeman.keepawake.plist"
|
||||
KEEP_AWAKE_HELPER_LABEL="com.codeman.keepawake"
|
||||
|
||||
# Everything the unattended steps print goes here; the terminal gets one line
|
||||
# per step and the tail of this file on failure.
|
||||
@@ -2719,200 +2705,6 @@ cloudflared_subcommand() {
|
||||
# Wait briefly for codeman-web.service to report active. A bad node path or a
|
||||
# busy port makes the unit crash within the first seconds (then sit in
|
||||
# activating/auto-restart), so a blind "started!" message would be a lie.
|
||||
# ----------------------------------------------------------------------------
|
||||
# Keep awake (laptops): Codeman holds an OS sleep lock while it runs
|
||||
# ----------------------------------------------------------------------------
|
||||
# The server does the work (src/keep-awake-manager.ts: systemd-inhibit on
|
||||
# Linux, caffeinate on macOS); the installer only asks, writes the setting and,
|
||||
# on macOS, installs the root helper for the one thing caffeinate cannot do:
|
||||
# keep a closed MacBook awake (scripts/keep-awake-macos.sh).
|
||||
|
||||
# True on a machine with its own battery or a lid. A peripheral's battery
|
||||
# (scope=Device, e.g. a wireless mouse) does not make a desktop a laptop.
|
||||
is_laptop() {
|
||||
local os="$1" d t scope
|
||||
if [[ "$os" == "macos" ]]; then
|
||||
pmset -g batt 2>/dev/null | grep -q 'InternalBattery'
|
||||
return
|
||||
fi
|
||||
if [[ -d "$LID_BUTTON_ROOT" ]] && [[ -n "$(ls -A "$LID_BUTTON_ROOT" 2>/dev/null)" ]]; then
|
||||
return 0
|
||||
fi
|
||||
for d in "$POWER_SUPPLY_ROOT"/*; do
|
||||
[[ -f "$d/type" ]] || continue
|
||||
t=$(cat "$d/type" 2>/dev/null || true)
|
||||
[[ "$t" == "Battery" ]] || continue
|
||||
scope=$(cat "$d/scope" 2>/dev/null || true)
|
||||
[[ "$scope" == "Device" ]] && continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
keep_awake_settings_file() {
|
||||
printf '%s\n' "$HOME/.codeman/settings.json"
|
||||
}
|
||||
|
||||
# The server writes settings.json with JSON.stringify(…, null, 2).
|
||||
keep_awake_enabled_now() {
|
||||
grep -q '"keepAwakeEnabled": *true' "$(keep_awake_settings_file)" 2>/dev/null
|
||||
}
|
||||
|
||||
# Asked right after question 3, on laptops only ($2 = force skips that check,
|
||||
# for `install.sh keep-awake`). The default is no, so --yes and headless runs
|
||||
# never turn it on: keeping a machine awake is the owner's call.
|
||||
choose_keep_awake() {
|
||||
local os="$1" force="${2:-}"
|
||||
KEEP_AWAKE=""
|
||||
KEEP_AWAKE_LID_HELPER=""
|
||||
if [[ "$force" != "force" ]]; then
|
||||
is_laptop "$os" || return 0
|
||||
fi
|
||||
if keep_awake_enabled_now; then
|
||||
info "Keep-awake is already on (App Settings > System > Power)."
|
||||
else
|
||||
echo -e " ${DIM}Closing the lid suspends the machine, and every agent session freezes until it wakes.${NC}" >&2
|
||||
prompt_yes_no "Keep this machine awake while Codeman runs, even with the lid closed (only while plugged in)?" "n" || return 0
|
||||
KEEP_AWAKE="1"
|
||||
fi
|
||||
# The helper needs sudo, so it is only ever offered to a person at a terminal.
|
||||
if [[ "$os" == "macos" ]] && [[ ! -f "$KEEP_AWAKE_HELPER_PLIST" ]] &&
|
||||
[[ "$NONINTERACTIVE" != "1" && "$ASSUME_YES" != "1" ]] && has_tty; then
|
||||
echo -e " ${DIM}macOS sleeps on lid close whatever an app asks. A small root helper (pmset disablesleep) covers that, only while Codeman runs.${NC}" >&2
|
||||
if prompt_yes_no "Install the lid helper? (asks for your admin password once)" "y"; then
|
||||
KEEP_AWAKE_LID_HELPER="1"
|
||||
sudo_session_start
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Turn the setting on before the service starts, so its first boot already
|
||||
# holds the lock. node does the JSON; a settings file that does not parse is
|
||||
# left untouched.
|
||||
write_keep_awake_setting() {
|
||||
local file
|
||||
file=$(keep_awake_settings_file)
|
||||
mkdir -p "$(dirname "$file")"
|
||||
if node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let s = {};
|
||||
try { s = JSON.parse(fs.readFileSync(p, "utf8")); }
|
||||
catch (e) { if (e.code !== "ENOENT") process.exit(2); }
|
||||
if (s === null || typeof s !== "object" || Array.isArray(s)) process.exit(2);
|
||||
s.keepAwakeEnabled = true;
|
||||
if (s.keepAwakeAcOnly === undefined) s.keepAwakeAcOnly = true;
|
||||
fs.writeFileSync(p, JSON.stringify(s, null, 2));
|
||||
' "$file" </dev/null; then
|
||||
success "Keep-awake is on (only while plugged in; App Settings > System > Power)"
|
||||
return 0
|
||||
fi
|
||||
warn "Could not turn keep-awake on: $file is not valid JSON. Use App Settings > System > Power instead."
|
||||
return 1
|
||||
}
|
||||
|
||||
# macOS: copy the helper to a ROOT-OWNED path (a root daemon must never run a
|
||||
# file the user can edit, and the install dir is the user's) and load it as a
|
||||
# LaunchDaemon that runs every 20 seconds. It only acts while the server keeps
|
||||
# a fresh request file in the data dir.
|
||||
install_keep_awake_lid_helper() {
|
||||
local src="$INSTALL_DIR/scripts/keep-awake-macos.sh"
|
||||
local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"
|
||||
local request="$HOME/.codeman/keep-awake-lid.pid"
|
||||
local tmp
|
||||
if [[ ! -f "$src" ]]; then
|
||||
warn "Lid helper not found at $src; skipped."
|
||||
return 1
|
||||
fi
|
||||
if ! { run_as_root mkdir -p "$KEEP_AWAKE_HELPER_DIR" &&
|
||||
run_as_root chown root:wheel "$KEEP_AWAKE_HELPER_DIR" &&
|
||||
run_as_root chmod 755 "$KEEP_AWAKE_HELPER_DIR" &&
|
||||
run_as_root install -m 755 -o root -g wheel "$src" "$script"; }; then
|
||||
warn "Could not install the lid helper (sudo refused?). Run later: install.sh keep-awake"
|
||||
return 1
|
||||
fi
|
||||
tmp=$(mktemp)
|
||||
cat > "$tmp" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$KEEP_AWAKE_HELPER_LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>$(xml_escape "$script")</string>
|
||||
<string>$(xml_escape "$request")</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>20</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
run_as_root launchctl unload "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true
|
||||
if ! run_as_root install -m 644 -o root -g wheel "$tmp" "$KEEP_AWAKE_HELPER_PLIST"; then
|
||||
rm -f "$tmp"
|
||||
warn "Could not write $KEEP_AWAKE_HELPER_PLIST. Run later: install.sh keep-awake"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
run_as_root launchctl load -w "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true
|
||||
if run_as_root launchctl list "$KEEP_AWAKE_HELPER_LABEL" &>/dev/null; then
|
||||
success "Lid helper installed (it only acts while Codeman asks it to)"
|
||||
return 0
|
||||
fi
|
||||
warn "The lid helper did not load. Inspect: sudo launchctl list $KEEP_AWAKE_HELPER_LABEL"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Uninstall: undo a disablesleep only the helper set (its .owned marker, never
|
||||
# an administrator's own setting), then remove the daemon and its files.
|
||||
remove_keep_awake_lid_helper() {
|
||||
[[ -f "$KEEP_AWAKE_HELPER_PLIST" || -d "$KEEP_AWAKE_HELPER_DIR" ]] || return 0
|
||||
run_as_root launchctl unload "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true
|
||||
if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake.owned" ]]; then
|
||||
run_as_root pmset -a disablesleep 0 2>/dev/null || true
|
||||
fi
|
||||
run_as_root rm -f "$KEEP_AWAKE_HELPER_PLIST" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" \
|
||||
"$KEEP_AWAKE_HELPER_DIR/keep-awake.owned" 2>/dev/null || true
|
||||
run_as_root rmdir "$KEEP_AWAKE_HELPER_DIR" 2>/dev/null || true
|
||||
success "Removed the keep-awake lid helper"
|
||||
}
|
||||
|
||||
# Work-phase half of choose_keep_awake. Both steps are optional extras: a
|
||||
# failure warns and the install carries on.
|
||||
apply_keep_awake() {
|
||||
if [[ "$KEEP_AWAKE" == "1" ]]; then
|
||||
write_keep_awake_setting || true
|
||||
fi
|
||||
if [[ "$KEEP_AWAKE_LID_HELPER" == "1" ]]; then
|
||||
install_keep_awake_lid_helper || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# `install.sh keep-awake`: turn it on for an existing install (no laptop check:
|
||||
# asking for it is the answer), add the macOS lid helper, then restart the
|
||||
# service so the running server picks it up.
|
||||
keep_awake_subcommand() {
|
||||
print_banner
|
||||
command -v node &>/dev/null || die "node is required. Install Codeman first (run the installer without arguments)."
|
||||
local os
|
||||
os=$(detect_os)
|
||||
choose_keep_awake "$os" force
|
||||
if [[ -z "$KEEP_AWAKE" && -z "$KEEP_AWAKE_LID_HELPER" ]]; then
|
||||
info "Nothing changed."
|
||||
return 0
|
||||
fi
|
||||
apply_keep_awake
|
||||
echo ""
|
||||
restart_running_service
|
||||
}
|
||||
|
||||
verify_systemd_active() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
@@ -3240,7 +3032,6 @@ main() {
|
||||
choose_network_binding
|
||||
echo ""
|
||||
choose_launch_mode "$os"
|
||||
choose_keep_awake "$os"
|
||||
echo ""
|
||||
|
||||
# ========================================================================
|
||||
@@ -3259,9 +3050,6 @@ main() {
|
||||
# (symlinks, PATH, launch menu) instead of silently "updating".
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
|
||||
|
||||
# Before the service starts, so its first boot already reads the setting.
|
||||
apply_keep_awake
|
||||
|
||||
local service_ok="true"
|
||||
if [[ "$LAUNCH_CHOICE" == "2" ]]; then
|
||||
if [[ "$SERVICE_TYPE" == "launchd" ]]; then
|
||||
@@ -3705,36 +3493,6 @@ print_done_screen() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Restart Codeman under whatever supervises it, so a new build or a changed
|
||||
# setting takes effect; otherwise say how. Shared by update and keep-awake.
|
||||
restart_running_service() {
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service 2>/dev/null || true
|
||||
if verify_systemd_active; then
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
warn "codeman-web.service did not come back up."
|
||||
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
|
||||
fi
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then
|
||||
# Left alone on purpose (see setup_launchd_service); it keeps running
|
||||
# the previous build until its owner restarts it.
|
||||
info "A system LaunchDaemon supervises Codeman; restart it to apply:"
|
||||
echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to apply:${NC}"
|
||||
echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
update() {
|
||||
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||
die "Codeman is not installed at $INSTALL_DIR. Run the installer first."
|
||||
@@ -3767,15 +3525,32 @@ update() {
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
|
||||
restart_running_service
|
||||
|
||||
# The lid helper runs from a root-owned copy, so an update never reaches it
|
||||
# on its own (and must not: that would need sudo here). Say so when it drifted.
|
||||
if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" ]] &&
|
||||
! cmp -s "$INSTALL_DIR/scripts/keep-awake-macos.sh" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"; then
|
||||
info "The keep-awake lid helper has a newer version. Refresh it with: install.sh keep-awake"
|
||||
echo ""
|
||||
# Auto-restart service if running, otherwise tell the user
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service 2>/dev/null || true
|
||||
if verify_systemd_active; then
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
warn "codeman-web.service did not come back up."
|
||||
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
|
||||
fi
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then
|
||||
# Left alone on purpose (see setup_launchd_service); it keeps running
|
||||
# the previous build until its owner restarts it.
|
||||
info "A system LaunchDaemon supervises Codeman; restart it to run the new build:"
|
||||
echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Reflect the service's actual binding in the closing notice. Updates
|
||||
# never rewrite the service files, so the existing choice is authoritative.
|
||||
@@ -3828,9 +3603,6 @@ uninstall() {
|
||||
rm -f "$agent_plist"
|
||||
success "Removed LaunchAgent"
|
||||
fi
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
remove_keep_awake_lid_helper
|
||||
fi
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
# This installer never writes a LaunchDaemon (setup_launchd_service
|
||||
# leaves one alone), so this one is the user's own headless-Mac setup:
|
||||
@@ -3949,7 +3721,6 @@ Subcommands
|
||||
name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
status Print the URLs, the QR code and how to manage the service
|
||||
cloudflared Install cloudflared for the in-app Cloudflare tunnel
|
||||
keep-awake Keep this machine awake while Codeman runs (macOS: adds the lid helper)
|
||||
|
||||
Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST,
|
||||
CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME,
|
||||
@@ -3991,7 +3762,7 @@ parse_flags() {
|
||||
CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;;
|
||||
--port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
update|uninstall|tailscale|name|status|cloudflared|keep-awake)
|
||||
update|uninstall|tailscale|name|status|cloudflared)
|
||||
[[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
|
||||
SUBCOMMAND="$1" ;;
|
||||
-*) die "Unknown option: $1 (see --help)" ;;
|
||||
@@ -4024,7 +3795,6 @@ case "$SUBCOMMAND" in
|
||||
name) setup_name_subcommand ;;
|
||||
status) status_subcommand ;;
|
||||
cloudflared) cloudflared_subcommand ;;
|
||||
keep-awake) keep_awake_subcommand ;;
|
||||
*)
|
||||
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
||||
# (clone succeeded but build/menu never finished) lacks the marker and
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
# keep-awake-macos.sh: root helper behind Codeman's "Keep this computer awake" on macOS.
|
||||
#
|
||||
# caffeinate (which the server runs itself, no root) stops idle sleep but not lid-close
|
||||
# sleep. Only `pmset -a disablesleep 1` keeps a closed MacBook awake, and that is a
|
||||
# machine-wide root setting with no owner process. So this helper, run by the
|
||||
# com.codeman.keepawake LaunchDaemon every 20 seconds, applies it while the server asks
|
||||
# for it and undoes it when the server stops asking:
|
||||
#
|
||||
# - The server writes its pid to the request file and rewrites it every 30 seconds
|
||||
# while it wants the lid covered (setting on, and on AC when "only on AC" is on).
|
||||
# - The request counts only while the file is fresh (under 2 minutes old) AND its pid
|
||||
# is alive AND that process belongs to the file's owner. A crashed or hung server
|
||||
# therefore releases the lid within about two minutes on its own.
|
||||
# - The helper undoes only a disablesleep it set itself (tracked by the .owned file),
|
||||
# so an administrator's own `pmset -a disablesleep 1` is never switched off.
|
||||
#
|
||||
# install.sh copies this file to a ROOT-OWNED path before the daemon runs it; never run
|
||||
# it from the user-writable install directory.
|
||||
#
|
||||
# Usage: keep-awake-macos.sh <request-file>
|
||||
# Test hooks (set only by the test suite): CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
|
||||
|
||||
set -u
|
||||
|
||||
REQ="${1:?usage: keep-awake-macos.sh <request-file>}"
|
||||
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
|
||||
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
|
||||
OWNED="$STATE_DIR/keep-awake.owned"
|
||||
|
||||
want=0
|
||||
# -L: refuse a symlink (root must not be steered to read some other file).
|
||||
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
|
||||
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
|
||||
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
|
||||
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
|
||||
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
|
||||
want=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
|
||||
[ -n "$current" ] || current=0
|
||||
|
||||
if [ "$want" = "1" ]; then
|
||||
# Already 1 without our marker means an administrator set it: leave it and do not
|
||||
# claim it, so releasing later never switches their setting off.
|
||||
if [ "$current" != "1" ]; then
|
||||
if "$PMSET" -a disablesleep 1; then
|
||||
mkdir -p "$STATE_DIR" && : > "$OWNED"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$OWNED" ]; then
|
||||
if [ "$current" = "1" ]; then
|
||||
"$PMSET" -a disablesleep 0 || exit 1
|
||||
fi
|
||||
rm -f "$OWNED"
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,402 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Holds the OS sleep lock behind `keepAwakeEnabled` for exactly as long
|
||||
* as Codeman runs. The decisions (which lock, when, what a failure means) are pure and
|
||||
* live in `keep-awake.ts`; this module only does the IO and keeps the state current.
|
||||
*
|
||||
* Lifecycle rules, each load-bearing:
|
||||
*
|
||||
* - **The lock dies with the server, never after it.** Linux: `systemd-inhibit` runs a
|
||||
* `cat` on a stdin pipe from this process, so any exit (SIGKILL included) closes the
|
||||
* pipe and releases the lock even under the unit's `KillMode=process`. macOS:
|
||||
* `caffeinate -w <pid>` exits with the server. The macOS lid request file is only a
|
||||
* heartbeat: the root helper ignores it once it is two minutes old.
|
||||
* - **Reconcile from settings, never from a request body.** `apply()` takes the merged
|
||||
* config and is idempotent, so the boot path and every `PUT /api/settings` call the
|
||||
* same thing.
|
||||
* - **A refusal is a state, not an error.** polkit denies the Linux lock until someone
|
||||
* logs in to the desktop; that is reported as `denied` and retried, never thrown.
|
||||
* - **Inert under vitest** unless a test injects its own deps, so the suite never takes
|
||||
* a real lock on the machine running it.
|
||||
*/
|
||||
|
||||
import { spawn as nodeSpawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { release } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
MAC_LID_HEARTBEAT_MS,
|
||||
MAC_LID_HELPER_PLIST,
|
||||
MAC_LID_REQUEST_FILE,
|
||||
LINUX_HELD_MARKER,
|
||||
POWER_POLL_MS,
|
||||
RETRY_MS,
|
||||
classifyInhibitFailure,
|
||||
isOnAcPowerLinux,
|
||||
isOnAcPowerMac,
|
||||
keepAwakePlatform,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
shouldHoldLock,
|
||||
type KeepAwakeConfig,
|
||||
type KeepAwakePlatform,
|
||||
type KeepAwakeStatus,
|
||||
type PowerSupplyInfo,
|
||||
} from './keep-awake.js';
|
||||
|
||||
/** Grace between closing the inhibitor's stdin and SIGTERM. */
|
||||
const RELEASE_GRACE_MS = 2_000;
|
||||
/** Bound on captured stderr from the lock process. */
|
||||
const STDERR_CAP = 2_048;
|
||||
|
||||
export interface KeepAwakeDeps {
|
||||
platform: KeepAwakePlatform;
|
||||
serverPid: number;
|
||||
spawn: (command: string, args: string[]) => ChildProcess;
|
||||
readPowerSupplies: () => Promise<PowerSupplyInfo[]>;
|
||||
readMacBatt: () => Promise<string | null>;
|
||||
lidHelperInstalled: () => Promise<boolean>;
|
||||
writeLidRequest: (pid: number) => Promise<void>;
|
||||
removeLidRequest: () => Promise<void>;
|
||||
}
|
||||
|
||||
const POWER_SUPPLY_DIR = '/sys/class/power_supply';
|
||||
|
||||
async function readSysValue(dir: string, name: string): Promise<string | undefined> {
|
||||
try {
|
||||
return (await fs.readFile(join(dir, name), 'utf-8')).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLinuxPowerSupplies(): Promise<PowerSupplyInfo[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(POWER_SUPPLY_DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: PowerSupplyInfo[] = [];
|
||||
for (const name of names) {
|
||||
const dir = join(POWER_SUPPLY_DIR, name);
|
||||
const type = await readSysValue(dir, 'type');
|
||||
if (!type) continue;
|
||||
const online = await readSysValue(dir, 'online');
|
||||
out.push({
|
||||
type,
|
||||
online: online === undefined ? undefined : online === '1',
|
||||
status: await readSysValue(dir, 'status'),
|
||||
scope: await readSysValue(dir, 'scope'),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPmsetBatt(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('/usr/bin/pmset', ['-g', 'batt'], { timeout: 5_000 }, (err, stdout) => {
|
||||
resolve(err ? null : String(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultDeps(): KeepAwakeDeps {
|
||||
const lidRequest = () => dataPath(MAC_LID_REQUEST_FILE);
|
||||
return {
|
||||
platform: keepAwakePlatform(process.platform, release()),
|
||||
serverPid: process.pid,
|
||||
spawn: (command, args) => nodeSpawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }),
|
||||
readPowerSupplies: readLinuxPowerSupplies,
|
||||
readMacBatt: readPmsetBatt,
|
||||
lidHelperInstalled: async () => {
|
||||
try {
|
||||
await fs.access(MAC_LID_HELPER_PLIST);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
writeLidRequest: async (pid) => {
|
||||
await fs.writeFile(lidRequest(), `${pid}\n`, { mode: 0o644 });
|
||||
},
|
||||
removeLidRequest: async () => {
|
||||
await fs.rm(lidRequest(), { force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class KeepAwakeManager {
|
||||
private readonly deps: KeepAwakeDeps;
|
||||
private readonly inert: boolean;
|
||||
private config: KeepAwakeConfig = { enabled: false, acOnly: true };
|
||||
private status: KeepAwakeStatus;
|
||||
private child: ChildProcess | null = null;
|
||||
/** Children we asked to exit; their exit is expected and must not trigger a retry. */
|
||||
private releasing = new WeakSet<ChildProcess>();
|
||||
private powerTimer: NodeJS.Timeout | null = null;
|
||||
private retryTimer: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
/** Serializes reconciles: a settings PUT can land while a power poll is mid-read. */
|
||||
private chain: Promise<void> = Promise.resolve();
|
||||
private stopped = false;
|
||||
private lastLoggedState: KeepAwakeStatus['state'] = 'off';
|
||||
|
||||
constructor(deps?: KeepAwakeDeps) {
|
||||
this.inert = !deps && !!process.env.VITEST;
|
||||
this.deps = deps ?? defaultDeps();
|
||||
this.status = {
|
||||
enabled: false,
|
||||
acOnly: true,
|
||||
platform: this.deps.platform,
|
||||
state: 'off',
|
||||
onAc: null,
|
||||
lidHelper: null,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Current status (a copy). */
|
||||
getStatus(): KeepAwakeStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/** Reconcile to `config`. Idempotent; safe to call on every settings save. */
|
||||
apply(config: KeepAwakeConfig): Promise<void> {
|
||||
this.config = { ...config };
|
||||
this.stopped = false;
|
||||
return this.enqueue();
|
||||
}
|
||||
|
||||
/** Release everything and stop all timers (server shutdown). */
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.clearTimers();
|
||||
await this.chain.catch(() => {});
|
||||
await this.release();
|
||||
this.status = { ...this.status, state: 'off', detail: null };
|
||||
}
|
||||
|
||||
private enqueue(): Promise<void> {
|
||||
const next = this.chain
|
||||
.then(() => this.reconcile())
|
||||
.catch((err) => {
|
||||
console.error('[keep-awake] reconcile failed:', err);
|
||||
});
|
||||
this.chain = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
const { enabled, acOnly } = this.config;
|
||||
this.status = { ...this.status, enabled, acOnly };
|
||||
|
||||
if (!enabled) {
|
||||
this.clearTimers();
|
||||
await this.release();
|
||||
this.setState('off', null);
|
||||
return;
|
||||
}
|
||||
if (this.inert) {
|
||||
this.setState('unavailable', 'Disabled under the test runner.');
|
||||
return;
|
||||
}
|
||||
if (this.deps.platform === 'unsupported') {
|
||||
this.setState('unavailable', 'Not supported on this system.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.deps.platform === 'macos') {
|
||||
this.status.lidHelper = (await this.deps.lidHelperInstalled()) ? 'installed' : 'missing';
|
||||
}
|
||||
|
||||
const onAc = acOnly ? await this.readOnAc() : null;
|
||||
this.status.onAc = onAc;
|
||||
this.ensurePowerPoll(acOnly);
|
||||
|
||||
if (!shouldHoldLock(this.config, onAc)) {
|
||||
await this.release();
|
||||
this.setState('paused-battery', null);
|
||||
return;
|
||||
}
|
||||
|
||||
// The lid helper is a separate mechanism from caffeinate: it only needs a fresh
|
||||
// request file, so it runs whether or not caffeinate is currently up.
|
||||
if (this.deps.platform === 'macos' && this.status.lidHelper === 'installed') this.startLidHeartbeat();
|
||||
|
||||
// A pending retry owns the next attempt; a live child is already the lock.
|
||||
if (this.child || this.retryTimer) return;
|
||||
if (this.status.state === 'unavailable') return;
|
||||
this.acquire();
|
||||
}
|
||||
|
||||
private async readOnAc(): Promise<boolean | null> {
|
||||
try {
|
||||
if (this.deps.platform === 'linux') return isOnAcPowerLinux(await this.deps.readPowerSupplies());
|
||||
if (this.deps.platform === 'macos') {
|
||||
const out = await this.deps.readMacBatt();
|
||||
return out === null ? null : isOnAcPowerMac(out);
|
||||
}
|
||||
} catch {
|
||||
/* unknown */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private acquire(): void {
|
||||
if (this.deps.platform === 'linux') this.acquireLinux();
|
||||
else if (this.deps.platform === 'macos') this.acquireMac();
|
||||
}
|
||||
|
||||
private acquireLinux(): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = this.deps.spawn('systemd-inhibit', linuxInhibitArgs());
|
||||
} catch (err) {
|
||||
this.onSpawnError(err as NodeJS.ErrnoException);
|
||||
return;
|
||||
}
|
||||
this.child = child;
|
||||
this.setState('starting', null);
|
||||
// Closing the pipe to an already-dead process emits EPIPE on the stream; unhandled,
|
||||
// that would take the whole server down.
|
||||
child.stdin?.on('error', () => {});
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (this.child === child && String(chunk).includes(LINUX_HELD_MARKER)) this.setState('active', null);
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderr.length < STDERR_CAP) stderr += String(chunk);
|
||||
});
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
this.onSpawnError(err);
|
||||
});
|
||||
child.on('exit', () => {
|
||||
if (this.releasing.has(child) || this.child !== child) return;
|
||||
this.child = null;
|
||||
const { state, detail } = classifyInhibitFailure(stderr);
|
||||
this.setState(state, detail);
|
||||
if (state !== 'unavailable') this.scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
private acquireMac(): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = this.deps.spawn('/usr/bin/caffeinate', macCaffeinateArgs(this.deps.serverPid));
|
||||
} catch (err) {
|
||||
this.onSpawnError(err as NodeJS.ErrnoException);
|
||||
return;
|
||||
}
|
||||
this.child = child;
|
||||
this.setState('starting', null);
|
||||
child.on('spawn', () => {
|
||||
if (this.child === child) this.setState('active', null);
|
||||
});
|
||||
child.stdin?.on('error', () => {});
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
this.onSpawnError(err);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (this.releasing.has(child) || this.child !== child) return;
|
||||
this.child = null;
|
||||
this.setState('failed', `caffeinate exited (${signal ?? `code ${code}`})`);
|
||||
this.scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
private onSpawnError(err: NodeJS.ErrnoException): void {
|
||||
if (err.code === 'ENOENT') {
|
||||
const tool = this.deps.platform === 'macos' ? 'caffeinate' : 'systemd-inhibit';
|
||||
this.setState('unavailable', `${tool} is not installed on this machine.`);
|
||||
return;
|
||||
}
|
||||
this.setState('failed', err.message);
|
||||
this.scheduleRetry();
|
||||
}
|
||||
|
||||
private startLidHeartbeat(): void {
|
||||
if (this.heartbeatTimer) return;
|
||||
const beat = () => {
|
||||
void this.deps.writeLidRequest(this.deps.serverPid).catch((err) => {
|
||||
console.warn('[keep-awake] could not write the lid request file:', err);
|
||||
});
|
||||
};
|
||||
beat();
|
||||
this.heartbeatTimer = setInterval(beat, MAC_LID_HEARTBEAT_MS);
|
||||
this.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
private async release(): Promise<void> {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.deps.platform === 'macos' && !this.inert) {
|
||||
await this.deps.removeLidRequest().catch(() => {});
|
||||
}
|
||||
const child = this.child;
|
||||
if (!child) return;
|
||||
this.child = null;
|
||||
this.releasing.add(child);
|
||||
// Closing stdin ends `cat` (Linux), which ends systemd-inhibit and drops the lock.
|
||||
child.stdin?.end();
|
||||
if (this.deps.platform === 'macos') {
|
||||
child.kill('SIGTERM');
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||
}, RELEASE_GRACE_MS);
|
||||
timer.unref?.();
|
||||
child.once('exit', () => clearTimeout(timer));
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer || this.stopped) return;
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.enqueue();
|
||||
}, RETRY_MS);
|
||||
this.retryTimer.unref?.();
|
||||
}
|
||||
|
||||
private ensurePowerPoll(acOnly: boolean): void {
|
||||
if (!acOnly) {
|
||||
if (this.powerTimer) clearInterval(this.powerTimer);
|
||||
this.powerTimer = null;
|
||||
return;
|
||||
}
|
||||
if (this.powerTimer) return;
|
||||
this.powerTimer = setInterval(() => void this.enqueue(), POWER_POLL_MS);
|
||||
this.powerTimer.unref?.();
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
for (const t of [this.powerTimer, this.heartbeatTimer]) if (t) clearInterval(t);
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
this.powerTimer = this.heartbeatTimer = this.retryTimer = null;
|
||||
}
|
||||
|
||||
private setState(state: KeepAwakeStatus['state'], detail: string | null): void {
|
||||
// Log settled transitions only: a denied lock is retried every minute and would
|
||||
// otherwise log `starting` + `denied` forever on a box nobody logs in to.
|
||||
if (state !== 'starting' && state !== this.lastLoggedState) {
|
||||
this.lastLoggedState = state;
|
||||
console.log(`[keep-awake] ${state}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
this.status = { ...this.status, state, detail };
|
||||
}
|
||||
}
|
||||
|
||||
/** The process-wide manager. */
|
||||
export const keepAwake = new KeepAwakeManager();
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs"
|
||||
* (`keepAwakeEnabled`, SYNCED, default OFF; `keepAwakeAcOnly`, default ON).
|
||||
*
|
||||
* A laptop that suspends freezes every agent session: nothing runs while the lid is
|
||||
* closed, the phone loses its tailnet route to the dashboard, and in-flight API
|
||||
* requests and ssh links usually break. The IO side (`keep-awake-manager.ts`) holds an
|
||||
* OS-level sleep lock for exactly as long as Codeman runs; everything it has to decide
|
||||
* lives here so the tests exercise the shipped logic.
|
||||
*
|
||||
* What each platform's lock can and cannot do, measured rather than assumed:
|
||||
*
|
||||
* - **Linux (systemd-logind).** Lid-close suspend obeys only the LOW-level
|
||||
* `handle-lid-switch` lock: logind's default `LidSwitchIgnoreInhibited=yes` makes it
|
||||
* ignore a plain `sleep` lock for the lid. A `sleep` lock also never blocks a suspend
|
||||
* requested by the SAME user (logind skips inhibitors whose uid matches the caller),
|
||||
* so the desktop's own Automatic Suspend timer keeps working; it is the user's setting
|
||||
* to change. polkit grants `handle-lid-switch` and `sleep` only to a process in an
|
||||
* active login session, and a systemd user service is not in one: polkit then falls
|
||||
* back to the user's display session, so the lock is granted while someone is logged
|
||||
* in to the desktop and DENIED at a login screen (or on a headless box). The manager
|
||||
* retries a denial, so logging in later picks it up.
|
||||
* - **macOS.** `caffeinate -i -s` (no root) blocks idle sleep, and system sleep while on
|
||||
* AC power, but NOT lid-close sleep. Only `pmset -a disablesleep 1` keeps a closed
|
||||
* MacBook awake, and that is a machine-wide root setting with no owner process, so the
|
||||
* optional root helper (`scripts/keep-awake-macos.sh`, installed by `install.sh`)
|
||||
* applies it while the server keeps a fresh request file, and undoes it when the file
|
||||
* goes stale or disappears.
|
||||
*/
|
||||
|
||||
/** Settings keys this feature reads. */
|
||||
export interface KeepAwakeConfig {
|
||||
/** Hold a sleep lock while Codeman runs. Opt-in: only an explicit `true` enables. */
|
||||
enabled: boolean;
|
||||
/** Release the lock while the machine runs on battery. Default ON. */
|
||||
acOnly: boolean;
|
||||
}
|
||||
|
||||
export type KeepAwakePlatform = 'linux' | 'macos' | 'unsupported';
|
||||
|
||||
/**
|
||||
* - `off`: the setting is off.
|
||||
* - `paused-battery`: `acOnly` and the machine is on battery.
|
||||
* - `starting`: a lock was requested and has not been confirmed yet.
|
||||
* - `active`: the lock is held.
|
||||
* - `denied`: Linux refused the lock (no active desktop login); retried periodically.
|
||||
* - `unavailable`: this machine has no usable mechanism (no systemd-logind, WSL, …).
|
||||
* - `failed`: anything else; retried periodically.
|
||||
*/
|
||||
export type KeepAwakeState = 'off' | 'paused-battery' | 'starting' | 'active' | 'denied' | 'unavailable' | 'failed';
|
||||
|
||||
export interface KeepAwakeStatus {
|
||||
enabled: boolean;
|
||||
acOnly: boolean;
|
||||
platform: KeepAwakePlatform;
|
||||
state: KeepAwakeState;
|
||||
/** Last known power source: true = AC, false = battery, null = unknown or not read. */
|
||||
onAc: boolean | null;
|
||||
/** macOS only: whether the root lid-close helper is installed. null elsewhere. */
|
||||
lidHelper: 'installed' | 'missing' | null;
|
||||
/** Short reason for `denied` / `unavailable` / `failed`, else null. */
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/** One entry of `/sys/class/power_supply/<name>/`. */
|
||||
export interface PowerSupplyInfo {
|
||||
/** `type`: Mains, Battery, USB, UPS, Wireless, … */
|
||||
type: string;
|
||||
/** `online` for adapters (1/0); undefined when the file is absent. */
|
||||
online?: boolean;
|
||||
/** `status` for batteries: Charging, Discharging, Full, Not charging, Unknown. */
|
||||
status?: string;
|
||||
/** `scope`: `Device` marks a peripheral's battery (a mouse), not the machine's. */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** Lock set requested from logind. Order is cosmetic; all three are block locks. */
|
||||
export const LINUX_INHIBIT_WHAT = 'handle-lid-switch:sleep:idle';
|
||||
|
||||
/** Printed by the inhibitor's child once the lock is held (systemd-inhibit execs it only then). */
|
||||
export const LINUX_HELD_MARKER = 'codeman-keep-awake-held';
|
||||
|
||||
/** Name of the request file the macOS lid helper watches, under the data dir. */
|
||||
export const MAC_LID_REQUEST_FILE = 'keep-awake-lid.pid';
|
||||
|
||||
/** The LaunchDaemon the installer writes for the macOS lid helper. */
|
||||
export const MAC_LID_HELPER_PLIST = '/Library/LaunchDaemons/com.codeman.keepawake.plist';
|
||||
|
||||
/** How often the server refreshes the macOS request file. The helper treats it as stale after 2 minutes. */
|
||||
export const MAC_LID_HEARTBEAT_MS = 30_000;
|
||||
|
||||
/** How often the power source is re-read while `acOnly` is on. */
|
||||
export const POWER_POLL_MS = 30_000;
|
||||
|
||||
/** Delay before retrying a denied or failed lock. */
|
||||
export const RETRY_MS = 60_000;
|
||||
|
||||
/** Settings → config. Absent `keepAwakeEnabled` is OFF; absent `keepAwakeAcOnly` is ON. */
|
||||
export function resolveKeepAwakeConfig(settings: Record<string, unknown>): KeepAwakeConfig {
|
||||
return {
|
||||
enabled: settings.keepAwakeEnabled === true,
|
||||
acOnly: settings.keepAwakeAcOnly !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mechanism applies. WSL is unsupported even though it may have systemd: the
|
||||
* Windows host decides when the machine sleeps, and a lock inside the VM does nothing.
|
||||
*/
|
||||
export function keepAwakePlatform(platform: string, kernelRelease: string): KeepAwakePlatform {
|
||||
if (platform === 'darwin') return 'macos';
|
||||
if (platform === 'linux') return /microsoft/i.test(kernelRelease) ? 'unsupported' : 'linux';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux power source from `/sys/class/power_supply`. Peripheral batteries
|
||||
* (`scope=Device`) are ignored. A machine with no battery of its own runs on external
|
||||
* power by definition; otherwise any online adapter means AC, adapters that are all
|
||||
* offline mean battery, and a machine that lists no adapter at all falls back to the
|
||||
* battery's own charging status. null when nothing conclusive is reported.
|
||||
*/
|
||||
export function isOnAcPowerLinux(supplies: readonly PowerSupplyInfo[]): boolean | null {
|
||||
const system = supplies.filter((s) => (s.scope ?? '').toLowerCase() !== 'device');
|
||||
const batteries = system.filter((s) => s.type === 'Battery');
|
||||
if (batteries.length === 0) return true;
|
||||
const adapters = system.filter((s) => s.type !== 'Battery' && s.online !== undefined);
|
||||
if (adapters.some((s) => s.online)) return true;
|
||||
if (adapters.length > 0) return false;
|
||||
const statuses = batteries.map((b) => (b.status ?? '').toLowerCase());
|
||||
if (statuses.includes('discharging')) return false;
|
||||
if (statuses.some((s) => s === 'charging' || s === 'full' || s === 'not charging')) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** macOS power source from `pmset -g batt` (first line names the source). */
|
||||
export function isOnAcPowerMac(pmsetBatt: string): boolean | null {
|
||||
const m = /drawing from '([^']+)'/.exec(pmsetBatt);
|
||||
if (!m) return null;
|
||||
if (m[1] === 'AC Power') return true;
|
||||
if (m[1] === 'Battery Power' || m[1] === 'UPS Power') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the lock should be held right now. An unknown power source counts as AC:
|
||||
* it is what a desktop or VM without battery reporting looks like.
|
||||
*/
|
||||
export function shouldHoldLock(config: KeepAwakeConfig, onAc: boolean | null): boolean {
|
||||
if (!config.enabled) return false;
|
||||
if (!config.acOnly) return true;
|
||||
return onAc !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* argv for `systemd-inhibit`. The child it runs announces the lock and then becomes
|
||||
* `cat` on a stdin pipe from the server: when the server exits for ANY reason (a
|
||||
* SIGKILL included) the pipe closes, `cat` exits, and logind drops the lock. That
|
||||
* matters because the shipped unit uses `KillMode=process`, which leaves children
|
||||
* running on stop; a `sleep infinity` child would hold the lock forever.
|
||||
*/
|
||||
export function linuxInhibitArgs(): string[] {
|
||||
return [
|
||||
`--what=${LINUX_INHIBIT_WHAT}`,
|
||||
'--who=Codeman',
|
||||
'--why=Keeping agent sessions running',
|
||||
'--mode=block',
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
`echo ${LINUX_HELD_MARKER}; exec cat`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* argv for `caffeinate`: `-i` idle sleep, `-s` system sleep on AC power. `-w` ties
|
||||
* the assertion to the server's pid, so it ends when the server does, crash included.
|
||||
*/
|
||||
export function macCaffeinateArgs(serverPid: number): string[] {
|
||||
return ['-i', '-s', '-w', String(serverPid)];
|
||||
}
|
||||
|
||||
/** Classify why `systemd-inhibit` exited before the lock was confirmed. */
|
||||
export function classifyInhibitFailure(stderr: string): { state: 'denied' | 'unavailable' | 'failed'; detail: string } {
|
||||
const text = stderr.trim();
|
||||
if (/access denied|not authori[sz]ed|interactive authentication required/i.test(text)) {
|
||||
return {
|
||||
state: 'denied',
|
||||
detail: 'Linux refused the sleep lock: no one is logged in to a desktop session on this machine.',
|
||||
};
|
||||
}
|
||||
if (/failed to connect to (system )?bus|no such file or directory|not found|unknown (unit|object)/i.test(text)) {
|
||||
return { state: 'unavailable', detail: 'systemd-logind is not reachable on this machine.' };
|
||||
}
|
||||
const firstLine = text.split('\n')[0]?.slice(0, 200) || 'the inhibitor exited';
|
||||
return { state: 'failed', detail: firstLine };
|
||||
}
|
||||
@@ -3107,27 +3107,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="keepAwakeGroup">
|
||||
<div class="set-group-head"><h4>Power</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="keep awake sleep suspend standby lid close laptop notebook power caffeinate inhibit">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep this computer awake</span>
|
||||
<span class="set-row-desc">While Codeman runs, stop the machine it runs on from sleeping, including when a laptop's lid is closed, so agents keep working and stay reachable from your phone. The screen can still turn off.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeepAwake"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="keep awake battery ac power charger plugged in">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Only on AC power</span>
|
||||
<span class="set-row-desc">Sleep normally on battery. A closed laptop in a bag gets hot and drains fast.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeepAwakeAcOnly" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div id="keepAwakeStatus" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Remote access</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
|
||||
@@ -424,11 +424,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
this._applyDoctorAdminGate();
|
||||
// Keep awake: server state (an OS sleep lock), default OFF; "only on AC" default ON.
|
||||
document.getElementById('appSettingsKeepAwake').checked = settings.keepAwakeEnabled === true;
|
||||
document.getElementById('appSettingsKeepAwakeAcOnly').checked = settings.keepAwakeAcOnly !== false;
|
||||
this._applyKeepAwakeAdminGate();
|
||||
this.loadKeepAwakeStatus();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
@@ -1229,53 +1224,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep awake changes machine state, and PUT /api/settings drops a non-admin's value in
|
||||
* multi-user mode, so a non-admin gets no Power group at all rather than a switch that
|
||||
* silently does nothing. Also wired to `codeman:me` for the late-resolving role.
|
||||
*/
|
||||
_applyKeepAwakeAdminGate() {
|
||||
const group = document.getElementById('keepAwakeGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/** One line on what the sleep lock is doing right now (GET /api/system/keep-awake). */
|
||||
async loadKeepAwakeStatus() {
|
||||
const out = document.getElementById('keepAwakeStatus');
|
||||
if (!out) return;
|
||||
let s = null;
|
||||
try {
|
||||
const res = await this._api('/api/system/keep-awake');
|
||||
const body = res && res.ok ? await res.json() : null;
|
||||
s = body?.success ? body.data : null;
|
||||
} catch { /* leave hidden */ }
|
||||
const text = s ? this.describeKeepAwakeStatus(s) : '';
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
},
|
||||
|
||||
/** Status → sentence. Pure; '' hides the note. */
|
||||
describeKeepAwakeStatus(s) {
|
||||
switch (s.state) {
|
||||
case 'off': return '';
|
||||
case 'starting': return 'Starting…';
|
||||
case 'paused-battery': return 'Paused: running on battery. It comes back when you plug in.';
|
||||
case 'unavailable': return `Not available here: ${s.detail || 'no supported sleep lock on this system.'}`;
|
||||
case 'denied': return `${s.detail || 'The sleep lock was refused.'} Codeman retries every minute, so it applies once you log in.`;
|
||||
case 'failed': return `Could not take the sleep lock (${s.detail || 'unknown error'}). Retrying every minute.`;
|
||||
case 'active':
|
||||
if (s.platform === 'macos') {
|
||||
return s.lidHelper === 'installed'
|
||||
? 'Active: this Mac will not sleep while Codeman runs, even with the lid closed. Apple menu > Sleep is blocked too.'
|
||||
: 'Active for idle sleep only: closing the lid still puts this Mac to sleep. To cover the lid, re-run the installer and answer yes to the lid question (asks for your admin password once).';
|
||||
}
|
||||
return "Active: closing the lid will not suspend this machine while Codeman runs. Your desktop's own Automatic Suspend timer is separate and still runs: if it is on, switch it off for when the machine is plugged in.";
|
||||
default: return '';
|
||||
}
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
@@ -2685,8 +2633,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
|
||||
keepAwakeEnabled: document.getElementById('appSettingsKeepAwake').checked,
|
||||
keepAwakeAcOnly: document.getElementById('appSettingsKeepAwakeAcOnly').checked,
|
||||
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
@@ -4820,5 +4766,4 @@ document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
window.app?._applyMcpSyncAdminGate?.();
|
||||
window.app?._applyDoctorAdminGate?.();
|
||||
window.app?._applyKeepAwakeAdminGate?.();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { keepAwake } from '../../keep-awake-manager.js';
|
||||
import { resolveKeepAwakeConfig } from '../../keep-awake.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
buildAwayDigest,
|
||||
@@ -1006,13 +1004,6 @@ export function registerSystemRoutes(
|
||||
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
|
||||
// it before persisting so settings.json stays clean.
|
||||
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
// Keep-awake is machine state (an OS sleep lock), so in multi-user mode only an
|
||||
// admin changes it. A non-admin's save carries whatever value its page loaded, so
|
||||
// the keys are dropped rather than refused: refusing would fail every settings save.
|
||||
if (!isAdmin(req)) {
|
||||
delete settingsToStore.keepAwakeEnabled;
|
||||
delete settingsToStore.keepAwakeAcOnly;
|
||||
}
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
@@ -1041,10 +1032,6 @@ export function registerSystemRoutes(
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Keep-awake reconciles from `merged` like the watchers above: a partial PUT
|
||||
// must not read as "turn it off".
|
||||
void keepAwake.apply(resolveKeepAwakeConfig(merged));
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
@@ -1095,14 +1082,6 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Keep awake ==========
|
||||
|
||||
// Status of the sleep lock behind `keepAwakeEnabled` (src/keep-awake-manager.ts). A
|
||||
// plain in-memory read: the settings toggle lives in PUT /api/settings.
|
||||
app.get('/api/system/keep-awake', async () => {
|
||||
return { success: true, data: keepAwake.getStatus() };
|
||||
});
|
||||
|
||||
// ========== Model Configuration ==========
|
||||
|
||||
app.get('/api/execution/model-config', async () => {
|
||||
|
||||
@@ -1378,14 +1378,6 @@ export const SettingsUpdateSchema = z
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Keep this computer awake while Codeman runs (src/keep-awake.ts). SYNCED, default OFF:
|
||||
* it is machine state (an OS sleep lock), so in multi-user mode only an admin can change
|
||||
* it; a non-admin's value is dropped by PUT /api/settings. `keepAwakeAcOnly` (default ON)
|
||||
* releases the lock on battery.
|
||||
*/
|
||||
keepAwakeEnabled: z.boolean().optional(),
|
||||
keepAwakeAcOnly: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
|
||||
@@ -111,8 +111,6 @@ import { intentStore } from '../intent-store.js';
|
||||
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import { keepAwake } from '../keep-awake-manager.js';
|
||||
import { resolveKeepAwakeConfig } from '../keep-awake.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
setupTimedRespawn,
|
||||
@@ -3144,10 +3142,6 @@ export class WebServer extends EventEmitter {
|
||||
console.log('Image watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Keep-awake: holds an OS sleep lock while this server runs (opt-in, default OFF).
|
||||
// A fresh read, like the gesture flag: it decides whether a lock is taken at all.
|
||||
void keepAwake.apply(resolveKeepAwakeConfig(await this.readSettings(true)));
|
||||
|
||||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||
if (await this.isTunnelEnabled()) {
|
||||
@@ -3982,11 +3976,6 @@ export class WebServer extends EventEmitter {
|
||||
// got wrong once.
|
||||
void stopDeepSeekWeb();
|
||||
|
||||
// Release the sleep lock with the server, not after it. The lock would also drop on
|
||||
// its own once this process exits (stdin pipe / caffeinate -w), but a graceful stop
|
||||
// should not leave the macOS lid request file to go stale on its own.
|
||||
await keepAwake.stop();
|
||||
|
||||
// Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed
|
||||
// only by the periodic idle sweep, whose interval is disposed just below.
|
||||
closeAllLlamaSwapLogTails();
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* @fileoverview install.sh's keep-awake follow-up, driven in a real bash where it can be
|
||||
* (laptop detection over a fake sysfs, the settings.json write, the --yes default) and
|
||||
* pinned statically where it cannot (the macOS root helper needs sudo and launchd).
|
||||
*
|
||||
* The rules: never turned on by --yes or a headless run; the setting is written before
|
||||
* the service starts; the root helper runs from a ROOT-OWNED copy, never from the
|
||||
* user-writable install dir; uninstall undoes only what the helper set.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
|
||||
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'install-keep-awake-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Source install.sh (library mode) in a real bash with HOME in the temp dir, then run `body`. */
|
||||
function drive(body: string, env: Record<string, string> = {}) {
|
||||
const script = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
${body}
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script, 'bash', INSTALL_SH], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
input: '',
|
||||
env: { ...process.env, HOME: join(dir, 'home'), ...env },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fakeSupply(name: string, files: Record<string, string>) {
|
||||
const d = join(dir, 'ps', name);
|
||||
mkdirSync(d, { recursive: true });
|
||||
for (const [k, v] of Object.entries(files)) writeFileSync(join(d, k), `${v}\n`);
|
||||
}
|
||||
|
||||
const laptopCheck = `
|
||||
POWER_SUPPLY_ROOT="${'$'}FAKE_PS"; LID_BUTTON_ROOT="${'$'}FAKE_LID"
|
||||
if is_laptop linux; then echo laptop; else echo desktop; fi
|
||||
`;
|
||||
|
||||
describe('is_laptop (Linux)', () => {
|
||||
const run = () => drive(laptopCheck, { FAKE_PS: join(dir, 'ps'), FAKE_LID: join(dir, 'lid') }).stdout.trim();
|
||||
|
||||
it('a machine with no battery and no lid is not a laptop', () => {
|
||||
mkdirSync(join(dir, 'ps'), { recursive: true });
|
||||
fakeSupply('AC', { type: 'Mains', online: '1' });
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
|
||||
it("a wireless mouse's battery does not make a desktop a laptop", () => {
|
||||
fakeSupply('hidpp_battery_0', { type: 'Battery', scope: 'Device', status: 'Discharging' });
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
|
||||
it('a system battery or a lid does', () => {
|
||||
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
|
||||
expect(run()).toBe('laptop');
|
||||
rmSync(join(dir, 'ps'), { recursive: true });
|
||||
mkdirSync(join(dir, 'lid', 'LID0'), { recursive: true });
|
||||
expect(run()).toBe('laptop');
|
||||
});
|
||||
|
||||
it('copes with a missing power_supply tree', () => {
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write_keep_awake_setting', () => {
|
||||
const settings = () => join(dir, 'home', '.codeman', 'settings.json');
|
||||
|
||||
it('creates settings.json with the setting on and AC-only on', () => {
|
||||
const r = drive('write_keep_awake_setting');
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({ keepAwakeEnabled: true, keepAwakeAcOnly: true });
|
||||
});
|
||||
|
||||
it('keeps every other key, and an explicit AC-only choice', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(settings(), JSON.stringify({ theme: 'dark', keepAwakeAcOnly: false }, null, 2));
|
||||
expect(drive('write_keep_awake_setting').status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({
|
||||
theme: 'dark',
|
||||
keepAwakeAcOnly: false,
|
||||
keepAwakeEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a settings file that does not parse untouched', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(settings(), '{ not json');
|
||||
const r = drive('write_keep_awake_setting || echo refused');
|
||||
expect(r.stdout).toContain('refused');
|
||||
expect(readFileSync(settings(), 'utf-8')).toBe('{ not json');
|
||||
});
|
||||
|
||||
it('is what keep_awake_enabled_now reads back', () => {
|
||||
const r = drive(
|
||||
'keep_awake_enabled_now && echo before; write_keep_awake_setting >/dev/null 2>&1; keep_awake_enabled_now && echo after'
|
||||
);
|
||||
expect(r.stdout.trim()).toBe('after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('choose_keep_awake', () => {
|
||||
it('--yes on a laptop never turns it on', () => {
|
||||
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
|
||||
const r = drive(
|
||||
`POWER_SUPPLY_ROOT="$FAKE_PS"; ASSUME_YES=1
|
||||
choose_keep_awake linux
|
||||
echo "keep=[$KEEP_AWAKE] helper=[$KEEP_AWAKE_LID_HELPER]"`,
|
||||
{ FAKE_PS: join(dir, 'ps') }
|
||||
);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('keep=[] helper=[]');
|
||||
});
|
||||
|
||||
it('asks nothing on a desktop', () => {
|
||||
const r = drive(
|
||||
`POWER_SUPPLY_ROOT="$FAKE_PS"; LID_BUTTON_ROOT="$FAKE_PS"
|
||||
choose_keep_awake linux 2>&1
|
||||
echo "keep=[$KEEP_AWAKE]"`,
|
||||
{ FAKE_PS: join(dir, 'nothing') }
|
||||
);
|
||||
expect(r.stdout.trim()).toBe('keep=[]');
|
||||
});
|
||||
|
||||
it('only notes an existing setting, without re-asking', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(join(dir, 'home', '.codeman', 'settings.json'), JSON.stringify({ keepAwakeEnabled: true }, null, 2));
|
||||
const r = drive(`choose_keep_awake linux force 2>&1; echo "keep=[$KEEP_AWAKE]"`);
|
||||
expect(r.stdout).toContain('already on');
|
||||
expect(r.stdout).toContain('keep=[]');
|
||||
});
|
||||
|
||||
it('defaults to no in the prompt itself', () => {
|
||||
const fn = SOURCE.slice(SOURCE.indexOf('choose_keep_awake() {'), SOURCE.indexOf('write_keep_awake_setting() {'));
|
||||
expect(fn).toMatch(/prompt_yes_no "Keep this machine awake[^"]*" "n"/);
|
||||
// The sudo-needing helper is offered only to a person at a terminal.
|
||||
expect(fn).toMatch(/"\$NONINTERACTIVE" != "1" && "\$ASSUME_YES" != "1" \]\] && has_tty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh keep-awake wiring', () => {
|
||||
const fn = (name: string, next: string) => SOURCE.slice(SOURCE.indexOf(`${name}() {`), SOURCE.indexOf(next));
|
||||
|
||||
it('asks after question 3 and applies before the service starts', () => {
|
||||
const main = fn('main', '\npreflight_detect() {');
|
||||
const ask = main.indexOf('choose_keep_awake "$os"');
|
||||
const apply = main.indexOf('apply_keep_awake');
|
||||
expect(ask).toBeGreaterThan(main.indexOf('choose_launch_mode "$os"'));
|
||||
expect(ask).toBeLessThan(main.indexOf('install_or_update_repo'));
|
||||
expect(apply).toBeGreaterThan(main.indexOf('run_step "Building Codeman"'));
|
||||
expect(apply).toBeLessThan(main.indexOf('setup_launchd_service'));
|
||||
expect(apply).toBeLessThan(main.indexOf('setup_systemd_service'));
|
||||
});
|
||||
|
||||
it('runs the root helper from a root-owned copy, never from the install dir', () => {
|
||||
const body = fn('install_keep_awake_lid_helper', '\nremove_keep_awake_lid_helper() {');
|
||||
expect(body).toContain('install -m 755 -o root -g wheel "$src" "$script"');
|
||||
expect(body).toContain('local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"');
|
||||
const programArgs = body.slice(body.indexOf('<key>ProgramArguments</key>'), body.indexOf('</array>'));
|
||||
expect(programArgs).toContain('$(xml_escape "$script")');
|
||||
expect(programArgs).not.toContain('INSTALL_DIR');
|
||||
// The request path must match what the server writes (dataPath('keep-awake-lid.pid')).
|
||||
expect(body).toContain('local request="$HOME/.codeman/keep-awake-lid.pid"');
|
||||
});
|
||||
|
||||
it('uninstall removes the helper and undoes only a disablesleep it set', () => {
|
||||
expect(fn('uninstall', '\nusage() {')).toContain('remove_keep_awake_lid_helper');
|
||||
const remove = fn('remove_keep_awake_lid_helper', '\napply_keep_awake() {');
|
||||
const owned = remove.indexOf('keep-awake.owned');
|
||||
expect(owned).toBeGreaterThan(-1);
|
||||
expect(remove.indexOf('pmset -a disablesleep 0')).toBeGreaterThan(owned);
|
||||
});
|
||||
|
||||
it('dispatches the keep-awake subcommand and documents it', () => {
|
||||
expect(SOURCE).toMatch(/update\|uninstall\|tailscale\|name\|status\|cloudflared\|keep-awake\)/);
|
||||
expect(SOURCE).toMatch(/\n {4}keep-awake\) {2}keep_awake_subcommand ;;/);
|
||||
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
|
||||
expect(header).toContain('install.sh keep-awake');
|
||||
expect(fn('usage', '\nparse_flags() {')).toContain('keep-awake');
|
||||
});
|
||||
|
||||
it('update and keep-awake share one restart path', () => {
|
||||
expect(fn('update', '\nuninstall() {')).toContain('restart_running_service');
|
||||
expect(fn('keep_awake_subcommand', '\nverify_systemd_active() {')).toContain('restart_running_service');
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Runs the real macOS lid helper (scripts/keep-awake-macos.sh) in bash
|
||||
* against a stub `pmset` that records what it was asked to do. The helper runs as root
|
||||
* from a LaunchDaemon, so the cases that matter are the ones where it must NOT act: a
|
||||
* stale, dead, foreign or symlinked request, and an administrator's own
|
||||
* `disablesleep 1`, which it must never switch off.
|
||||
*
|
||||
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
|
||||
* here, so this passes on Linux and macOS alike. Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const HELPER = fileURLToPath(new URL('../scripts/keep-awake-macos.sh', import.meta.url));
|
||||
|
||||
let dir: string;
|
||||
let stateDir: string;
|
||||
let request: string;
|
||||
|
||||
/** Stub pmset: `-g` prints the stored SleepDisabled, `-a disablesleep N` stores N and logs it. */
|
||||
function writeStubPmset(initial: '0' | '1' | null) {
|
||||
const value = join(dir, 'sleepdisabled');
|
||||
if (initial !== null) writeFileSync(value, initial);
|
||||
const stub = join(dir, 'pmset');
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/bin/bash
|
||||
if [ "$1" = "-g" ]; then
|
||||
echo "System-wide power settings:"
|
||||
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
|
||||
echo "Currently in use:"
|
||||
echo " sleep 1"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
|
||||
printf '%s' "$3" > "${value}"
|
||||
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
);
|
||||
chmodSync(stub, 0o755);
|
||||
return stub;
|
||||
}
|
||||
|
||||
function run() {
|
||||
const result = spawnSync('bash', [HELPER, request], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
|
||||
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}
|
||||
|
||||
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
|
||||
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
|
||||
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
|
||||
const requestFromLiveServer = () => writeFileSync(request, `${process.pid}\n`);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'keep-awake-helper-'));
|
||||
stateDir = join(dir, 'state');
|
||||
request = join(dir, 'keep-awake-lid.pid');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('keep-awake-macos.sh', () => {
|
||||
it('does nothing without a request', () => {
|
||||
writeStubPmset('0');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
|
||||
run(); // steady state: no repeated pmset call
|
||||
expect(calls()).toBe('disablesleep 1\n');
|
||||
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
|
||||
});
|
||||
|
||||
it("never switches off an administrator's own disablesleep", () => {
|
||||
writeStubPmset('1');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('treats an absent SleepDisabled line as 0', () => {
|
||||
writeStubPmset(null);
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
});
|
||||
|
||||
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores a stale request (a crashed or hung server) and releases', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
|
||||
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a request whose pid is not running', () => {
|
||||
writeStubPmset('0');
|
||||
const gone = spawnSync('true').pid;
|
||||
writeFileSync(request, `${gone}\n`);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('ignores garbage and refuses a symlinked request', () => {
|
||||
writeStubPmset('0');
|
||||
writeFileSync(request, 'not-a-pid\n');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
|
||||
rmSync(request);
|
||||
const real = join(dir, 'elsewhere.pid');
|
||||
writeFileSync(real, `${process.pid}\n`);
|
||||
symlinkSync(real, request);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
/**
|
||||
* @fileoverview KeepAwakeManager (src/keep-awake-manager.ts) driven through fake deps: no
|
||||
* real lock is ever taken. Pins the lifecycle: a lock is confirmed before it reads as
|
||||
* active, a polkit refusal is a retried state, AC-only follows the power source, a
|
||||
* release closes the inhibitor's stdin (which is what drops the lock), and the macOS lid
|
||||
* request file follows the lock only when the root helper is installed.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KeepAwakeManager, type KeepAwakeDeps } from '../src/keep-awake-manager.js';
|
||||
import {
|
||||
LINUX_HELD_MARKER,
|
||||
POWER_POLL_MS,
|
||||
RETRY_MS,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
type PowerSupplyInfo,
|
||||
} from '../src/keep-awake.js';
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
/** Like the real `cat`: closing stdin ends the process (asynchronously). */
|
||||
stdin = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||||
this.exitCode = 0;
|
||||
this.emit('exit', 0, null);
|
||||
});
|
||||
}),
|
||||
});
|
||||
exitCode: number | null = null;
|
||||
signalCode: string | null = null;
|
||||
kill = vi.fn((signal?: string) => {
|
||||
this.signalCode = signal ?? 'SIGTERM';
|
||||
this.emit('exit', null, this.signalCode);
|
||||
return true;
|
||||
});
|
||||
/** The process announces the lock (systemd-inhibit only execs its child once held). */
|
||||
hold() {
|
||||
this.stdout.emit('data', Buffer.from(`${LINUX_HELD_MARKER}\n`));
|
||||
}
|
||||
fail(stderr: string, code = 1) {
|
||||
this.stderr.emit('data', Buffer.from(stderr));
|
||||
this.exitCode = code;
|
||||
this.emit('exit', code, null);
|
||||
}
|
||||
}
|
||||
|
||||
const AC: PowerSupplyInfo[] = [
|
||||
{ type: 'Mains', online: true },
|
||||
{ type: 'Battery', status: 'Charging' },
|
||||
];
|
||||
const BATTERY: PowerSupplyInfo[] = [
|
||||
{ type: 'Mains', online: false },
|
||||
{ type: 'Battery', status: 'Discharging' },
|
||||
];
|
||||
|
||||
/** Let queued reconciles (a promise chain) run to completion. */
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r));
|
||||
};
|
||||
|
||||
function makeDeps(overrides: Partial<KeepAwakeDeps> = {}) {
|
||||
const children: FakeChild[] = [];
|
||||
let supplies = AC;
|
||||
let batt = "Now drawing from 'AC Power'";
|
||||
const deps: KeepAwakeDeps = {
|
||||
platform: 'linux',
|
||||
serverPid: 4242,
|
||||
spawn: vi.fn(() => {
|
||||
const c = new FakeChild();
|
||||
children.push(c);
|
||||
return c as unknown as ChildProcess;
|
||||
}),
|
||||
readPowerSupplies: vi.fn(async () => supplies),
|
||||
readMacBatt: vi.fn(async () => batt),
|
||||
lidHelperInstalled: vi.fn(async () => true),
|
||||
writeLidRequest: vi.fn(async () => {}),
|
||||
removeLidRequest: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
children,
|
||||
setSupplies: (s: PowerSupplyInfo[]) => (supplies = s),
|
||||
setBatt: (s: string) => (batt = s),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KeepAwakeManager on Linux', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('takes the logind lock and reads active only once the lock is confirmed', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(deps.spawn).toHaveBeenCalledWith('systemd-inhibit', linuxInhibitArgs());
|
||||
expect(m.getStatus().state).toBe('starting');
|
||||
children[0].hold();
|
||||
expect(m.getStatus()).toMatchObject({ state: 'active', onAc: true, platform: 'linux', lidHelper: null });
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('reports a polkit refusal as denied and retries it', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].fail('Failed to inhibit: Access denied\n');
|
||||
expect(m.getStatus().state).toBe('denied');
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_MS);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(2);
|
||||
children[1].hold();
|
||||
expect(m.getStatus().state).toBe('active');
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('turning it off closes the inhibitor stdin, which drops the lock, and does not retry', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: false });
|
||||
children[0].hold();
|
||||
await m.apply({ enabled: false, acOnly: false });
|
||||
expect(children[0].stdin.end).toHaveBeenCalled();
|
||||
expect(m.getStatus().state).toBe('off');
|
||||
// The released child exiting is expected, not a failure to retry.
|
||||
await settle();
|
||||
await vi.advanceTimersByTimeAsync(RETRY_MS * 2);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(m.getStatus().state).toBe('off');
|
||||
});
|
||||
|
||||
it('AC-only: no lock on battery, takes it when plugged in, releases on unplug', async () => {
|
||||
const { deps, children, setSupplies } = makeDeps();
|
||||
setSupplies(BATTERY);
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
expect(m.getStatus()).toMatchObject({ state: 'paused-battery', onAc: false });
|
||||
|
||||
setSupplies(AC);
|
||||
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
children[0].hold();
|
||||
expect(m.getStatus().state).toBe('active');
|
||||
|
||||
setSupplies(BATTERY);
|
||||
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
||||
await settle();
|
||||
expect(children[0].stdin.end).toHaveBeenCalled();
|
||||
expect(m.getStatus().state).toBe('paused-battery');
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('without AC-only it never reads the power source', async () => {
|
||||
const { deps, setSupplies } = makeDeps();
|
||||
setSupplies(BATTERY);
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: false });
|
||||
expect(deps.readPowerSupplies).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('a machine without systemd-inhibit is unavailable, and is not retried', async () => {
|
||||
const { deps } = makeDeps({
|
||||
spawn: vi.fn(() => {
|
||||
throw Object.assign(new Error('spawn systemd-inhibit ENOENT'), { code: 'ENOENT' });
|
||||
}),
|
||||
});
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(m.getStatus()).toMatchObject({ state: 'unavailable' });
|
||||
expect(m.getStatus().detail).toMatch(/systemd-inhibit is not installed/);
|
||||
await vi.advanceTimersByTimeAsync(RETRY_MS * 3);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('stop() releases the lock and leaves nothing scheduled', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].hold();
|
||||
await m.stop();
|
||||
await settle();
|
||||
expect(children[0].stdin.end).toHaveBeenCalled();
|
||||
expect(children[0].kill).not.toHaveBeenCalled();
|
||||
expect(m.getStatus().state).toBe('off');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeepAwakeManager on macOS', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs caffeinate tied to the server pid and keeps the lid request fresh while held', async () => {
|
||||
const { deps, children } = makeDeps({ platform: 'macos' });
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(deps.spawn).toHaveBeenCalledWith('/usr/bin/caffeinate', macCaffeinateArgs(4242));
|
||||
children[0].emit('spawn');
|
||||
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'installed', onAc: true });
|
||||
expect(deps.writeLidRequest).toHaveBeenCalledWith(4242);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await m.apply({ enabled: false, acOnly: true });
|
||||
expect(deps.removeLidRequest).toHaveBeenCalled();
|
||||
expect(children[0].kill).toHaveBeenCalled();
|
||||
const writes = (deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBe(writes);
|
||||
});
|
||||
|
||||
it('writes no lid request when the root helper is not installed', async () => {
|
||||
const { deps, children } = makeDeps({ platform: 'macos', lidHelperInstalled: vi.fn(async () => false) });
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].emit('spawn');
|
||||
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'missing' });
|
||||
expect(deps.writeLidRequest).not.toHaveBeenCalled();
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('on battery with AC-only, drops both caffeinate and the lid request', async () => {
|
||||
const { deps, children, setBatt } = makeDeps({ platform: 'macos' });
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].emit('spawn');
|
||||
setBatt("Now drawing from 'Battery Power'");
|
||||
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
||||
await settle();
|
||||
expect(m.getStatus().state).toBe('paused-battery');
|
||||
expect(children[0].kill).toHaveBeenCalled();
|
||||
expect(deps.removeLidRequest).toHaveBeenCalled();
|
||||
await m.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the process-wide manager under vitest', () => {
|
||||
it('is inert: enabling it never spawns a real lock', async () => {
|
||||
const m = new KeepAwakeManager();
|
||||
await m.apply({ enabled: true, acOnly: false });
|
||||
expect(m.getStatus()).toMatchObject({ state: 'unavailable', detail: 'Disabled under the test runner.' });
|
||||
await m.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs"
|
||||
* (src/keep-awake.ts): settings defaults, platform choice, power-source parsing, the
|
||||
* hold/release rule, the lock argv and the failure classification.
|
||||
*
|
||||
* Port: none (pure).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LINUX_HELD_MARKER,
|
||||
LINUX_INHIBIT_WHAT,
|
||||
classifyInhibitFailure,
|
||||
isOnAcPowerLinux,
|
||||
isOnAcPowerMac,
|
||||
keepAwakePlatform,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
resolveKeepAwakeConfig,
|
||||
shouldHoldLock,
|
||||
} from '../src/keep-awake.js';
|
||||
|
||||
describe('resolveKeepAwakeConfig', () => {
|
||||
it('is off unless explicitly enabled, and AC-only unless explicitly not', () => {
|
||||
expect(resolveKeepAwakeConfig({})).toEqual({ enabled: false, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: 'yes' })).toEqual({ enabled: false, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true })).toEqual({ enabled: true, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true, keepAwakeAcOnly: false })).toEqual({
|
||||
enabled: true,
|
||||
acOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepAwakePlatform', () => {
|
||||
it('maps darwin and linux, and treats WSL as unsupported', () => {
|
||||
expect(keepAwakePlatform('darwin', '24.0.0')).toBe('macos');
|
||||
expect(keepAwakePlatform('linux', '6.8.0-124-generic')).toBe('linux');
|
||||
expect(keepAwakePlatform('linux', '5.15.153.1-microsoft-standard-WSL2')).toBe('unsupported');
|
||||
expect(keepAwakePlatform('win32', '10.0.22631')).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnAcPowerLinux', () => {
|
||||
const battery = (status: string) => ({ type: 'Battery', status });
|
||||
it('a machine with no battery of its own is on external power', () => {
|
||||
expect(isOnAcPowerLinux([])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'Battery', status: 'Discharging', scope: 'Device' }])).toBe(true);
|
||||
});
|
||||
it('an online adapter means AC, all adapters offline means battery', () => {
|
||||
expect(isOnAcPowerLinux([{ type: 'Mains', online: true }, battery('Charging')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'USB', online: true }, battery('Unknown')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'Mains', online: false }, battery('Unknown')])).toBe(false);
|
||||
});
|
||||
it('falls back to the battery status when no adapter is listed', () => {
|
||||
expect(isOnAcPowerLinux([battery('Discharging')])).toBe(false);
|
||||
expect(isOnAcPowerLinux([battery('Full')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([battery('Not charging')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([battery('Unknown')])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnAcPowerMac', () => {
|
||||
it('reads the source from `pmset -g batt`', () => {
|
||||
expect(isOnAcPowerMac("Now drawing from 'AC Power'\n -InternalBattery-0 (id=1)\t100%; charged;")).toBe(true);
|
||||
expect(isOnAcPowerMac("Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t80%;")).toBe(false);
|
||||
expect(isOnAcPowerMac("Now drawing from 'UPS Power'")).toBe(false);
|
||||
expect(isOnAcPowerMac('')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldLock', () => {
|
||||
it('holds only when enabled, and on battery only when not AC-only', () => {
|
||||
expect(shouldHoldLock({ enabled: false, acOnly: false }, true)).toBe(false);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, true)).toBe(true);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, false)).toBe(false);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: false }, false)).toBe(true);
|
||||
});
|
||||
it('treats an unknown power source as AC (a desktop without battery reporting)', () => {
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock argv', () => {
|
||||
it('asks logind for the lid lock, not just sleep (LidSwitchIgnoreInhibited=yes ignores sleep)', () => {
|
||||
const args = linuxInhibitArgs();
|
||||
expect(LINUX_INHIBIT_WHAT.split(':')).toContain('handle-lid-switch');
|
||||
expect(args).toContain(`--what=${LINUX_INHIBIT_WHAT}`);
|
||||
expect(args).toContain('--mode=block');
|
||||
});
|
||||
it('ends in a cat on stdin, so the lock dies with the server', () => {
|
||||
const args = linuxInhibitArgs();
|
||||
const script = args[args.length - 1];
|
||||
expect(args.slice(-3, -1)).toEqual(['/bin/sh', '-c']);
|
||||
expect(script).toBe(`echo ${LINUX_HELD_MARKER}; exec cat`);
|
||||
expect(script).not.toMatch(/sleep/);
|
||||
});
|
||||
it('ties caffeinate to the server pid', () => {
|
||||
expect(macCaffeinateArgs(4242)).toEqual(['-i', '-s', '-w', '4242']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyInhibitFailure', () => {
|
||||
it('reads a polkit refusal as denied', () => {
|
||||
expect(classifyInhibitFailure('Failed to inhibit: Access denied\n').state).toBe('denied');
|
||||
expect(classifyInhibitFailure('Interactive authentication required.').state).toBe('denied');
|
||||
});
|
||||
it('reads a missing logind as unavailable', () => {
|
||||
expect(classifyInhibitFailure('Failed to connect to bus: No such file or directory').state).toBe('unavailable');
|
||||
});
|
||||
it('keeps the first line of anything else', () => {
|
||||
const r = classifyInhibitFailure('something odd\nmore');
|
||||
expect(r).toEqual({ state: 'failed', detail: 'something odd' });
|
||||
expect(classifyInhibitFailure('').detail).toBe('the inhibitor exited');
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Keep-awake wiring in system-routes: PUT /api/settings reconciles the sleep
|
||||
* lock from the MERGED settings (a partial body never reads as "turn it off"), a
|
||||
* non-admin's value is dropped in multi-user mode (it is machine state), and
|
||||
* GET /api/system/keep-awake returns the manager's status in the standard envelope.
|
||||
*
|
||||
* Uses app.inject(); the manager is mocked, so no real lock is taken. Port: N/A.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
const { store, keepAwake } = vi.hoisted(() => ({
|
||||
store: { settings: {} as Record<string, unknown>, written: [] as Record<string, unknown>[] },
|
||||
keepAwake: {
|
||||
apply: vi.fn(async () => {}),
|
||||
getStatus: vi.fn(() => ({
|
||||
enabled: true,
|
||||
acOnly: true,
|
||||
platform: 'linux',
|
||||
state: 'active',
|
||||
onAc: true,
|
||||
lidHelper: null,
|
||||
detail: null,
|
||||
})),
|
||||
stop: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => JSON.stringify(store.settings)),
|
||||
writeFile: vi.fn(async (_path: string, data: string) => {
|
||||
store.written.push(JSON.parse(data));
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
|
||||
vi.mock('../../src/keep-awake-manager.js', () => ({ keepAwake }));
|
||||
|
||||
describe('keep-awake in system routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
store.settings = {};
|
||||
store.written = [];
|
||||
keepAwake.apply.mockClear();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
await harness?.app.close();
|
||||
});
|
||||
|
||||
it('a partial PUT keeps the persisted keep-awake on', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
store.settings = { keepAwakeEnabled: true, keepAwakeAcOnly: false };
|
||||
const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showMonitor: true } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: false });
|
||||
});
|
||||
|
||||
it('an explicit PUT turns it on and persists it', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(store.written.at(-1)).toMatchObject({ keepAwakeEnabled: true });
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
|
||||
});
|
||||
|
||||
it('rejects a non-boolean value', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(keepAwake.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a non-admin's value in multi-user mode, without failing the rest of the save", async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSystemRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
store.settings = { keepAwakeEnabled: false };
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true, keepAwakeAcOnly: false, showMonitor: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const written = store.written.at(-1)!;
|
||||
expect(written.keepAwakeEnabled).toBe(false);
|
||||
expect('keepAwakeAcOnly' in written).toBe(false);
|
||||
expect(written.showMonitor).toBe(true);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: false, acOnly: true });
|
||||
});
|
||||
|
||||
it('an admin can change it in multi-user mode', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSystemRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
|
||||
});
|
||||
|
||||
it('GET /api/system/keep-awake returns the status envelope', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/system/keep-awake' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: keepAwake.getStatus() });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user