Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.
The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.
Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.
Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:
- grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
dependency elsewhere in this script, so that's a correctness fix, not just a port.
- ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
enough for NPM's container to reach) — both silently mis-reported on macOS, since the
"command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
- getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
its full 30-minute timeout regardless of whether the record actually resolved. macOS
branch uses dscacheutil.
- stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
option -- c"), and the failure was VISIBLE and made every layout directory report as
wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
- apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
installers (one literally named install_update_linux.sh, the other hardcoding
"_Linux_" into the release asset URL) — each gets a brew branch, formula names
verified against the live Homebrew catalog rather than assumed.
- sudo -u "$USERNAME" to become the SAME account the process already is — real on
Linux, where this whole script runs as root and dropping to the owner needs no
password; on macOS the process never escalates in the first place, so re-invoking
sudo for yourself asks for a real password on every call, or fails outright without
sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
setup's own as_owner() (already the shared entry point there) got the same branch,
and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
were rerouted through it too.
Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).
Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.
Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
143 lines
6.5 KiB
Bash
143 lines
6.5 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# officer-setup — the pm2 ecosystem file, and starting the processes
|
|
# =============================================================================
|
|
#
|
|
# Definitions only.
|
|
#
|
|
# ── The ecosystem file is GENERATED, and is not in git ──
|
|
#
|
|
# There used to be four of them — ecosystem.config.cjs, .light., .mac.light. and
|
|
# a .profile. that the others derived from. A profile deriving from a full list
|
|
# means the full list has to exist, which means every plugin's process is
|
|
# described in the repository whether or not anybody installed it, and a test had
|
|
# to assert that the two files still agreed with each other.
|
|
#
|
|
# One generated file removes all of that. It describes exactly the processes this
|
|
# install runs, it is written once at setup, and nothing in git can drift from
|
|
# it. A plugin adds its own entry when it is installed.
|
|
#
|
|
# ── Why .cjs and not .js ──
|
|
#
|
|
# PM2's own convention is ecosystem.config.js, and it would be wrong here:
|
|
# package.json declares "type": "module", so a .js file in this directory is ESM
|
|
# and `module.exports` throws "module is not defined in ES module scope". PM2
|
|
# require()s the config, so the extension has to say CommonJS out loud.
|
|
|
|
[[ -n "${OFFICER_SETUP_SERVICES_LOADED:-}" ]] && return 0
|
|
OFFICER_SETUP_SERVICES_LOADED=1
|
|
|
|
ecosystem_file() { echo "$(platform_dir)/ecosystem.config.cjs"; }
|
|
|
|
# The processes a core install runs. Everything else is a plugin.
|
|
#
|
|
# `officer-pty` is node rather than bun, and that is not an oversight: it loads
|
|
# node-pty, a native module built against Node's ABI. Everything else is bun.
|
|
#
|
|
# `officer-claude-code` was `officer-agent` until 2026-08-13. The old name said
|
|
# nothing about what it runs, and it sits beside officer-anthropic-proxy — which
|
|
# is a different process doing a different job — so "the agent" was ambiguous
|
|
# exactly where it mattered. It spawns `claude`; the name says so now.
|
|
CORE_PROCESSES=(
|
|
"officer|bun|start"
|
|
"officer-anthropic-proxy|bun|run src/servers/sidecar/claude/index.ts"
|
|
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
|
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
|
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
|
# Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry.
|
|
#
|
|
# It was a Docker service because the app store made everything one, not because Radicale needs a
|
|
# container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal
|
|
# server having a calendar is not an integration with something you already run; it is the baseline.
|
|
# Radicale needs `python3` and the `radicale` package, both installed by machine-setup.
|
|
"officer-caldav|bun|run src/servers/sidecar/caldav/index.ts"
|
|
# Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with
|
|
# memos and photos when it was slated to become a plugin — the only one of the three never extracted,
|
|
# so it sat mounted nowhere with its schema commented out.
|
|
#
|
|
# Core rather than a plugin because the producers are the platform's own: the job queue, an agent turn
|
|
# ending, mail arriving. A notifier the owner can uninstall is one those producers cannot rely on.
|
|
# It boots with no credentials at all — every channel reports `isConfigured() === false` and dispatch
|
|
# resolves empty — so an install that never sets an APNs key runs it as a no-op rather than crash-looping.
|
|
"officer-notify|bun|run src/servers/sidecar/notify/index.ts"
|
|
)
|
|
|
|
write_ecosystem() {
|
|
local dest entry name script args
|
|
dest="$(ecosystem_file)"
|
|
|
|
{
|
|
cat <<'HEADER'
|
|
// Generated by officer-setup. Not in git, and not meant to be — it describes THIS
|
|
// install, and the next machine generates its own.
|
|
//
|
|
// `cwd` is pinned on every app for two reasons. Bun auto-loads .env from the
|
|
// working directory (and the pty sidecar does `import 'dotenv/config'`), so
|
|
// without it a process started from anywhere else comes up with no POSTGRES_URL.
|
|
// And src/servers/data-path.ts derives the install root as the PARENT of the
|
|
// working directory, so a wrong cwd does not fail — it relocates data/,
|
|
// capabilities/ and dockers/ somewhere else entirely. `assertInstallLayout`
|
|
// refuses to boot when that happens.
|
|
//
|
|
// To add a plugin later, add its entry here. Nothing derives this file from
|
|
// anything, so there is no second list to keep it agreeing with.
|
|
|
|
module.exports = {
|
|
apps: [
|
|
HEADER
|
|
for entry in "${CORE_PROCESSES[@]}"; do
|
|
IFS='|' read -r name script args <<<"$entry"
|
|
printf " { name: '%s', script: '%s', args: '%s', cwd: '%s', watch: false },\n" \
|
|
"$name" "$script" "$args" "$(platform_dir)"
|
|
done
|
|
cat <<'FOOTER'
|
|
],
|
|
};
|
|
FOOTER
|
|
} >"$dest"
|
|
|
|
chown "${USERNAME}:$(user_group)" "$dest"
|
|
return 0
|
|
}
|
|
|
|
pm2_start() {
|
|
as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
|
}
|
|
|
|
pm2_save() { as_owner "pm2 save" 2>&1; }
|
|
|
|
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
|
|
# doing it — so this runs what it prints, which is the whole point of already
|
|
# being root here.
|
|
#
|
|
# `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument
|
|
# has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd
|
|
# unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo
|
|
# command"). That is real, separate work rather than a one-line swap, so this skips with a
|
|
# stated reason on macOS instead of pretending the systemd path could ever produce anything
|
|
# usable there — matches the same call this codebase already made for the Tailscale exit-node
|
|
# NIC tuning and the proxy's systemd docker-ordering fix.
|
|
pm2_enable_startup() {
|
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet"
|
|
return 1
|
|
fi
|
|
local cmd
|
|
cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
|
[[ -z "$cmd" ]] && return 1
|
|
eval "${cmd#sudo }"
|
|
}
|
|
|
|
# One line per process: name, status, restarts.
|
|
pm2_status_lines() {
|
|
as_owner "pm2 jlist" 2>/dev/null |
|
|
node -e '
|
|
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
|
let apps = []; try { apps = JSON.parse(s); } catch { }
|
|
for (const a of apps) {
|
|
const st = a.pm2_env?.status ?? "?";
|
|
console.log(`${a.name}|${st}|${a.pm2_env?.restart_time ?? 0}`);
|
|
}
|
|
});'
|
|
}
|