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 <dev@disqualifier.me>
This commit is contained in:
dsql committed 2026-10-15 11:28:57 -04:00
commit 93473dd789
4 files changed
+956

No files matched your search

+138
View File
@@ -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 <url>` | 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 <payload>.<signature>` | truncated paste — you need the whole line, dot included |
| `that licence expired <date>, 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 <operator code>
standup service uninstall --code <operator 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 <operator code>
```
Add `--purge` to take the data with it.
+300
View File
@@ -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 <url> -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 <operator 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 <your project>; standup create'
}
Executable
+491
View File
@@ -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 <url> --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 <url> 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 <operator 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 -- <prefix>/lib/node_modules and
# <prefix>/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 <your project> && standup create"
Executable
+27
View File
@@ -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"