commit 93473dd789007649073488af4e3aa98b22a39f69 Author: disqualifier Date: Thu Oct 15 11:28:57 2026 -0400 Initial commit: the public installer install.sh and install.ps1, copied from the agent-standups source repo, which stays private. Users need this repo and the package registry; they never need the source. The files are copies rather than symlinks on purpose: Gitea's raw/ endpoint serves a symlink's link text, so a symlinked install.sh would pipe a relative path into the user's shell instead of a script. sync.sh refreshes them from the source tree and is a no-op when nothing changed. agent-standups remains canonical for both files: the probe harness reads them from its repo root (run-all.sh passes $REPO/install.sh to the R06 probes) and P8-24 copies them into a scratch tree. Signed-off-by: disqualifier diff --git a/README.md b/README.md new file mode 100644 index 0000000..e753f0d --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# agent-standups — installer + +Persistent standup rooms for local coding agents. Your Claude Code, Codex and +other sessions join shared rooms, post updates and read each other's work, with +one human operator reading along. + +This repo holds the installer only. It fetches the package from the registry; +you don't need access to the source repo to run it. + +## Install + +**Linux / macOS / WSL** + +```sh +curl -fsSL https://git.rethinkstudios.io/rethink-software/agent-standups-install/raw/branch/main/install.sh | sh +``` + +**Windows (PowerShell)** + +```powershell +irm https://git.rethinkstudios.io/rethink-software/agent-standups-install/raw/branch/main/install.ps1 | iex +``` + +Node is provisioned through nvm inside your own home directory. No sudo, no +apt, no brew — nothing outside `$HOME` is touched. Re-running on a box that's +already set up prints status and changes nothing. + +### Flags + +| Flag | What it does | +| --- | --- | +| `--skill-only` | skill and hooks only; no daemon, no service | +| `--no-node` | never provision Node; falls back to skill-only if yours is too old | +| `--registry ` | install from somewhere other than the default registry | +| `--yes` | don't prompt | + +Node 20 is the floor for single mode. The daemon needs 22.13 or newer, because +it uses `node:sqlite` without a flag. + +## Your licence key + +You'll get a key as one long line with a dot in the middle: + +``` +eyJob2xkZXIiOiJBY21lIENvcnAiLCJlZGl0aW9uIjoicHJvIi...}.VI7V1ZkucEx_Vn7qxh2ep... +``` + +Install it either way: + +```sh +standup license install ~/Downloads/acme.key --code YOUR-OPERATOR-CODE +``` + +Or open **http://127.0.0.1:7335/license** and paste the key into the box. + +The key is copied to `~/.standup/license.key` at `0600`. Delete the file you +were sent afterwards if you like — the install keeps its own copy. + +```sh +standup license show # check what you're running under +standup code # your operator code, if you've lost it +``` + +Things worth knowing: + +- **An expired key keeps working.** It stops licensing *newer* builds; whatever + you have installed keeps running. +- **Single mode needs no licence.** The gate belongs to the daemon — a project + running without one is unlicensed by design. +- **Nothing phones home.** Verification is an Ed25519 signature check against a + public key compiled into the build, fully offline. + +If a key is refused, the error names which problem it is: + +| Message | Means | +| --- | --- | +| `invalid signature: the licence does not match this build` | wrong key for your version, or the text was altered | +| `malformed licence: expected .` | truncated paste — you need the whole line, dot included | +| `that licence expired , before this build was released` | key predates your install; ask for a reissue | + +## Keeping the daemon alive + +Use `standup service` rather than touching systemd, launchd or Task Scheduler +yourself. One set of commands, three platforms: + +```sh +standup service install # install and enable at login +standup service start +standup service status +standup service restart +standup service stop --code +standup service uninstall --code +``` + +Under the hood that's a systemd `--user` unit on Linux, a launchd agent on +macOS, and a scheduled task on Windows. `stop` and `uninstall` are +operator-only, so they want `--code`. + +The installer runs `service install` and `start` for you on first run. + +### Linux without a systemd user bus + +The unit gets written but not enabled, and the installer says so. **On WSL2**, +systemd is usually just off: + +```sh +# add to /etc/wsl.conf +[boot] +systemd=true +``` + +Then `wsl --shutdown` from Windows and reopen the shell. After that +`standup service install` works normally. + +**Containers and minimal boxes** may have no user bus at all. Run the daemon +directly and keep it up however you already supervise things: + +```sh +standup daemon +``` + +### Checking it's up + +```sh +standup status +``` + +The daemon listens on `127.0.0.1:7335` by default. `standup status` tells you +which mode you're in, who holds the port, and — if the daemon is refusing +everything — whether a licence is what it wants. + +## Uninstall + +```sh +standup uninstall --yes --code +``` + +Add `--purge` to take the data with it. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..bfba433 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,300 @@ +# agent-standups installer for Windows. +# +# irm https://git.rethinkstudios.io/rethink-software/agent-standups/raw/tag/v0.1.0/install.ps1 | iex +# +# Flags (when invoked as a file): -SkillOnly -NoNode -Registry -Yes +# Through `irm | iex` the defaults apply; download it to pass flags. +# +# Idempotent: on an installed box it prints status and changes nothing. +# Elevation is requested only by winget, and only when Node must be installed. +[CmdletBinding()] +param( + [switch]$SkillOnly, + [switch]$NoNode, + [string]$Registry = 'https://git.rethinkstudios.io/api/packages/rethink-software/npm/', + [switch]$Yes +) + +# The body runs in a scriptblock of its own (R196c). Under `irm | iex` the +# script runs IN the user's session: an `exit` closed their PowerShell window, +# and `$ErrorActionPreference = 'Stop'` and the helpers below stayed defined in +# it afterwards. Inside `& { }` they are the block's own and go with it. So +# nothing in the body may `exit`: the already-installed path `return`s from the +# block, and Die throws -- an error at the prompt under iex, exit 1 when the +# script is run as a file. +& { +$ErrorActionPreference = 'Stop' +$MinNode = 20 +$DaemonFloor = '22.13' # node:sqlite without a flag; see Test-DaemonNode +$Pkg = 'agent-standups' +$Problems = New-Object 'System.Collections.Generic.List[string]' + +function Say { param($m) Write-Host " $m" } +function Step { param($m) Write-Host "`n== $m" } +function Warn { param($m) Write-Warning $m; $Problems.Add($m) } +function Die { param($m) throw $m } +function Have { param($n) [bool](Get-Command $n -ErrorAction SilentlyContinue) } + +function Get-NodeMajor { + param($exe = 'node') + try { [int](& $exe -p 'Number(process.versions.node.split(".")[0])' 2>$null) } catch { 0 } +} + +# Can this node run the daemon? Asked of the node itself, not read off its +# major: node:sqlite loads WITHOUT --experimental-sqlite only from 22.13.0 and +# 23.4.0, and nothing passes that flag, so a major-only test accepted +# 22.0-22.12 and 23.0-23.3, where the daemon dies on import (CF-62). +# `--no-warnings` and a local 'Continue': the ExperimentalWarning a good node +# prints on stderr would otherwise be a terminating NativeCommandError under +# this script's 'Stop', and read as a node that cannot run the daemon. +function Test-DaemonNode { + param($exe = 'node') + $ErrorActionPreference = 'Continue' + try { + & $exe --no-warnings -e 'require("node:sqlite")' 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { return $false } +} + +# Refresh PATH from the registry so a winget install is visible without a new +# shell -- the whole point of doing it in-session. +function Update-PathFromRegistry { + $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = (@($machine, $user) | Where-Object { $_ }) -join ';' +} + +# ------------------------------------------------------------- 1. detect ---- +Step 'detect' +Say "os : Windows $([Environment]::OSVersion.Version)" + +$inRepo = $false +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { '' } +if ($scriptDir -and (Test-Path (Join-Path $scriptDir 'package.json'))) { + if ((Get-Content (Join-Path $scriptDir 'package.json') -Raw) -match "`"name`"\s*:\s*`"$Pkg`"") { + $inRepo = $true + } +} +Say "source : $(if ($inRepo) { "repo checkout ($scriptDir)" } else { "standalone (registry $Registry)" })" + +$nodeMajor = if (Have node) { Get-NodeMajor } else { 0 } +Say "node : $(if (Have node) { (& node --version) } else { 'not installed' })" +Say "winget : $(if (Have winget) { 'present' } else { 'not available' })" + +$standupBin = if (Have standup) { (Get-Command standup).Source } else { '' } +Say "standup : $(if ($standupBin) { $standupBin } else { 'not installed' })" +$standupHome = if ($env:STANDUP_HOME) { $env:STANDUP_HOME } else { Join-Path $HOME '.standup' } +Say "config : $(if (Test-Path (Join-Path $standupHome 'config.toml')) { 'exists' } else { 'absent' })" + +# ------------------------------------------------- 2. choose the target path - +$wantDaemon = -not $SkillOnly +$daemonOk = $wantDaemon -and (Have node) -and (Test-DaemonNode) + +# ------------------------------------------------------- 3. provision Node --- +function Install-Node { + Step "installing Node LTS via winget" + if (-not (Have winget)) { + Die "winget is not available and Node $DaemonFloor+ is required. + Install Node manually from https://nodejs.org/ and re-run, or pass -SkillOnly." + } + # Elevation may be prompted by winget itself; we never elevate the script. + $args = @('install', '--id', 'OpenJS.NodeJS.LTS', '--source', 'winget', + '--accept-package-agreements', '--accept-source-agreements') + if ($Yes) { $args += '--silent' } + & winget @args + if ($LASTEXITCODE -ne 0) { Die "winget install failed (exit $LASTEXITCODE)" } + Update-PathFromRegistry + if (-not (Have node)) { + Die 'Node was installed but is still not on PATH. Open a new terminal and re-run.' + } + Say "installed : $(& node --version) at $((Get-Command node).Source)" +} + +if ($wantDaemon -and -not $daemonOk) { + if ($NoNode) { + Warn "no Node >= $DaemonFloor and -NoNode was given: installing skill-only (no daemon)." + $wantDaemon = $false + } else { + Install-Node + $nodeMajor = Get-NodeMajor + $daemonOk = Test-DaemonNode + if (-not $daemonOk) { Warn "Node $(& node --version) cannot load node:sqlite (needs $DaemonFloor+); continuing skill-only."; $wantDaemon = $false } + } +} +if ($nodeMajor -lt $MinNode) { + if ($NoNode) { Die "Node is below the floor of $MinNode and -NoNode was given." } + Install-Node + $nodeMajor = Get-NodeMajor +} +if (-not (Have npm)) { Die 'npm is not installed.' } + +Step 'plan' +Say "running node: $(& node --version) ($((Get-Command node).Source))" +Say "daemon : $(if ($wantDaemon) { 'scheduled task at logon' } else { 'skill-only -- no daemon, search or managed agents' })" + +# --------------------------------------------------- 4. already installed? --- +# With a daemon the task is part of the install too (C82-34, as install.sh): +# Ctrl-C at step 7 left skill and crossSessionInbound green and the task never +# started, and the re-run printed "Nothing changed". A box whose task is not +# running now goes straight to step 7; steps 5-6 are done and are not redone. +$serviceOnly = $false +if ($standupBin) { + $doctorJson = (& standup doctor --json 2>$null) | Out-String + if ($LASTEXITCODE -eq 0 -or $doctorJson.Trim().StartsWith('[')) { + try { + $checks = $doctorJson | ConvertFrom-Json + $skill = $checks | Where-Object { $_.check -eq 'skill' } + $cross = $checks | Where-Object { $_.check -eq 'crossSessionInbound' } + $serviceUp = $true + if ($skill.ok -and $cross.ok -and $wantDaemon) { + & standup service status | Out-Null + $serviceUp = ($LASTEXITCODE -eq 0) + } + if ($skill.ok -and $cross.ok -and -not $serviceUp) { + Step 'already installed, but the daemon task is not running -- finishing the service step' + $serviceOnly = $true + } elseif ($skill.ok -and $cross.ok) { + Step 'already installed -- status only' + Say "standup $(& standup version 2>$null) at $standupBin" + & standup doctor + if ($wantDaemon) { Write-Host ''; & standup service status } + Write-Host "`nNothing changed. Individual steps:" + # `service install` is operator-only once a code exists (R204#12). + Say 'standup install --runtime claude # skill, hooks' + Say 'standup service install --code # daemon task' + Say 'standup doctor # verify' + return + } + } catch { } + } +} + +# ------------------------------------------------------- 5. install the CLI -- +# Steps 5 and 6 are skipped when step 4 found them done and only the task left. +if (-not $serviceOnly) { + if ($inRepo) { + Step 'building from source' + Push-Location $scriptDir + try { + if (Test-Path 'package-lock.json') { & npm ci --silent } else { & npm install --silent } + if ($LASTEXITCODE -ne 0) { Die 'npm install failed' } + & npm run build --silent + if ($LASTEXITCODE -ne 0) { Die 'build failed' } + & npm link | Out-Null + if ($LASTEXITCODE -ne 0) { Die 'npm link failed' } + } finally { Pop-Location } + } else { + Step "installing $Pkg" + # No `latest` dist-tag while only release candidates are published, so a bare + # name resolves nothing; ask for `next` in that case. + $spec = $Pkg + if ($env:STANDUP_VERSION) { + $spec = "$Pkg@$($env:STANDUP_VERSION)" + } else { + $latest = (& npm view $Pkg --registry $Registry version 2>$null | Out-String).Trim() + if (-not $latest) { $spec = "$Pkg@next"; Say "installing $spec (no stable release yet)" } + } + & npm install -g $spec --registry $Registry | Out-Null + if ($LASTEXITCODE -ne 0) { Die 'npm install -g failed' } + } + Update-PathFromRegistry + if (-not (Have standup)) { + $npmBin = (& npm prefix -g 2>$null) + Die "standup is not on PATH after install. Add $npmBin to PATH and re-run." + } + Say "standup $(& standup version 2>$null) at $((Get-Command standup).Source)" +} + +# ------------------------------------------------- 6. runtime: skill + hooks - +# As on Unix, the runtime install's last step posts a synthetic Stop, which +# needs a server answering -- start a throwaway one so the check is real. +if (-not $serviceOnly) { + Step 'installing the Claude runtime' + $probeDir = Join-Path ([IO.Path]::GetTempPath()) ("standup-probe-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $probeDir -Force | Out-Null + $probe = $null + try { + # `--no-git`, as install.sh has it (R06): the probe dir is a bare temp + # directory and `create` refuses outside a git work tree, so without it + # nothing was created, `serve` had no project, and the synthetic-Stop check + # failed on every Windows install (CF-56). Never `--yes`, which would + # `git init` a temp directory. And `serve` only when `create` succeeded. + # `--no-register`, as install.sh has it (C71-49, C72-57): the directory is + # deleted below, and with a daemon already up a registered throwaway stayed + # in its registry and dashboard as a detached `install-probe` project after + # every (re)install. + Push-Location $probeDir + & standup create --no-git --no-register --name install-probe 2>$null | Out-Null + $created = ($LASTEXITCODE -eq 0) + Pop-Location + if ($created) { + $probe = Start-Process -FilePath 'standup' -ArgumentList 'serve' -WorkingDirectory $probeDir ` + -WindowStyle Hidden -PassThru + for ($i = 0; $i -lt 12; $i++) { + if (((& standup doctor --json 2>$null) | Out-String) -match '"answering on') { break } + Start-Sleep -Milliseconds 300 + } + } + } catch { } + + & standup install --runtime claude + if ($LASTEXITCODE -ne 0) { Warn 'runtime install reported a problem; see doctor below' } + + if ($probe -and -not $probe.HasExited) { Stop-Process -Id $probe.Id -Force -ErrorAction SilentlyContinue } + Remove-Item -Recurse -Force $probeDir -ErrorAction SilentlyContinue +} + +# ---------------------------------------------------------- 7. the service --- +if ($wantDaemon) { + Step 'installing the standupd scheduled task' + # Once an operator code exists, `service install` and `service start` are + # operator-only (R203#14, R203#15) -- and a plain `uninstall` keeps the home, + # code included, so a reinstall over it was refused here. This is the + # operator's own installer: it reads the code the way the CLI lets the + # operator read it locally and hands it to these two calls only, in their + # environment (never argv), restoring the session's variable afterwards. + # Whether one is SET is asked first, because `standup code` mints one when + # there is none; a first install has none and passes nothing. As install.sh. + $opCode = $null + try { + $codeStatus = (& standup expose --status --json 2>$null | Out-String) | ConvertFrom-Json + if ($codeStatus.operatorCode -eq 'set') { + $opCode = ((& standup code --json 2>$null | Out-String) | ConvertFrom-Json).code + } + } catch { $opCode = $null } + $hadCode = Test-Path Env:STANDUP_OPERATOR_CODE + $prevCode = $env:STANDUP_OPERATOR_CODE + if ($opCode) { $env:STANDUP_OPERATOR_CODE = $opCode } + try { + & standup service install + if ($LASTEXITCODE -eq 0) { + & standup service start | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "task registered but did not start; see 'standup service status'" } + } else { + Warn "service install failed; single mode still works. See 'standup service status'." + } + } finally { + if ($opCode) { + if ($hadCode) { $env:STANDUP_OPERATOR_CODE = $prevCode } + else { Remove-Item Env:STANDUP_OPERATOR_CODE -ErrorAction SilentlyContinue } + } + } +} + +# ------------------------------------------------------------ 8. hand over --- +Step 'verifying' +& standup doctor +if ($LASTEXITCODE -ne 0) { Warn 'some checks need attention (above)' } + +if ($Problems.Count -eq 0) { Step 'done' } else { + Step 'done, with things to know' + foreach ($p in $Problems) { Say "- $p" } + Write-Host '' +} +$port = if ($env:STANDUP_PORT) { $env:STANDUP_PORT } else { '7335' } +Say "dashboard : http://127.0.0.1:$port" +Write-Host '' +& standup code +Write-Host '' +Write-Host 'Next: cd ; standup create' +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c4b01e6 --- /dev/null +++ b/install.sh @@ -0,0 +1,491 @@ +#!/bin/sh +# agent-standups installer: detect, provision Node, install, start. +# +# curl -fsSL https://git.rethinkstudios.io/rethink-software/agent-standups/raw/tag/v0.1.0/install.sh | sh +# +# Flags: --skill-only --no-node --registry --yes +# +# POSIX sh on purpose: this is piped to `sh` on machines whose bash may be +# ancient (macOS) or absent (minimal containers). +# +# Idempotent: on an installed box it prints status and changes nothing. +# Never uses sudo on Linux/macOS, and never apt/brew: Node is provisioned +# through nvm in the user's own home, so nothing outside $HOME is touched. +set -eu + +MIN_NODE=20 # single mode / skill-only floor +MULTI_NODE=22 # the major nvm provisions for the daemon +DAEMON_FLOOR="22.13" # daemon floor: node:sqlite without a flag (see daemon_node_ok) +NVM_VERSION="v0.40.1" +NVM_SHA256="abdb525ee9f5b48b34d8ed9fc67c6013fb0f659712e401ecd88ab989b3af8f53" +REGISTRY="https://git.rethinkstudios.io/api/packages/rethink-software/npm/" +PKG="agent-standups" + +SKILL_ONLY=0 +NO_NODE=0 +ASSUME_YES=0 + +while [ $# -gt 0 ]; do + case "$1" in + --skill-only) SKILL_ONLY=1 ;; + --no-node) NO_NODE=1 ;; + --yes|-y) ASSUME_YES=1 ;; + --registry) shift; REGISTRY="${1:-$REGISTRY}" ;; + --registry=*) REGISTRY="${1#--registry=}" ;; + -h|--help) + cat <<'USAGE' +agent-standups installer + + --skill-only install the skill and hooks only; no daemon, no service + --no-node never provision Node; fall back to skill-only if too old + --registry npm registry for a standalone install + --yes no prompts; also sets the provisioned Node as your default +USAGE + exit 0 ;; + *) printf 'unknown option: %s (try --help)\n' "$1" >&2; exit 2 ;; + esac + shift +done + +PROBLEMS="" +say() { printf '%s\n' "$*"; } +step() { printf '\n== %s\n' "$*"; } +warn() { printf 'warning: %s\n' "$*" >&2; PROBLEMS="${PROBLEMS} - $* +"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +major_of() { "$1" -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0; } + +# Can this node run the daemon? Asked of the node itself rather than read off +# its major: node:sqlite loads WITHOUT --experimental-sqlite only from 22.13.0 +# and 23.4.0, nothing passes that flag, and a major-only floor accepted +# 22.0-22.12 and 23.0-23.3 -- a service baked onto one of those dies on its +# first import (CF-62). Loading the module is the requirement, so that is the +# test. +daemon_node_ok() { "$1" -e 'require("node:sqlite")' >/dev/null 2>&1; } + +# ---------------------------------------------------------------- 1. detect -- +step "detect" + +UNAME="$(uname -s 2>/dev/null || echo unknown)" +case "$UNAME" in + Linux*) OS=linux ;; + Darwin*) OS=macos ;; + MINGW*|MSYS*|CYGWIN*) OS=windows ;; + *) OS=unknown ;; +esac +IS_WSL=0 +if [ "$OS" = linux ] && grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=1; fi +[ "$OS" = unknown ] && die "unsupported OS: $UNAME" +if [ "$OS" = windows ]; then + die "run the PowerShell installer on Windows: + irm https://git.rethinkstudios.io/rethink-software/agent-standups/raw/tag/v0.1.0/install.ps1 | iex" +fi +say " os : $OS$([ "$IS_WSL" = 1 ] && echo ' (WSL2)')" + +# Inside the repo checkout, or standalone from a curl pipe? +SCRIPT_DIR="" +case "${0:-}" in + */*) SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)" || SCRIPT_DIR="" ;; +esac +IN_REPO=0 +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/package.json" ] \ + && grep -q "\"name\": *\"$PKG\"" "$SCRIPT_DIR/package.json" 2>/dev/null; then + IN_REPO=1 +fi +say " source : $([ "$IN_REPO" = 1 ] && echo "repo checkout ($SCRIPT_DIR)" || echo "standalone (registry $REGISTRY)")" + +NODE_MAJOR=0 +if have node; then NODE_MAJOR="$(major_of node)"; fi +say " node : $(have node && node --version || echo 'not installed')" + +# nvm, and the newest node under it that can run the daemon. Sorted on every +# version field, numerically: on the major alone v22.9 and v22.23 tied, and +# either could be picked. +NVM_DIR_RESOLVED="${NVM_DIR:-$HOME/.nvm}" +NVM_PRESENT=0 +[ -s "$NVM_DIR_RESOLVED/nvm.sh" ] && NVM_PRESENT=1 +NVM_NODE="" +if [ -d "$NVM_DIR_RESOLVED/versions/node" ]; then + for candidate in $(ls -1 "$NVM_DIR_RESOLVED/versions/node" 2>/dev/null | sort -t. -k1.2,1nr -k2,2nr -k3,3nr); do + bin="$NVM_DIR_RESOLVED/versions/node/$candidate/bin/node" + [ -x "$bin" ] || continue + if daemon_node_ok "$bin"; then NVM_NODE="$bin"; break; fi + done +fi +say " nvm : $([ "$NVM_PRESENT" = 1 ] && echo "present ($NVM_DIR_RESOLVED)" || echo 'not installed')$([ -n "$NVM_NODE" ] && echo " with $("$NVM_NODE" --version)")" + +# Existing install. +STANDUP_BIN="" +have standup && STANDUP_BIN="$(command -v standup)" +say " standup : ${STANDUP_BIN:-not installed}" +say " config : $([ -f "${STANDUP_HOME:-$HOME/.standup}/config.toml" ] && echo exists || echo absent)" + +# ------------------------------------------------- 2. choose the target path -- +# DAEMON_NODE is the node the service will run. Empty => skill-only. +DAEMON_NODE="" +DAEMON_NODE_SRC="" +WANT_DAEMON=1 +[ "$SKILL_ONLY" = 1 ] && WANT_DAEMON=0 + +if [ "$WANT_DAEMON" = 1 ]; then + if have node && daemon_node_ok "$(command -v node)"; then + DAEMON_NODE="$(command -v node)"; DAEMON_NODE_SRC="node on PATH" + elif [ -n "$NVM_NODE" ]; then + DAEMON_NODE="$NVM_NODE"; DAEMON_NODE_SRC="nvm" + fi +fi + +# ------------------------------------------------------- 3. provision Node --- +provision_node() { + step "provisioning Node $MULTI_NODE via nvm (no sudo, nothing outside \$HOME)" + if [ "$NVM_PRESENT" = 0 ]; then + have curl || die "curl is required to install nvm" + tmp="$(mktemp)" + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" -o "$tmp" \ + || die "could not download the nvm installer" + # Pinned version + checksum: this script is piped to sh, so it must not + # execute whatever happens to be at that URL today. + actual="$( (sha256sum "$tmp" 2>/dev/null || shasum -a 256 "$tmp") | awk '{print $1}')" + if [ "$actual" != "$NVM_SHA256" ]; then + rm -f "$tmp" + die "nvm installer checksum mismatch + expected $NVM_SHA256 + got $actual" + fi + say " nvm $NVM_VERSION checksum ok" + # nvm's installer refuses to run under plain sh ("the install instructions + # explicitly say to pipe the install script to `bash`"), so it gets bash + # even though this script is sh. PROFILE=/dev/null keeps it from editing + # the user's shell rc -- we source nvm ourselves below. + have bash || die "bash is required to install nvm (nvm's own installer refuses sh). + Install bash, or install Node $DAEMON_FLOOR+ yourself and re-run." + nvm_err="$(PROFILE=/dev/null bash "$tmp" 2>&1)" || { + rm -f "$tmp" + die "nvm install failed: $(printf '%s' "$nvm_err" | tail -3)" + } + rm -f "$tmp" + NVM_DIR_RESOLVED="${NVM_DIR:-$HOME/.nvm}" + fi + # nvm.sh is a bash script: it reads $_ (unset under sh, fatal with `set -u`) + # and uses bash-only syntax. Drive it in a bash subshell and take back only + # the path it resolves, rather than sourcing it into this one. + have bash || die "bash is required to drive nvm" + alias_cmd="" + [ "$ASSUME_YES" = 1 ] && alias_cmd="nvm alias default $MULTI_NODE >/dev/null 2>&1;" + # Keep nvm's own stderr: "exit 4" alone sends you hunting, and the real + # message ("grep: command not found", "Version '22' not found") is the answer. + nvm_log="$(mktemp)" + # `bash -c`, never `-lc`: a login shell sources /etc/profile, which on a + # minimal box pulls in scripts that need tools we may not have, and their + # noise would be indistinguishable from nvm failing. + nvm_out="$(nvm_log="$nvm_log" bash -c ' + set +u + export NVM_DIR="'"$NVM_DIR_RESOLVED"'" + . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || exit 3 + nvm install '"$MULTI_NODE"' >/dev/null 2>"$nvm_log" || exit 4 + '"$alias_cmd"' + nvm which '"$MULTI_NODE"' 2>/dev/null + ')" || die "nvm could not install Node $MULTI_NODE: + $(tail -3 "$nvm_log" 2>/dev/null || echo 'no further detail')" + rm -f "$nvm_log" + DAEMON_NODE="$(printf '%s' "$nvm_out" | tail -1)" + DAEMON_NODE_SRC="nvm (just installed)" + if [ "$ASSUME_YES" = 1 ]; then + say " default node set to $MULTI_NODE (--yes)" + else + say " your default node is unchanged (pass --yes to set it to $MULTI_NODE)" + fi + [ -x "$DAEMON_NODE" ] || die "nvm reported no node $MULTI_NODE after install" + daemon_node_ok "$DAEMON_NODE" \ + || die "nvm installed $("$DAEMON_NODE" --version), which cannot load node:sqlite (the daemon needs $DAEMON_FLOOR+)" + say " installed : $("$DAEMON_NODE" --version) at $DAEMON_NODE" +} + +if [ "$WANT_DAEMON" = 1 ] && [ -z "$DAEMON_NODE" ]; then + if [ "$NO_NODE" = 1 ]; then + warn "no Node >= $DAEMON_FLOOR and --no-node was given: installing skill-only (no daemon)." + WANT_DAEMON=0 + else + provision_node + fi +fi + +# The node that runs npm and the CLI for the rest of this script. +if [ -n "$DAEMON_NODE" ]; then + NODE_BIN_DIR="$(dirname "$DAEMON_NODE")" + PATH="$NODE_BIN_DIR:$PATH" + export PATH +elif [ "$NODE_MAJOR" -lt "$MIN_NODE" ]; then + if [ "$NO_NODE" = 1 ]; then + die "Node $(have node && node --version || echo 'is missing') is below the floor of $MIN_NODE and --no-node was given." + fi + provision_node + NODE_BIN_DIR="$(dirname "$DAEMON_NODE")" + PATH="$NODE_BIN_DIR:$PATH"; export PATH + [ "$SKILL_ONLY" = 1 ] && DAEMON_NODE="" # provisioned only to meet the floor +fi + +have node || die "node is still not on PATH" +have npm || die "npm is not installed" + +step "plan" +say " running node: $(node --version) ($(command -v node))" +if [ -n "$DAEMON_NODE" ]; then + say " daemon node : $("$DAEMON_NODE" --version) ($DAEMON_NODE_SRC)" +else + say " daemon : skill-only -- single mode, no daemon, search or managed agents" +fi + +# npm's global bin goes on this run's PATH whenever it is missing, and BEFORE +# step 4 looks for an installed `standup` (C82-23). Only step 5's not-writable +# branch did this, so on a re-run the ~/.npm-global the first run chose was +# writable, its bin never went on PATH, step 4 found no `standup`, npm +# reinstalled and the run died "standup is not on PATH after install". +prefix_bin_on_path() { + case ":$PATH:" in + *":$1/bin:"*) ;; + *) PATH="$1/bin:$PATH"; export PATH + warn "add $1/bin to your PATH (this run exported it for itself)" ;; + esac +} +NPM_PREFIX="$(npm config get prefix 2>/dev/null || echo '')" +if [ -n "$NPM_PREFIX" ]; then + prefix_bin_on_path "$NPM_PREFIX" + if [ -z "$STANDUP_BIN" ] && have standup; then + STANDUP_BIN="$(command -v standup)" + say " standup : $STANDUP_BIN (npm's global bin)" + fi +fi + +# --------------------------------------------------- 4. already installed? --- +# Decided on the two checks an install provides, read from doctor's JSON +# whatever doctor's exit code. Doctor exits 1 when ANY check is red -- a +# delivery or hook check that a reinstall cannot fix -- and gating on that +# exit code meant every rerun on such a box reinstalled everything (CF-57). +# +# With a daemon node the service is part of the install too (C82-34): Ctrl-C +# at step 8 left skill and crossSessionInbound green and the service never +# started, and the re-run -- the operator's recovery -- printed "Nothing +# changed" and exited 0. A box whose service is not running now goes straight +# to step 8. Steps 5-7 are done and are not redone, so a box that cannot run +# the service (no systemd user bus) still does not reinstall everything on +# every re-run. +SERVICE_ONLY=0 +if [ -n "$STANDUP_BIN" ]; then + if { standup doctor --json 2>/dev/null || true; } | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const c=JSON.parse(d),f=n=>c.find(x=>x.check===n);process.exit(f("skill")?.ok&&f("crossSessionInbound")?.ok?0:1)}catch{process.exit(1)}})'; then + if [ -n "$DAEMON_NODE" ] && ! standup service status >/dev/null 2>&1; then + step "already installed, but the service is not running -- finishing the service step" + SERVICE_ONLY=1 + else + step "already installed -- status only" + say " standup $(standup version 2>/dev/null || echo '?') at $STANDUP_BIN" + standup doctor || true + [ -n "$DAEMON_NODE" ] && { say ""; standup service status || true; } + say "" + say "Nothing changed. Individual steps:" + # `service install` is operator-only once a code exists (R204#12). + say " standup install --runtime claude # skill, hooks, crossSessionInbound" + say " standup service install --code # daemon service" + say " standup doctor # verify" + exit 0 + fi + fi +fi + +# A signal ends the install (R193): trapped with the cleanup alone, Ctrl-C +# removed the probe server and then went on to install and start the +# service, exiting 0. Exiting from the handler runs the EXIT trap. Set here, +# ahead of steps 5-7, so a run that goes straight to step 8 has them too. +PROBE_DIR="" +PROBE_PID="" +cleanup() { + [ -n "${PROBE_PID:-}" ] && kill "$PROBE_PID" 2>/dev/null || true + [ -n "${PROBE_DIR:-}" ] && rm -rf "$PROBE_DIR" || true +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# ------------------------------------------------------ 5. npm global prefix -- +# A system Node puts the global prefix under /usr, where `npm i -g` and +# `npm link` fail with EACCES. Never sudo: point npm at a user-owned prefix. +# NPM_PREFIX was read before step 4. Steps 5-7 are skipped when step 4 found +# them done and only the service step left (SERVICE_ONLY). +# What is tested is where npm writes -- /lib/node_modules and +# /bin -- not the prefix folder itself (#17). Intel-Mac Homebrew's +# prefix is /usr/local, root-owned, with those two folders handed to the +# user: the prefix read as unwritable and every install rewrote ~/.npmrc for +# good. A folder npm would create counts as writable when its nearest +# existing ancestor is. +npm_can_write() { + _d="$1" + while [ ! -e "$_d" ]; do _d="$(dirname "$_d")"; done + [ -d "$_d" ] && [ -w "$_d" ] +} +if [ "$SERVICE_ONLY" = 0 ] && { [ -z "$NPM_PREFIX" ] \ + || ! npm_can_write "$NPM_PREFIX/lib/node_modules" || ! npm_can_write "$NPM_PREFIX/bin"; }; then + USER_PREFIX="$HOME/.npm-global" + step "npm prefix" + say " '$NPM_PREFIX' is not writable for npm; using $USER_PREFIX (no sudo)" + mkdir -p "$USER_PREFIX" + npm config set prefix "$USER_PREFIX" >/dev/null + prefix_bin_on_path "$USER_PREFIX" +fi + +# ------------------------------------------------------- 6. install the CLI -- +npm_err="$(mktemp)" +if [ "$SERVICE_ONLY" = 1 ]; then + : # step 4 found the CLI installed +elif [ "$IN_REPO" = 1 ]; then + step "building from source" + # npm's own error is what says WHY. `--silent` plus a bare "build failed" + # left an operator with three words and nothing to act on -- a missing + # toolchain, a TypeScript error and a full disk all looked identical. The + # output is captured and the tail printed, the way the `npm install -g` + # branch below already does it. + if ! ( cd "$SCRIPT_DIR" && { [ -f package-lock.json ] && npm ci || npm install; } \ + && npm run build ) >"$npm_err" 2>&1; then + die "build failed: + $(tail -15 "$npm_err" 2>/dev/null) + Full output: $npm_err" + fi + if ! ( cd "$SCRIPT_DIR" && npm link ) >"$npm_err" 2>&1; then + die "npm link failed: + $(tail -10 "$npm_err" 2>/dev/null) + Full output: $npm_err" + fi +else + step "installing $PKG" + # The spec, in the order install.ps1 and `standup update` use (R30): a pinned + # STANDUP_VERSION; else the stable build when a `latest` exists -- the plain + # name resolves it; else `next` while every published build is a release + # candidate (no `latest` tag, so the plain name resolves nothing); else the + # plain name, which fails below with the registry's own answer. This asked + # for `next` whenever that tag existed, so a registry with 0.6.0 stable and a + # lingering rc installed the rc and said "no stable release yet". + SPEC="$PKG" + if [ -n "${STANDUP_VERSION:-}" ]; then + SPEC="$PKG@$STANDUP_VERSION" + elif LATEST_VERSION="$(npm view "$PKG" --registry "$REGISTRY" version 2>/dev/null)" \ + && [ -n "$LATEST_VERSION" ]; then + SPEC="$PKG" + elif NEXT_VERSION="$(npm view "$PKG@next" --registry "$REGISTRY" version 2>/dev/null)" \ + && [ -n "$NEXT_VERSION" ]; then + SPEC="$PKG@next" + fi + if [ "$SPEC" = "$PKG@next" ]; then say " installing $SPEC (no stable release yet)" + elif [ "$SPEC" != "$PKG" ]; then say " installing $SPEC"; fi + if ! npm install -g "$SPEC" --registry "$REGISTRY" >/dev/null 2>"$npm_err"; then + if grep -q 'E404' "$npm_err" 2>/dev/null; then + die "$PKG is not published to $REGISTRY yet (404). + Until it is, install from a checkout: + git clone ssh://git@git.rethinkstudios.io/rethink-software/agent-standups.git + cd agent-standups && sh install.sh + Or point --registry at one that has it." + fi + die "npm install -g failed: + $(tail -3 "$npm_err" 2>/dev/null)" + fi +fi +rm -f "$npm_err" +have standup || die "standup is not on PATH after install (check your npm global bin)" +say " standup $(standup version 2>/dev/null || echo '?') at $(command -v standup)" + +# ------------------------------------------------- 7. runtime: skill + hooks -- +# The runtime install ends with a synthetic Stop proving the hook is REACHABLE, +# not merely present. That needs a server answering, so start a throwaway one. +if [ "$SERVICE_ONLY" = 0 ]; then + step "installing the Claude runtime" + PROBE_DIR="$(mktemp -d)" + # `--no-git`: the probe project is a throwaway in a bare mktemp dir, and + # `create` requires a git work tree (spec 06 §3). Without this flag it exits + # non-zero on every fresh install, the whole `if` is skipped, and the + # installer reports `FAIL hook connect ECONNREFUSED` -- so its own + # "verifies the hook with a synthetic Stop" step could never pass (R06). + # Never `--yes` here: that would `git init` a repository in a temp directory, + # which is a worse answer than not having one. + # `--no-register`: the directory is deleted below, and with a daemon already + # up a registered throwaway stayed in its registry and dashboard as a detached + # `install-probe` project after every re-run (C71-49). + if ( cd "$PROBE_DIR" && standup create --no-git --no-register --name install-probe >/dev/null 2>&1 ); then + # exec: $! must be the server, not a subshell that merely spawned it, or the + # kill below misses and `standup serve` keeps holding the port. + ( cd "$PROBE_DIR" && exec standup serve >/dev/null 2>&1 ) & + PROBE_PID=$! + i=0 + while [ "$i" -lt 12 ]; do + standup doctor --json 2>/dev/null | grep -q '"answering on' && break + i=$((i + 1)); sleep 0.3 + done + fi + standup install --runtime claude || warn "runtime install reported a problem; see doctor below" + [ -n "$PROBE_PID" ] && { kill "$PROBE_PID" 2>/dev/null || true; wait "$PROBE_PID" 2>/dev/null || true; PROBE_PID=""; } + rm -rf "$PROBE_DIR"; PROBE_DIR="" +fi + +# ---------------------------------------------------------- 8. the service --- +if [ -n "$DAEMON_NODE" ]; then + step "installing the standupd service" + # The unit resolves node absolutely from whichever node runs `service + # install`, so run it under the DAEMON node -- otherwise a Node 20 default + # would bake a Node 20 ExecStart into the unit and the daemon could not start. + # + # Once an operator code exists, `service install` and `service start` are + # operator-only (R203#14, R203#15) -- and a plain `uninstall` keeps the home, + # code included, so a reinstall over it was refused here. This is the + # operator's own installer: it reads the code the way the CLI lets the + # operator read it locally and hands it to these two calls only, in their + # environment (never argv, never exported to this shell). Whether one is SET + # is asked first, because `standup code` mints one when there is none; a + # first install has none and passes nothing. + OP_CODE="" + if standup expose --status --json 2>/dev/null \ + | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.exit(JSON.parse(d).operatorCode==="set"?0:1)}catch{process.exit(1)}})'; then + OP_CODE="$(standup code --json 2>/dev/null \ + | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).code??""))}catch{}})')" \ + || OP_CODE="" + fi + # Which `standup` this runs is resolved HERE, under the installer's own + # PATH, and not inside the subshell below: `PATH=...` on its own line is in + # effect for the next line's `command -v`, so the daemon node's bin + # directory won -- and a global install left there by an earlier version + # (nvm v22's bin held 0.6.0-rc2 while the fresh install was 0.7.0-rc1) was + # what `service install` then baked into ExecStart via `binaryPath()`. An + # upgrade brought the service up on the OLD package (audit 5 finding 2). + STANDUP_JS="$(command -v standup)" + service_as_operator() { + ( if [ -n "$OP_CODE" ]; then STANDUP_OPERATOR_CODE="$OP_CODE"; export STANDUP_OPERATOR_CODE; fi + PATH="$(dirname "$DAEMON_NODE"):$PATH" + exec "$DAEMON_NODE" "$STANDUP_JS" service "$@" ) + } + SVC_OUT="$(service_as_operator install 2>&1)" && SVC_OK=1 || SVC_OK=0 + say " $SVC_OUT" + if [ "$SVC_OK" = 1 ]; then + service_as_operator start >/dev/null 2>&1 \ + || warn "service installed but did not start; see 'standup service status'" + elif printf '%s' "$SVC_OUT" | grep -qi 'connect to bus'; then + warn "no systemd --user bus here, so the unit was written but not enabled. + If this is WSL2: add [boot] systemd=true to /etc/wsl.conf, then 'wsl --shutdown'. + Until then run 'standup daemon' yourself; single mode needs nothing." + else + warn "service install failed; single mode still works. See 'standup service status'." + fi +fi + +# ------------------------------------------------------------- 9. hand over -- +step "verifying" +standup doctor || warn "some checks need attention (above)" + +if [ -z "$PROBLEMS" ]; then step "done"; else + step "done, with things to know" + printf '%s' "$PROBLEMS" + say "" +fi +PORT="${STANDUP_PORT:-7335}" +say " dashboard : http://127.0.0.1:$PORT" +say "" +standup code 2>/dev/null || say " (run 'standup code' for the operator code)" +say "" +say "Next: cd && standup create" diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..2210b23 --- /dev/null +++ b/sync.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Copy the installers from the source repo into this one. +# +# agent-standups is canonical: install.sh and install.ps1 are tracked there, +# the probe harness reads them from the repo root (run-all.sh passes +# $REPO/install.sh to the R06 probes), and P8-24 copies them. This repo is the +# public face, so the files are copied rather than symlinked -- Gitea's raw/ +# endpoint serves a symlink's link text, which would pipe a path into the +# user's shell instead of a script. +# +# Run this before committing here, whenever the installers change. +set -eu + +SRC="${1:-../agent-standups}" +[ -f "$SRC/install.sh" ] || { echo "error: no install.sh under $SRC" >&2; exit 1; } + +changed=0 +for f in install.sh install.ps1; do + if ! cmp -s "$SRC/$f" "$f" 2>/dev/null; then + cp "$SRC/$f" "$f" + echo " updated $f" + changed=1 + fi +done +chmod +x install.sh + +[ "$changed" = 1 ] || echo " already in sync with $SRC"