diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfd534..21d51f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.17.0] - 2026-08-27 + +### Added + +- **The arm's lifecycle is no longer one-way.** `codesema brain disconnect` clears the locally stored brain credentials (`syncUrl`/`syncWorkspaceId`/`syncSecret`), idempotent and non-interactive like the rest of `brain-commands.ts`, and reminds the caller to also revoke the arm server-side, in the repository's dashboard Settings — this command never talks to the brain itself, only the local file `brain connect` wrote. `codesema brain install-service` writes and enables a systemd `--user` unit for `codesema brain serve`, generated from the same template already shipped at `assets/systemd/codesema-brain.service` (its path resolved from the installed package the way `serve.ts` already resolves its embedded `web-dist`), pinned to the invoking repository's root and the actual binary running the command — never a bare `codesema` relying on `PATH`, which a systemd unit is not guaranteed to see the same way an interactive shell does — with an optional `--env-file` for secrets and `loginctl enable-linger` enabled best-effort (a failure, common in containers and WSL, is reported to the caller rather than failing the install). `codesema brain uninstall-service` reverses it; both commands are idempotent, the same doctrine `brain stop` already has for an absent pidfile. + +- **An arm can now be installed on a server you already have, not only a freshly provisioned VM.** `packages/cli/assets/deploy/install.sh` is a runner-style installer, the same binary/OS split gitlab-runner's own installer uses: it checks Node.js (>= 20), `gh`, a container runtime (docker or rootless podman) and `codesema`/`claude-code` before installing any of them, clones the target repository through `gh auth setup-git` so no token ever lands in `.git/config`, and hands the systemd unit itself to `codesema brain install-service` rather than writing it by hand. `cloud-init.yaml.example`'s own embedded `provision.sh` is now a thin bootstrap — just enough Node.js to run `npm i -g codesema` — that calls this exact same script from the installed package, so the fresh-VM and existing-server paths share one implementation instead of two that could drift apart; `brain.env.example` grew `CODESEMA_BRAIN_URL` (defaulted to the production brain) and `REPO_URL` alongside the three secrets it already carried, one file feeding both paths. `docs/deploy-vm-arm.md` gains an "Existing server (BYOC)" runbook and an "Uninstall" section covering both paths, and states the same order-channel security gate for a server install as it already does for a real VPS — the constraint is the brain's order channel, not how the machine was provisioned. + ## [0.16.0] - 2026-08-27 ### Added diff --git a/docs/deploy-vm-arm.md b/docs/deploy-vm-arm.md index 9b4ef67..cea3b0d 100644 --- a/docs/deploy-vm-arm.md +++ b/docs/deploy-vm-arm.md @@ -8,10 +8,14 @@ gate below is lifted — on a real server, from the exact same artifact. The provisioning content lives in `packages/cli/assets/deploy/`: -- `cloud-init.yaml.example` — the full cloud-init file. Copy it to - `cloud-init.local.yaml` (gitignored) and fill in every `__PLACEHOLDER__`. -- `brain.env.example` — the three secrets `cloud-init.local.yaml` needs, with - one line each on where to mint them. +- `cloud-init.yaml.example` — the full cloud-init file for a fresh machine. + Copy it to `cloud-init.local.yaml` (gitignored) and fill in every + `__PLACEHOLDER__`. +- `brain.env.example` — the five values `cloud-init.local.yaml` (and + `install.sh`, below) need, with one line each on where to mint them. +- `install.sh` — the same installer `cloud-init.yaml.example` calls under + the hood, runnable directly against a server you already have instead of + a fresh VM. See [Existing server (BYOC)](#existing-server-byoc) below. ## Security gate @@ -156,3 +160,65 @@ Once the order-channel hardening has shipped, the same `cloud-init.local.yaml` targets a real VPS unchanged: hand it to the provider's cloud-init field at creation time instead of `multipass launch --cloud-init`. Nothing else in this runbook changes. + +## Existing server (BYOC) + +Already have a server — a VPS, a machine in your own fleet — instead of +provisioning a fresh one? Skip the VM and cloud-init entirely and run the +installer directly on it. Same artifact, same end state: `install.sh` is +exactly what `cloud-init.yaml.example`'s own `provision.sh` calls once it +has bootstrapped just enough (Node.js, `npm i -g codesema`) to run it, so +there is one place — not two — that knows how to turn a machine into a +running arm. + +```bash +REPO_URL=https://github.com/org/repo.git \ +GH_TOKEN=... \ +CLAUDE_CODE_OAUTH_TOKEN=... \ +CODESEMA_BRAIN_TOKEN=csk_... \ + bash packages/cli/assets/deploy/install.sh +``` + +The same five values `brain.env.example` documents (steps 1-2 above mint +them the same way), passed as environment variables instead of pasted into +a YAML file — `CODESEMA_BRAIN_URL` defaults to `https://codesema.com` if +left unset. Any of the other four left unset is prompted for interactively +when the script is run from a terminal; a piped or otherwise non-interactive +run fails loudly instead of hanging on a prompt nobody can answer. + +`install.sh` is idempotent, the same "check before acting" doctrine +throughout this file: Node.js (>= 20, else nodesource), `gh` (else the +official apt repo), a container runtime (docker or podman, installing +rootless podman only if neither is present) and `codesema`/`claude-code` are +all checked before anything is installed, the repository is cloned only if +not already there, and the run ends by calling `codesema brain +install-service` itself — it never writes the systemd unit by hand. + +**Same gate as step 7 above (a real server).** This puts a 24/7 arm on a +machine whose order channel has no signature or confirmation and no kill +switch yet — the constraint is the brain's order channel, not how the +machine was provisioned, so it applies here exactly as it does to a fresh +VPS. Do not point this at a real, internet-facing server until that +hardening has shipped. + +## Uninstall + +**VM**: `multipass delete codesema-arm --purge`. Separately, in the +dashboard, switch the repository's execution mode back from arm to server — +deleting the VM does not do that for you. In-flight tickets need no manual +cleanup: a claim's lease expires on its own once nothing renews it with a +heartbeat. + +**Existing server**: + +```bash +codesema brain uninstall-service # stops and removes the systemd --user unit +codesema brain disconnect # clears the locally stored brain credentials +npm uninstall -g codesema @anthropic-ai/claude-code +``` + +Then, in the dashboard, revoke this arm from the repository's Settings — +`brain disconnect` only clears this machine's own copy of the credentials, +it does not revoke them server-side — and switch the repository's execution +mode back from arm to server if you want the brain's own scheduler to pick +this repository back up. diff --git a/package.json b/package.json index d166afa..f97618f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesema-tools", - "version": "0.16.0", + "version": "0.17.0", "private": true, "type": "module", "workspaces": [ diff --git a/packages/cli/assets/deploy/brain.env.example b/packages/cli/assets/deploy/brain.env.example index 538ef91..b493792 100644 --- a/packages/cli/assets/deploy/brain.env.example +++ b/packages/cli/assets/deploy/brain.env.example @@ -1,8 +1,13 @@ -# codesema brain daemon environment, loaded by the systemd unit through -# EnvironmentFile= (see cloud-init.yaml.example). Copy, fill in the three -# values, then paste them into cloud-init.local.yaml's write_files block — -# or scp this file straight to /etc/codesema/brain.env on a provisioned host. -# Full minting steps: docs/deploy-vm-arm.md. +# One file, everything install.sh and cloud-init.yaml.example need to stand +# up a codesema arm — copy, fill in every __PLACEHOLDER__, then either paste +# these five lines into cloud-init.local.yaml's write_files block, or export +# them directly before running install.sh by hand on an existing server (or +# scp this file to /etc/codesema/brain.env and `set -a; . brain.env; set +a` +# first). Full minting steps: docs/deploy-vm-arm.md. +# +# Only CLAUDE_CODE_OAUTH_TOKEN and GH_TOKEN are read by the running daemon +# (systemd's EnvironmentFile=, every ticket); the other three are consumed +# once, at install time, by install.sh and `codesema brain connect`. # Long-lived token from `claude setup-token`, run on any machine with a browser. CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__ @@ -12,3 +17,9 @@ GH_TOKEN=__PLACEHOLDER__ # csk_., minted with `codesema link` against a scratch CODESEMA_CONFIG_DIR. CODESEMA_BRAIN_TOKEN=__PLACEHOLDER__ + +# The brain to connect to. Real default already filled in: change it only for a self-hosted brain. +CODESEMA_BRAIN_URL=https://codesema.com + +# HTTPS clone URL of the repository this arm works. +REPO_URL=__PLACEHOLDER__ diff --git a/packages/cli/assets/deploy/cloud-init.yaml.example b/packages/cli/assets/deploy/cloud-init.yaml.example index 66195da..cc17095 100644 --- a/packages/cli/assets/deploy/cloud-init.yaml.example +++ b/packages/cli/assets/deploy/cloud-init.yaml.example @@ -3,6 +3,12 @@ # once the order-channel hardening gate is lifted, the real VPS deployment # described in docs/deploy-vm-arm.md. Same file, same content, either target. # +# This is the "fresh machine" path. For an EXISTING server you already +# manage, use assets/deploy/install.sh directly instead — this file's own +# provision.sh is a thin bootstrap (just enough to get npm running, chicken- +# egg) that ends by calling that exact same script, so there is only one +# place that knows how to turn a bare machine into a running arm. +# # Usage: cp cloud-init.yaml.example cloud-init.local.yaml, replace every # __PLACEHOLDER__ below (brain.env.example says where each one comes from), # then: @@ -42,46 +48,22 @@ write_files: # defer: true — cloud-init's default module order runs write_files BEFORE # users-groups, so an `owner: codesema:...` here would fail with "no such # user" unless the write is deferred past user creation. + # + # All five values install.sh needs, in the one file it (and the systemd + # unit it installs, through EnvironmentFile=) reads from — see + # brain.env.example for where each one comes from. CODESEMA_BRAIN_URL + # ships with a real default rather than a placeholder: override it only + # for a self-hosted brain. - path: /etc/codesema/brain.env owner: root:codesema permissions: '0640' defer: true content: | - # Filled in from cloud-init.local.yaml; loaded by the systemd unit - # below through EnvironmentFile= (see brain.env.example for how to - # mint each value). CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__ GH_TOKEN=__PLACEHOLDER__ CODESEMA_BRAIN_TOKEN=__PLACEHOLDER__ - - - path: /home/codesema/.config/systemd/user/codesema-brain.service - owner: codesema:codesema - permissions: '0644' - defer: true - content: | - # Derived from packages/cli/assets/systemd/codesema-brain.service: - # only WorkingDirectory/EnvironmentFile/ExecStart are pinned to this - # VM's layout, nothing else changed. Stop with - # `systemctl --user stop codesema-brain.service`, never a raw `kill` - # or `codesema brain stop`: Restart=on-failure relaunches either. - [Unit] - Description=codesema brain daemon - After=network-online.target - Wants=network-online.target - - [Service] - Type=simple - WorkingDirectory=/home/codesema/codesema-bench - EnvironmentFile=/etc/codesema/brain.env - # Absolute path: systemd --user units do not reliably inherit an - # interactive shell's PATH, and this is where npm puts global bins - # when node itself comes from the nodesource package (prefix /usr). - ExecStart=/usr/bin/codesema brain serve - Restart=on-failure - RestartSec=5 - - [Install] - WantedBy=default.target + CODESEMA_BRAIN_URL=https://codesema.com + REPO_URL=__PLACEHOLDER__ # No defer needed: root:root, no dependency on the codesema user existing. - path: /opt/codesema/provision.sh @@ -92,45 +74,30 @@ write_files: exec > >(tee -a /var/log/codesema-provision.log) 2>&1 echo "[codesema-provision] starting at $(date -u +%FT%TZ)" - # The bench repo this arm works; replace before launch, same as the - # three secrets in /etc/codesema/brain.env. - BENCH_REPO_URL="__CODESEMA_BENCH_REPO_URL__" - - # Fail fast and loud on a forgotten placeholder rather than let the - # service crashloop later with no clue why. + # Fail fast and loud on a forgotten placeholder rather than let + # install.sh (or the service it enables) crashloop later with no clue + # why. if grep -q '__PLACEHOLDER__' /etc/codesema/brain.env; then echo "[codesema-provision] /etc/codesema/brain.env still has __PLACEHOLDER__ values, aborting" >&2 exit 1 fi - if [ "$BENCH_REPO_URL" = "__CODESEMA_BENCH_REPO_URL__" ]; then - echo "[codesema-provision] BENCH_REPO_URL was not replaced, aborting" >&2 - exit 1 - fi - # --- Node.js 22 LTS (nodesource): packages: above only installs from - # repos that already exist, so the repo-add step lives here. + # --- Node.js 22 LTS (nodesource): the one step install.sh cannot do + # for itself here — chicken-egg, npm does not exist yet to run it. + # install.sh's own node check finds this already satisfied and skips + # it, same as it would on a server that already had node. curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y nodejs - # --- GitHub CLI, official repo (same reasoning as nodejs above). - install -d -m 0755 /usr/share/keyrings - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - -o /usr/share/keyrings/githubcli-archive-keyring.gpg - chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - > /etc/apt/sources.list.d/github-cli.list - apt-get update - apt-get install -y gh - npm install -g codesema@latest @anthropic-ai/claude-code - # Asserted rather than assumed: the systemd unit's ExecStart is a - # hardcoded absolute path, so a silent prefix drift here must fail - # the provisioning run, not the service five seconds after boot. - test -x /usr/bin/codesema || { echo "[codesema-provision] codesema not at /usr/bin/codesema, fix ExecStart" >&2; exit 1; } + # Asserted rather than assumed: install.sh is invoked below by its + # installed path, so a silent prefix drift here must fail the + # provisioning run, not that invocation five seconds later. + test -x /usr/bin/codesema || { echo "[codesema-provision] codesema not at /usr/bin/codesema, fix nodesource's npm prefix" >&2; exit 1; } # write_files ran before this user existed; own its home now, before - # any command below runs as codesema (gh/git both write under $HOME). + # any command below runs as codesema (gh/git/npm all write under $HOME). mkdir -p /home/codesema chown -R codesema:codesema /home/codesema @@ -144,55 +111,37 @@ write_files: # the entire point of running this as a boot-time daemon. loginctl enable-linger codesema - # `gh auth setup-git` wires env-based auth (GH_TOKEN) into git's own - # HTTPS credential helper, so the clone below never puts the token in - # this repo's .git/config: only ~/.gitconfig gets a `credential.helper` - # line naming gh, which reads GH_TOKEN again at call time. - runuser -u codesema -- bash -c ' - set -euo pipefail - set -a; . /etc/codesema/brain.env; set +a - gh auth setup-git - ' - runuser -u codesema -- env BENCH_REPO_URL="$BENCH_REPO_URL" bash -c ' - set -euo pipefail - set -a; . /etc/codesema/brain.env; set +a - git clone "$BENCH_REPO_URL" /home/codesema/codesema-bench - ' + # codesema's user manager must actually be up before install.sh's own + # `systemctl --user`/`loginctl` calls (inside `codesema brain + # install-service`) can reach it — enable-linger above only spawns it + # asynchronously through logind, hence the bounded retry. `-M + # codesema@` reaches the manager through systemd-logind directly + # (systemd >= 248, shipped since Ubuntu 20.10), which is more robust + # here than hand-rolling XDG_RUNTIME_DIR this soon after enable-linger. + for _ in $(seq 1 10); do + systemctl --user -M codesema@ daemon-reload 2>/dev/null && break + sleep 1 + done + systemctl --user -M codesema@ daemon-reload - # agent/isolation written FIRST: `codesema brain connect` below loads - # this same file and merges its own three keys into it — it does not - # overwrite what is already there. - install -d -o codesema -g codesema -m 0700 /home/codesema/.config/codesema - cat > /home/codesema/.config/codesema/config.json <<'JSON' - { - "agent": "claude -p", - "isolation": "container" - } - JSON - chown codesema:codesema /home/codesema/.config/codesema/config.json - chmod 0600 /home/codesema/.config/codesema/config.json - - runuser -u codesema -- bash -c ' + # Once warm, install.sh's own PLAIN `systemctl --user` calls (no -M + # addressing: that is a cloud-init-specific concern install.sh has no + # reason to know about) need XDG_RUNTIME_DIR themselves. runuser's PAM + # session does not reliably set it (a known systemd/util-linux gap: + # https://github.com/systemd/systemd/issues/10574), so it is exported + # explicitly instead of assumed. install.sh reads the rest of its + # configuration (REPO_URL, the brain URL/token, the two runtime + # secrets) straight out of brain.env itself. + runuser -u codesema -- env XDG_RUNTIME_DIR="/run/user/$(id -u codesema)" bash -c ' set -euo pipefail set -a; . /etc/codesema/brain.env; set +a - codesema brain connect --url "https://codesema.com" --token "$CODESEMA_BRAIN_TOKEN" + "$(npm root -g)/codesema/assets/deploy/install.sh" ' # Warms the checks image ahead of the first ticket instead of paying # for the pull on that ticket's critical path. runuser -u codesema -- podman pull docker.io/library/node:26 - # `-M codesema@` reaches codesema's user@ manager through - # systemd-logind directly (systemd >= 248, shipped since Ubuntu - # 20.10): avoids hand-rolling XDG_RUNTIME_DIR, which is fragile this - # soon after enable-linger. The retry covers logind's own startup lag. - for _ in $(seq 1 10); do - systemctl --user -M codesema@ daemon-reload 2>/dev/null && break - sleep 1 - done - systemctl --user -M codesema@ daemon-reload - systemctl --user -M codesema@ enable --now codesema-brain.service - echo "[codesema-provision] done at $(date -u +%FT%TZ)" runcmd: diff --git a/packages/cli/assets/deploy/install.sh b/packages/cli/assets/deploy/install.sh new file mode 100755 index 0000000..2cbe63a --- /dev/null +++ b/packages/cli/assets/deploy/install.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Idempotent codesema arm installer for an EXISTING Ubuntu/Debian server (the +# "bring your own compute" path; cloud-init.yaml.example is the equivalent +# for a fresh VM provisioned from scratch — see docs/deploy-vm-arm.md for +# both). Runner-style split, same shape as gitlab-runner's install + +# config.sh/svc.sh: this script only gets the OS ready (Node.js, gh, a +# container runtime) and codesema/claude-code onto PATH; the systemd --user +# unit itself is written by `codesema brain install-service`, never by this +# script, so there is exactly one place that knows the unit's shape. +# +# Safe to re-run: every step checks before it acts. Reads its configuration +# from the environment and falls back to an interactive prompt for whichever +# required variable is missing and stdin is a terminal: +# +# CODESEMA_BRAIN_URL brain to connect to (default: https://codesema.com) +# CODESEMA_BRAIN_TOKEN csk_., from `codesema link` +# REPO_URL HTTPS clone URL of the repo this arm works +# GH_TOKEN GitHub token, "repo" scope +# CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token` +# +# Usage: +# REPO_URL=https://github.com/org/repo.git GH_TOKEN=... \ +# CLAUDE_CODE_OAUTH_TOKEN=... CODESEMA_BRAIN_TOKEN=csk_... \ +# bash install.sh +# +# Minting each value: docs/deploy-vm-arm.md. + +set -euo pipefail + +log() { + echo "[codesema-install] $*" +} + +fail() { + echo "[codesema-install] $*" >&2 + exit 1 +} + +# OS package steps only: root if we already are, sudo otherwise. Never used +# for the npm install below — see the prefix check next to it — since +# blanket sudo there is a known way to install into root's npm prefix +# instead of a user-managed one (nvm/volta/fnm), silently leaving the +# freshly "installed" binary off the invoking user's PATH. +as_root() { + if [ "$(id -u)" = "0" ]; then + "$@" + else + command -v sudo >/dev/null 2>&1 \ + || fail "not root and sudo not found: run this script as root, or install sudo first" + sudo "$@" + fi +} + +# $1: var name, $2: prompt text, $3 (optional): "secret" to hide input. +# Leaves the variable untouched if already set, and never blocks a +# non-interactive run (piped install, CI, cloud-init): it silently does +# nothing when stdin is not a terminal, leaving require_var below to fail +# loud instead of this hanging on a read nobody can answer. +prompt_var() { + local name="$1" text="$2" mode="${3:-}" value="" + if [ -n "${!name:-}" ] || [ ! -t 0 ]; then + return + fi + if [ "$mode" = "secret" ]; then + read -r -s -p "$text: " value + echo + else + read -r -p "$text: " value + fi + printf -v "$name" '%s' "$value" +} + +require_var() { + local name="$1" + [ -n "${!name:-}" ] \ + || fail "$name is required (set it in the environment, or run this script from an interactive terminal)" +} + +: "${CODESEMA_BRAIN_URL:=https://codesema.com}" + +prompt_var CODESEMA_BRAIN_TOKEN "Brain token (csk_., from 'codesema link')" secret +prompt_var REPO_URL "Repository to clone (HTTPS URL)" +prompt_var GH_TOKEN "GitHub token (repo scope)" secret +prompt_var CLAUDE_CODE_OAUTH_TOKEN "Claude Code OAuth token (from 'claude setup-token')" secret + +require_var CODESEMA_BRAIN_TOKEN +require_var REPO_URL +require_var GH_TOKEN +require_var CLAUDE_CODE_OAUTH_TOKEN +export GH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + +log "starting at $(date -u +%FT%TZ)" + +# --- Node.js >= 20 (nodesource, only if missing or too old). +node_major() { + command -v node >/dev/null 2>&1 || { echo 0; return; } + node -p 'process.versions.node.split(".")[0]' +} + +if [ "$(node_major)" -lt 20 ]; then + log "installing Node.js 22 (nodesource)" + curl -fsSL https://deb.nodesource.com/setup_22.x | as_root bash - + as_root apt-get install -y nodejs +else + log "node $(node -v) already >= 20, skipping" +fi + +# --- GitHub CLI (official repo, only if missing). +if command -v gh >/dev/null 2>&1; then + log "gh already present, skipping" +else + log "installing gh (official apt repo)" + as_root install -d -m 0755 /usr/share/keyrings + # tee, not curl -o: this script is not guaranteed to run as root, unlike + # cloud-init's own runcmd, so writing into /usr/share/keyrings needs the + # same curl-as-yourself/write-as-root split apt.gpg installs always use. + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | as_root tee /usr/share/keyrings/githubcli-archive-keyring.gpg >/dev/null + as_root chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | as_root tee /etc/apt/sources.list.d/github-cli.list >/dev/null + as_root apt-get update + as_root apt-get install -y gh +fi + +# --- A container runtime: docker OR rootless podman: only install podman +# (the default this project documents) when NEITHER is already present. +if command -v docker >/dev/null 2>&1 || command -v podman >/dev/null 2>&1; then + log "container runtime already present ($(command -v docker || command -v podman)), skipping" +else + log "installing rootless podman" + as_root apt-get install -y podman uidmap slirp4netns + if ! grep -q "^$(id -un):" /etc/subuid 2>/dev/null; then + as_root usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$(id -un)" + log "subuid/subgid ranges added for $(id -un): log out and back in (or 'loginctl terminate-user \$(whoami)') before the first container run" + fi +fi + +# --- codesema + claude-code on PATH. Presence-gated, not version-gated: a +# locked-down service account (no sudo, by design — see +# cloud-init.yaml.example's `codesema` user) can reach this script with +# both already installed by root moments earlier, and must be able to +# finish the install without ever touching npm's root-owned prefix. +# A human re-running this by hand to pick up a new codesema release should +# just `npm install -g codesema@latest` themselves first; this script's job +# is "make sure it's here", not "keep it current". +if command -v codesema >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then + log "codesema and claude already on PATH, skipping npm install" +else + log "installing codesema and @anthropic-ai/claude-code (npm -g)" + npm_prefix="$(npm config get prefix 2>/dev/null || echo /usr/local)" + if [ -w "$npm_prefix" ]; then + npm install -g codesema@latest @anthropic-ai/claude-code + else + as_root npm install -g codesema@latest @anthropic-ai/claude-code + fi + command -v codesema >/dev/null 2>&1 \ + || fail "codesema not on PATH after npm install -g — check npm's global bin dir is in PATH" + command -v claude >/dev/null 2>&1 \ + || fail "claude not on PATH after npm install -g — check npm's global bin dir is in PATH" +fi + +# --- Clone the repository. gh's own credential helper authenticates the +# clone from GH_TOKEN (already exported above), so the token never lands in +# this repo's .git/config — only ~/.gitconfig gets a credential.helper line +# naming gh, which reads GH_TOKEN again at call time. +repo_name="$(basename "$REPO_URL" .git)" +repo_dir="$HOME/$repo_name" + +gh auth setup-git + +if [ -d "$repo_dir/.git" ]; then + log "$repo_dir already cloned, skipping" +else + log "cloning $REPO_URL into $repo_dir" + git clone "$REPO_URL" "$repo_dir" +fi + +# --- Base config: written only if absent, so a re-run never clobbers a +# configuration already customized by hand (`codesema config`). Written +# FIRST: `codesema brain connect` below loads this same file and merges its +# own three keys into it, it does not overwrite what is already there. +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/codesema" +config_file="$config_dir/config.json" +if [ -f "$config_file" ]; then + log "$config_file already exists, leaving it alone" +else + mkdir -p "$config_dir" + cat > "$config_file" <<'JSON' +{ + "agent": "claude -p", + "isolation": "container" +} +JSON + chmod 0600 "$config_file" +fi + +codesema brain connect --url "$CODESEMA_BRAIN_URL" --token "$CODESEMA_BRAIN_TOKEN" + +# --- Runtime secrets for the systemd unit: only the two the DAEMON reads on +# every ticket (CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN). CODESEMA_BRAIN_TOKEN is +# install-time only — `brain connect` above already turned it into +# config.json's stored credentials — so it has no reason to also live here. +env_file="$config_dir/brain.env" +umask 077 +cat > "$env_file" < { }) }) + describe('disconnect', () => { + test('is a soft no-op when nothing is connected', async () => { + await expect(brainCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() + expect(loadGlobalConfig().syncUrl).toBeUndefined() + }) + + test('clears syncUrl/syncWorkspaceId/syncSecret, and only those', async () => { + await brainCommand({ + action: 'connect', + cwd, + url: 'https://brain.example', + token: 'csk_ws1.sec1', + }) + saveGlobalConfig({ ...loadGlobalConfig(), agent: 'claude -p' }) + + await expect(brainCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() + + const config = loadGlobalConfig() + expect(config.syncUrl).toBeUndefined() + expect(config.syncWorkspaceId).toBeUndefined() + expect(config.syncSecret).toBeUndefined() + expect(config.agent).toBe('claude -p') + }) + + test('running it twice is fine (idempotent)', async () => { + await brainCommand({ + action: 'connect', + cwd, + url: 'https://brain.example', + token: 'csk_ws1.sec1', + }) + await brainCommand({ action: 'disconnect', cwd }) + await expect(brainCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() + }) + }) + describe('status', () => { test('throws when not connected', async () => { await expect(brainCommand({ action: 'status', cwd })).rejects.toThrow() @@ -541,4 +577,92 @@ describe('brainCommand', () => { } }) }) + + describe('install-service / uninstall-service', () => { + const previousXdg = process.env.XDG_CONFIG_HOME + let xdgConfigHome: string + + function noopExecFn(calls: { command: string; args: readonly string[] }[]) { + return (command: string, args: readonly string[]) => { + calls.push({ command, args }) + return '' + } + } + + function unitPath(): string { + return join(xdgConfigHome, 'systemd', 'user', 'codesema-brain.service') + } + + beforeEach(() => { + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-braincmd-xdg-')) + process.env.XDG_CONFIG_HOME = xdgConfigHome + }) + + afterEach(() => { + rmSync(xdgConfigHome, { recursive: true, force: true }) + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdg + } + }) + + test('install-service refuses to run outside a git repository', async () => { + await expect( + brainCommand({ action: 'install-service', cwd, execFn: noopExecFn([]) }), + ).rejects.toThrow() + expect(existsSync(unitPath())).toBe(false) + }) + + test('install-service writes the unit pinned to the resolved repo root', async () => { + initRepo(cwd) + const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd, + encoding: 'utf8', + }).trim() + const calls: { command: string; args: readonly string[] }[] = [] + + await expect( + brainCommand({ action: 'install-service', cwd, execFn: noopExecFn(calls) }), + ).resolves.toBeUndefined() + + expect(existsSync(unitPath())).toBe(true) + const unit = readFileSync(unitPath(), 'utf8') + expect(unit).toContain(`WorkingDirectory=${repoRoot}`) + expect(calls.map((c) => c.args.join(' '))).toContain( + '--user enable --now codesema-brain.service', + ) + }) + + test('install-service surfaces a clear error when systemctl is absent, and writes nothing', async () => { + initRepo(cwd) + const execFn = (command: string) => { + if (command === 'systemctl') { + throw Object.assign(new Error('spawn systemctl ENOENT'), { code: 'ENOENT' }) + } + return '' + } + await expect(brainCommand({ action: 'install-service', cwd, execFn })).rejects.toThrow( + t('brain.systemctlNotFound'), + ) + expect(existsSync(unitPath())).toBe(false) + }) + + test('uninstall-service is a soft no-op when nothing is installed', async () => { + await expect( + brainCommand({ action: 'uninstall-service', cwd, execFn: noopExecFn([]) }), + ).resolves.toBeUndefined() + }) + + test('uninstall-service removes a previously installed unit', async () => { + initRepo(cwd) + await brainCommand({ action: 'install-service', cwd, execFn: noopExecFn([]) }) + expect(existsSync(unitPath())).toBe(true) + + await expect( + brainCommand({ action: 'uninstall-service', cwd, execFn: noopExecFn([]) }), + ).resolves.toBeUndefined() + expect(existsSync(unitPath())).toBe(false) + }) + }) }) diff --git a/packages/cli/src/brain-commands.ts b/packages/cli/src/brain-commands.ts index 8781e70..44e5d78 100644 --- a/packages/cli/src/brain-commands.ts +++ b/packages/cli/src/brain-commands.ts @@ -20,7 +20,9 @@ import { } from './brain-client.js' import { draftAndPublishTicket } from './brain-draft.js' import { readBrainPidfile, removeBrainPidfile } from './brain-pidfile.js' +import { installBrainService, uninstallBrainService, type ExecCommandFn } from './brain-service.js' import { loadGlobalConfig, saveGlobalConfig } from './config.js' +import { tryGit } from './git.js' import { t } from './i18n.js' import { loadSyncCredentials } from './sync.js' import { ACCENT, AMBER, dim, GREEN, paint, renderFieldRows, type FieldRow } from './ui.js' @@ -53,12 +55,16 @@ export type BrainCommandOptions = { prompt?: string | undefined /** `brain serve --detach` only: background the daemon instead of running it here. */ detach?: boolean | undefined + /** `brain install-service` only: EnvironmentFile= for the generated systemd unit. */ + envFile?: string | undefined /** Test seam. */ fetchImpl?: typeof fetch | undefined /** Test seam. */ runAgentFn?: typeof runAgent | undefined /** Test seam for `brain serve --detach`: never forks a real process in tests. */ spawnFn?: SpawnFn | undefined + /** Test seam for `brain install-service`/`uninstall-service`: never shells out to a real systemctl/loginctl in tests. */ + execFn?: ExecCommandFn | undefined /** Test seams for `brain stop`'s bounded poll: real 10s/200ms by default. */ stopTimeoutMs?: number | undefined stopPollIntervalMs?: number | undefined @@ -87,6 +93,28 @@ async function brainConnect(opts: BrainCommandOptions): Promise { console.log('') } +/** + * Local-only: clears the three credentials `brainConnect` wrote, the same + * destructure-and-omit `sync.ts`'s `deleteWorkspaceData` uses to drop + * `syncWorkspaceId`/`syncSecret` (here all three, since disconnecting a brain + * is meant to fully forget it, not just its data). No API call — the brain + * has its own revocation, shipped separately in its dashboard Settings — so + * this only ever touches the local file and reminds the caller to revoke + * there too. + */ +async function brainDisconnect(): Promise { + const config = loadGlobalConfig() + if (!config.syncUrl && !config.syncWorkspaceId && !config.syncSecret) { + printResult(t('brain.alreadyDisconnected'), []) + return + } + const { syncUrl: _url, syncWorkspaceId: _id, syncSecret: _secret, ...rest } = config + saveGlobalConfig(rest) + printResult(t('brain.disconnected'), []) + console.log(` ${paint(t('brain.disconnectRevokeReminder'), AMBER)}`) + console.log('') +} + /** `{2h14m}` / `{6m03s}` / `{9s}`: coarsest-first, no leading zero on the coarsest unit. */ function formatUptime(startedAt: string, nowMs: number): string { const elapsedS = Math.max(0, Math.floor((nowMs - Date.parse(startedAt)) / 1000)) @@ -370,11 +398,57 @@ async function brainStop(opts: BrainCommandOptions): Promise { printResult(t('brain.stopped', { pid: pidfile.pid }), []) } +/** + * Writes and enables the systemd --user unit (D-lifecycle): must run inside + * the repo the daemon should serve, same as `brain serve` itself, since that + * repo's top-level path becomes the unit's WorkingDirectory. + */ +async function brainInstallService(opts: BrainCommandOptions): Promise { + const repoRoot = tryGit(['rev-parse', '--show-toplevel'], opts.cwd) + if (!repoRoot) { + throw new Error(t('brain.serviceNotARepo')) + } + const result = installBrainService({ + workingDirectory: repoRoot, + cwd: opts.cwd, + envFile: opts.envFile, + execFn: opts.execFn, + }) + const rows: FieldRow[] = [ + { label: t('brain.fieldUnit'), value: result.unitPath }, + { label: t('brain.fieldWorkingDirectory'), value: result.workingDirectory }, + { label: t('brain.fieldExecStart'), value: result.execStart }, + ] + if (result.environmentFile) { + rows.push({ label: t('brain.fieldEnvironmentFile'), value: result.environmentFile }) + } + printResult(t('brain.serviceInstalled'), rows) + if (result.lingerError) { + console.log(` ${paint(t('brain.lingerFailed', { reason: result.lingerError }), AMBER)}`) + } + console.log('') +} + +/** Idempotent: no unit file on disk is success, the same "nothing to do" doctrine `brainStop` already has for an absent pidfile. */ +async function brainUninstallService(opts: BrainCommandOptions): Promise { + const result = uninstallBrainService({ execFn: opts.execFn }) + if (!result.removed) { + printResult(t('brain.serviceNotInstalled'), []) + return + } + printResult(t('brain.serviceUninstalled'), [ + { label: t('brain.fieldUnit'), value: result.unitPath }, + ]) +} + export async function brainCommand(opts: BrainCommandOptions): Promise { switch (opts.action) { case 'connect': await brainConnect(opts) return + case 'disconnect': + await brainDisconnect() + return case 'status': await brainStatus(opts) return @@ -387,6 +461,12 @@ export async function brainCommand(opts: BrainCommandOptions): Promise { case 'stop': await brainStop(opts) return + case 'install-service': + await brainInstallService(opts) + return + case 'uninstall-service': + await brainUninstallService(opts) + return case undefined: console.log(t('brain.usage')) return diff --git a/packages/cli/src/brain-service.test.ts b/packages/cli/src/brain-service.test.ts new file mode 100644 index 0000000..b11dea1 --- /dev/null +++ b/packages/cli/src/brain-service.test.ts @@ -0,0 +1,248 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + installBrainService, + renderBrainServiceUnit, + systemdUnitPath, + uninstallBrainService, + type ExecCommandFn, +} from './brain-service.js' +import { t } from './i18n.js' + +type Call = { command: string; args: readonly string[] } + +function recordingExecFn(calls: Call[]): ExecCommandFn { + return (command, args) => { + calls.push({ command, args }) + return '' + } +} + +function throwingOn(command: string, calls: Call[]): ExecCommandFn { + return (cmd, args) => { + calls.push({ command: cmd, args }) + if (cmd === command) { + throw Object.assign(new Error(`spawn ${command} ENOENT`), { code: 'ENOENT' }) + } + return '' + } +} + +describe('renderBrainServiceUnit', () => { + test('pins the three per-install directives and keeps the static ones from the shipped template', () => { + const unit = renderBrainServiceUnit({ + workingDirectory: '/home/codesema/bench', + execStart: '/usr/lib/node_modules/codesema/dist/index.mjs brain serve', + environmentFile: null, + }) + expect(unit).toContain('[Unit]') + expect(unit).toContain('[Service]') + expect(unit).toContain('[Install]') + expect(unit).toContain('WorkingDirectory=/home/codesema/bench') + expect(unit).toContain('ExecStart=/usr/lib/node_modules/codesema/dist/index.mjs brain serve') + expect(unit).not.toContain('EnvironmentFile=') + expect(unit).toContain('Description=codesema brain daemon') + expect(unit).toContain('Restart=on-failure') + expect(unit).toContain('RestartSec=5') + expect(unit).toContain('WantedBy=default.target') + }) + + test('includes EnvironmentFile= only when given', () => { + const unit = renderBrainServiceUnit({ + workingDirectory: '/repo', + execStart: '/bin/codesema brain serve', + environmentFile: '/etc/codesema/brain.env', + }) + expect(unit).toContain('EnvironmentFile=/etc/codesema/brain.env') + }) +}) + +describe('installBrainService / uninstallBrainService', () => { + const previousXdg = process.env.XDG_CONFIG_HOME + let xdgConfigHome: string + let cwd: string + + beforeEach(() => { + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-xdg-')) + cwd = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-cwd-')) + process.env.XDG_CONFIG_HOME = xdgConfigHome + }) + + afterEach(() => { + rmSync(xdgConfigHome, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdg + } + }) + + test('systemdUnitPath honors XDG_CONFIG_HOME', () => { + expect(systemdUnitPath()).toBe(join(xdgConfigHome, 'systemd', 'user', 'codesema-brain.service')) + }) + + test('writes the unit, reloads, enables --now, then enables lingering, in that order', () => { + const calls: Call[] = [] + const result = installBrainService({ + workingDirectory: '/repo', + cwd, + execFn: recordingExecFn(calls), + }) + + expect(existsSync(result.unitPath)).toBe(true) + expect(result.workingDirectory).toBe('/repo') + expect(result.execStart).toBe(`${realpathSync(process.argv[1] as string)} brain serve`) + expect(result.environmentFile).toBeNull() + expect(result.lingerError).toBeNull() + + expect(calls).toEqual([ + { command: 'systemctl', args: ['--version'] }, + { command: 'systemctl', args: ['--user', 'daemon-reload'] }, + { command: 'systemctl', args: ['--user', 'enable', '--now', 'codesema-brain.service'] }, + { command: 'loginctl', args: ['enable-linger'] }, + ]) + }) + + test('resolves a relative --env-file against the given cwd, not process.cwd()', () => { + writeFileSync(join(cwd, 'brain.env'), 'GH_TOKEN=x\n') + const result = installBrainService({ + workingDirectory: '/repo', + cwd, + envFile: 'brain.env', + execFn: recordingExecFn([]), + }) + expect(result.environmentFile).toBe(join(cwd, 'brain.env')) + const written = readFileSync(result.unitPath, 'utf8') + expect(written).toContain(`EnvironmentFile=${join(cwd, 'brain.env')}`) + }) + + test('an absolute --env-file is used as-is', () => { + const envFile = join(xdgConfigHome, 'brain.env') + writeFileSync(envFile, 'GH_TOKEN=x\n') + const result = installBrainService({ + workingDirectory: '/repo', + cwd, + envFile, + execFn: recordingExecFn([]), + }) + expect(result.environmentFile).toBe(envFile) + }) + + test('a missing --env-file throws and writes nothing', () => { + expect(() => + installBrainService({ + workingDirectory: '/repo', + cwd, + envFile: 'does-not-exist.env', + execFn: recordingExecFn([]), + }), + ).toThrow() + expect(existsSync(systemdUnitPath())).toBe(false) + }) + + test('no systemctl on the machine: throws a clear error and writes nothing', () => { + const calls: Call[] = [] + expect(() => + installBrainService({ + workingDirectory: '/repo', + cwd, + execFn: throwingOn('systemctl', calls), + }), + ).toThrow(t('brain.systemctlNotFound')) + expect(existsSync(systemdUnitPath())).toBe(false) + expect(calls).toEqual([{ command: 'systemctl', args: ['--version'] }]) + }) + + test('a failing loginctl is reported but does not fail the install (unit still enabled)', () => { + const calls: Call[] = [] + const execFn: ExecCommandFn = (command, args) => { + calls.push({ command, args }) + if (command === 'loginctl') { + throw new Error('Failed to connect to bus: No such file or directory') + } + return '' + } + const result = installBrainService({ workingDirectory: '/repo', cwd, execFn }) + expect(result.lingerError).toBe('Failed to connect to bus: No such file or directory') + expect(existsSync(result.unitPath)).toBe(true) + expect(calls.some((c) => c.command === 'systemctl' && c.args.includes('enable'))).toBe(true) + }) + + test('uninstall with no unit installed: idempotent no-op, no exec calls', () => { + const calls: Call[] = [] + const result = uninstallBrainService({ execFn: recordingExecFn(calls) }) + expect(result).toEqual({ removed: false, unitPath: systemdUnitPath() }) + expect(calls).toEqual([]) + }) + + test('uninstall removes an installed unit: disable --now, delete the file, then daemon-reload', () => { + installBrainService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + const calls: Call[] = [] + const result = uninstallBrainService({ execFn: recordingExecFn(calls) }) + + expect(result.removed).toBe(true) + expect(existsSync(result.unitPath)).toBe(false) + expect(calls).toEqual([ + { command: 'systemctl', args: ['--version'] }, + { command: 'systemctl', args: ['--user', 'disable', '--now', 'codesema-brain.service'] }, + { command: 'systemctl', args: ['--user', 'daemon-reload'] }, + ]) + }) + + test('uninstall of an existing unit with no systemctl: throws and leaves the unit file in place', () => { + installBrainService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + const calls: Call[] = [] + expect(() => uninstallBrainService({ execFn: throwingOn('systemctl', calls) })).toThrow( + t('brain.systemctlNotFound'), + ) + expect(existsSync(systemdUnitPath())).toBe(true) + }) +}) + +describe('directory creation', () => { + const previousXdg = process.env.XDG_CONFIG_HOME + let xdgConfigHome: string + let cwd: string + + beforeEach(() => { + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-mkdir-')) + cwd = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-mkdir-cwd-')) + process.env.XDG_CONFIG_HOME = xdgConfigHome + }) + + afterEach(() => { + rmSync(xdgConfigHome, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdg + } + }) + + test('creates ~/.config/systemd/user when it does not exist yet', () => { + expect(existsSync(join(xdgConfigHome, 'systemd'))).toBe(false) + installBrainService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + expect(existsSync(systemdUnitPath())).toBe(true) + }) + + test('overwrites a unit that already exists (re-running install-service after an upgrade)', () => { + mkdirSync(join(xdgConfigHome, 'systemd', 'user'), { recursive: true }) + writeFileSync(systemdUnitPath(), 'stale content') + installBrainService({ workingDirectory: '/new-repo', cwd, execFn: recordingExecFn([]) }) + const written = readFileSync(systemdUnitPath(), 'utf8') + expect(written).toContain('WorkingDirectory=/new-repo') + expect(written).not.toContain('stale content') + }) +}) diff --git a/packages/cli/src/brain-service.ts b/packages/cli/src/brain-service.ts new file mode 100644 index 0000000..ebd84fd --- /dev/null +++ b/packages/cli/src/brain-service.ts @@ -0,0 +1,198 @@ +// systemd --user lifecycle for the brain daemon: `codesema brain +// install-service` writes ~/.config/systemd/user/codesema-brain.service from +// the unit shipped at assets/systemd/codesema-brain.service — the asset is +// resolved the same way serve.ts resolves its embedded `web-dist` (a URL +// relative to this module's own bundled location) — then pins +// WorkingDirectory/ExecStart to the repo and binary actually running this +// command and enables the unit now. `uninstall-service` reverses it. +// +// Only the three per-install directives (WorkingDirectory, EnvironmentFile, +// ExecStart) are computed here; every other line (Description, After, +// Restart policy, …) is read back out of the shipped template so the +// generated unit never drifts from the one documented in that file. +// +// Logic only, no console output: brain-commands.ts renders the result, the +// same split brain-draft.ts's draftAndPublishTicket has with its own caller. + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { t } from './i18n.js' + +const UNIT_TEMPLATE_PATH = fileURLToPath( + new URL('../assets/systemd/codesema-brain.service', import.meta.url), +) + +const UNIT_NAME = 'codesema-brain.service' + +/** The one `execFileSync` shape actually needed here, pulled out as its own type for the same reason brain-commands.ts's `SpawnFn` is: a test fake has no reason to satisfy the real dozen-overload signature. */ +export type ExecCommandFn = (command: string, args: readonly string[]) => string + +function realExec(command: string, args: readonly string[]): string { + return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function isNotFound(err: unknown): boolean { + return (err as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +function systemdUserDir(): string { + const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config') + return join(base, 'systemd', 'user') +} + +export function systemdUnitPath(): string { + return join(systemdUserDir(), UNIT_NAME) +} + +/** + * Directive lines only, keyed by name: comments, section headers and blank + * lines are dropped. The three per-install directives are looked up here too + * (WorkingDirectory, ExecStart) but the caller always overrides them — + * parsing them regardless keeps this function ignorant of which keys are + * "static" vs "computed", one less thing to keep in sync by hand. + */ +function parseUnitDirectives(templateText: string): Map { + const directives = new Map() + for (const line of templateText.split('\n')) { + const trimmed = line.trim() + if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('[')) { + continue + } + const eq = trimmed.indexOf('=') + if (eq === -1) { + continue + } + directives.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)) + } + return directives +} + +export function renderBrainServiceUnit(input: { + workingDirectory: string + execStart: string + environmentFile: string | null +}): string { + const directives = parseUnitDirectives(readFileSync(UNIT_TEMPLATE_PATH, 'utf8')) + const get = (key: string, fallback: string): string => directives.get(key) ?? fallback + const lines = [ + '# Generated by `codesema brain install-service`; re-run that command after', + '# upgrading codesema instead of hand-editing ExecStart below. Stop with', + '# `systemctl --user stop codesema-brain.service`, never a raw `kill` or', + '# `codesema brain stop`: Restart=on-failure relaunches either.', + '', + '[Unit]', + `Description=${get('Description', 'codesema brain daemon')}`, + `After=${get('After', 'network-online.target')}`, + `Wants=${get('Wants', 'network-online.target')}`, + '', + '[Service]', + `Type=${get('Type', 'simple')}`, + `WorkingDirectory=${input.workingDirectory}`, + ...(input.environmentFile ? [`EnvironmentFile=${input.environmentFile}`] : []), + `ExecStart=${input.execStart}`, + `Restart=${get('Restart', 'on-failure')}`, + `RestartSec=${get('RestartSec', '5')}`, + '', + '[Install]', + `WantedBy=${get('WantedBy', 'default.target')}`, + '', + ] + return lines.join('\n') +} + +function ensureSystemctlAvailable(execFn: ExecCommandFn): void { + try { + execFn('systemctl', ['--version']) + } catch (err) { + if (isNotFound(err)) { + throw new Error(t('brain.systemctlNotFound'), { cause: err }) + } + throw err + } +} + +/** `process.argv[1]`, realpath'd through any symlink/shim — never a bare `codesema` PATH lookup, which a systemd --user unit is not guaranteed to resolve the same way an interactive shell does. */ +function resolveExecStart(): string { + const entry = process.argv[1] + if (entry === undefined) { + throw new Error(t('brain.serviceExecPathUnknown')) + } + return realpathSync(entry) +} + +export type InstallBrainServiceOptions = { + workingDirectory: string + /** Resolves a relative `envFile` — the CLI's own invocation directory, threaded through explicitly rather than read from `process.cwd()` here so this stays a function of its inputs. */ + cwd: string + envFile?: string | undefined + execFn?: ExecCommandFn | undefined +} + +export type InstallBrainServiceResult = { + unitPath: string + workingDirectory: string + execStart: string + environmentFile: string | null + /** Set when `loginctl enable-linger` failed (containers/WSL commonly have no session bus): the unit is still installed and enabled, it just will not survive past the invoking user's session ending. */ + lingerError: string | null +} + +export function installBrainService(opts: InstallBrainServiceOptions): InstallBrainServiceResult { + const execFn = opts.execFn ?? realExec + // Probed before anything is written: a missing systemd must leave no + // half-installed unit file behind. + ensureSystemctlAvailable(execFn) + + const environmentFile = opts.envFile ? resolve(opts.cwd, opts.envFile) : null + if (environmentFile && !existsSync(environmentFile)) { + throw new Error(t('brain.envFileNotFound', { path: environmentFile })) + } + + const execStart = `${resolveExecStart()} brain serve` + const unitPath = systemdUnitPath() + mkdirSync(dirname(unitPath), { recursive: true }) + writeFileSync( + unitPath, + renderBrainServiceUnit({ workingDirectory: opts.workingDirectory, execStart, environmentFile }), + ) + + execFn('systemctl', ['--user', 'daemon-reload']) + execFn('systemctl', ['--user', 'enable', '--now', UNIT_NAME]) + + let lingerError: string | null = null + try { + // No USER argument: `loginctl enable-linger` defaults to the caller's own + // session, which is exactly the account this unit was just installed for. + execFn('loginctl', ['enable-linger']) + } catch (err) { + lingerError = err instanceof Error ? err.message : String(err) + } + + return { + unitPath, + workingDirectory: opts.workingDirectory, + execStart, + environmentFile, + lingerError, + } +} + +export type UninstallBrainServiceResult = { removed: boolean; unitPath: string } + +export function uninstallBrainService(opts: { + execFn?: ExecCommandFn | undefined +}): UninstallBrainServiceResult { + const execFn = opts.execFn ?? realExec + const unitPath = systemdUnitPath() + if (!existsSync(unitPath)) { + return { removed: false, unitPath } + } + ensureSystemctlAvailable(execFn) + execFn('systemctl', ['--user', 'disable', '--now', UNIT_NAME]) + rmSync(unitPath) + execFn('systemctl', ['--user', 'daemon-reload']) + return { removed: true, unitPath } +} diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index f9e6abe..4598159 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -47,6 +47,12 @@ Usage: foreground codesema brain stop Stop a brain daemon started with --detach (or under systemd) for this repo + codesema brain disconnect Forget the connected brain (clears local credentials only — + also revoke this arm in the dashboard's Settings) + codesema brain install-service [--env-file ] + Install a systemd --user unit that runs \`codesema brain serve\` + for this repo, enabled and started now + codesema brain uninstall-service Stop and remove that systemd --user unit Options: --branch Local branch to review (default: interactive picker, else current branch) @@ -69,6 +75,7 @@ Options: --url, --token \`brain connect\`: the brain's URL and a csk_. token --issue \`brain ticket\`: draft from this forge issue number --title, --prompt \`brain ticket\`: draft from a free-form title and prompt instead of an issue + --env-file \`brain install-service\`: EnvironmentFile= for the generated systemd unit -h, --help Show this help -v, --version Show version @@ -369,9 +376,10 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_ 'sync.unreachable': 'could not reach {url}: check your connection or CODESEMA_SYNC_URL', 'sync.badResponse': 'unexpected response from {url}: required fields are missing or invalid', - 'brain.usage': 'usage: codesema brain ', + 'brain.usage': + 'usage: codesema brain ', 'brain.unknownAction': - 'unknown brain action: {action} (expected connect, status, ticket, serve or stop)', + 'unknown brain action: {action} (expected connect, disconnect, status, ticket, serve, stop, install-service or uninstall-service)', 'brain.connectMissingFlags': '`codesema brain connect` needs both --url and --token ', 'brain.badToken': 'malformed token: expected csk_.', @@ -408,6 +416,26 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_ 'brain.heartbeatMinutes': '{n}min ago', 'brain.heartbeatHours': '{n}h ago', 'brain.heartbeatDays': '{n}d ago', + 'brain.disconnected': 'Disconnected from the brain.', + 'brain.alreadyDisconnected': 'Already disconnected.', + 'brain.disconnectRevokeReminder': + "Also revoke this arm in the dashboard's repo Settings — this only cleared local credentials.", + 'brain.serviceNotARepo': + '`codesema brain install-service` must be run inside the git repository this daemon should serve', + 'brain.systemctlNotFound': + 'systemctl not found: this machine has no user systemd session to install into. Run the daemon in the foreground (`codesema brain serve`) or backgrounded (`codesema brain serve --detach`) instead.', + 'brain.envFileNotFound': 'env file not found: {path}', + 'brain.serviceExecPathUnknown': + 'could not determine the path to the running codesema binary (process.argv[1] is empty)', + 'brain.serviceInstalled': 'Brain service installed and started.', + 'brain.serviceUninstalled': 'Brain service stopped and removed.', + 'brain.serviceNotInstalled': 'No brain service installed (nothing to do).', + 'brain.fieldUnit': 'unit', + 'brain.fieldWorkingDirectory': 'working directory', + 'brain.fieldExecStart': 'exec start', + 'brain.fieldEnvironmentFile': 'environment file', + 'brain.lingerFailed': + "could not enable lingering ({reason}): the service will stop when this user's session ends. Common in containers/WSL with no full systemd — run `sudo loginctl enable-linger $(whoami)` yourself if your host supports it.", 'menu.title': 'What do you want to do?', 'menu.review': 'Simple review', @@ -574,6 +602,13 @@ Usage : codesema brain ticket --title --prompt

Rédige et publie un ticket depuis un titre et un prompt libres codesema brain serve Alias de \`codesema workspace --brain\` + codesema brain disconnect Oublie le cerveau connecté (efface seulement les identifiants + locaux, pensez aussi à révoquer ce bras dans les Settings du + dashboard) + codesema brain install-service [--env-file ] + Installe une unité systemd --user qui lance + \`codesema brain serve\` pour ce dépôt, activée et démarrée + codesema brain uninstall-service Arrête et supprime cette unité systemd --user Options : --branch Branche locale à passer en revue (défaut : sélecteur interactif, sinon branche courante) @@ -597,6 +632,7 @@ Options : --url, --token \`brain connect\` : l'URL du cerveau et un jeton csk_. --issue \`brain ticket\` : rédige depuis ce numéro d'issue du forge --title, --prompt \`brain ticket\` : rédige depuis un titre et un prompt libres plutôt qu'une issue + --env-file \`brain install-service\` : EnvironmentFile= de l'unité systemd générée -h, --help Afficher cette aide -v, --version Afficher la version @@ -910,9 +946,10 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver. 'sync.unreachable': 'impossible de joindre {url} : vérifiez votre connexion ou CODESEMA_SYNC_URL', 'sync.badResponse': 'réponse inattendue de {url} : champs requis manquants ou invalides', - 'brain.usage': 'usage : codesema brain ', + 'brain.usage': + 'usage : codesema brain ', 'brain.unknownAction': - 'action brain inconnue : {action} (attendu connect, status, ticket, serve ou stop)', + 'action brain inconnue : {action} (attendu connect, disconnect, status, ticket, serve, stop, install-service ou uninstall-service)', 'brain.connectMissingFlags': '`codesema brain connect` nécessite --url et --token ', 'brain.badToken': 'jeton malformé : format attendu csk_.', 'brain.connected': 'Connecté au cerveau à {url}.', @@ -948,6 +985,26 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver. 'brain.heartbeatMinutes': 'il y a {n}min', 'brain.heartbeatHours': 'il y a {n}h', 'brain.heartbeatDays': 'il y a {n}j', + 'brain.disconnected': 'Déconnecté du cerveau.', + 'brain.alreadyDisconnected': 'Déjà déconnecté.', + 'brain.disconnectRevokeReminder': + "Pensez aussi à révoquer ce bras dans les Settings du dépôt sur le dashboard : ceci n'a effacé que les identifiants locaux.", + 'brain.serviceNotARepo': + '`codesema brain install-service` doit être lancé depuis le dépôt git que ce daemon doit servir', + 'brain.systemctlNotFound': + "systemctl introuvable : cette machine n'a pas de session systemd utilisateur pour y installer le service. Lancez plutôt le daemon au premier plan (`codesema brain serve`) ou en arrière-plan (`codesema brain serve --detach`).", + 'brain.envFileNotFound': 'fichier env introuvable : {path}', + 'brain.serviceExecPathUnknown': + "impossible de déterminer le chemin du binaire codesema en cours d'exécution (process.argv[1] est vide)", + 'brain.serviceInstalled': 'Service brain installé et démarré.', + 'brain.serviceUninstalled': 'Service brain arrêté et supprimé.', + 'brain.serviceNotInstalled': 'Aucun service brain installé (rien à faire).', + 'brain.fieldUnit': 'unité', + 'brain.fieldWorkingDirectory': 'répertoire de travail', + 'brain.fieldExecStart': 'commande de démarrage', + 'brain.fieldEnvironmentFile': 'fichier env', + 'brain.lingerFailed': + "impossible d'activer le lingering ({reason}) : le service s'arrêtera à la fin de la session de cet utilisateur. Fréquent dans les conteneurs/WSL sans systemd complet : lancez vous-même `sudo loginctl enable-linger $(whoami)` si votre hôte le permet.", 'menu.title': 'Que voulez-vous faire ?', 'menu.review': 'Revue simple', diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a690ad9..d86dba6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -50,6 +50,7 @@ type ParsedValues = { title?: string | undefined prompt?: string | undefined detach?: boolean | undefined + 'env-file'?: string | undefined } export const COMMAND_NAMES = [ @@ -220,6 +221,7 @@ async function runCommand( title: values.title, prompt: values.prompt, detach: values.detach, + envFile: values['env-file'], }) break } @@ -250,6 +252,7 @@ async function main(): Promise { title: { type: 'string' }, prompt: { type: 'string' }, detach: { type: 'boolean' }, + 'env-file': { type: 'string' }, }, })