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.
115 lines
4.9 KiB
Bash
115 lines
4.9 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# officer-setup — the install layout
|
|
# =============================================================================
|
|
#
|
|
# Definitions only.
|
|
#
|
|
# ── One root, and nothing configurable underneath it ──
|
|
#
|
|
# $OFFICER_ROOT/
|
|
# platform/ the app — the git checkout
|
|
# data/ DATA_PATH: managed homes, attachments, job logs
|
|
# dockers/ services the app store provisioned
|
|
# capabilities/ the file-based item store — skills, tools, tasks, processes
|
|
#
|
|
# The original asked separately for DATA_PATH and for OFFICER_ITEMS_DIR, and left
|
|
# the app store's directory implicit. Three answers that had to agree with each
|
|
# other, given by somebody with no reason to know they had to.
|
|
#
|
|
# Now one question — where the root goes — and the rest follows. Anybody who wants
|
|
# data/ on a bigger volume can symlink it; that is a decision about storage, not
|
|
# about how Officer is laid out, and it does not need a prompt in a setup script.
|
|
#
|
|
# This is also what the code already assumes. app-store/paths.ts derives
|
|
# OFFICER_ROOT as dirname(DATA_PATH) and DOCKERS_DIR as OFFICER_ROOT/dockers, so
|
|
# setting DATA_PATH to <root>/data is the whole of what makes the layout correct.
|
|
|
|
[[ -n "${OFFICER_SETUP_LAYOUT_LOADED:-}" ]] && return 0
|
|
OFFICER_SETUP_LAYOUT_LOADED=1
|
|
|
|
layout_data_dir() { echo "${OFFICER_ROOT}/data"; }
|
|
layout_dockers_dir() { echo "${OFFICER_ROOT}/dockers"; }
|
|
layout_items_dir() { echo "${OFFICER_ROOT}/capabilities"; }
|
|
|
|
layout_dirs() {
|
|
echo "$OFFICER_ROOT"
|
|
echo "$(layout_data_dir)"
|
|
echo "$(layout_dockers_dir)"
|
|
echo "$(layout_items_dir)"
|
|
}
|
|
|
|
# A file's owning username, portably: GNU stat (Linux) takes `-c %U`, BSD stat (macOS) has
|
|
# no `-c` at all and wants `-f %Su` instead — confirmed live, GNU form exits
|
|
# "illegal option -- c" under macOS's stat. Try GNU first since that is the common case,
|
|
# fall back to BSD rather than branching on an OS variable this file does not otherwise
|
|
# track.
|
|
file_owner() { stat -c %U "$1" 2>/dev/null || stat -f %Su "$1"; }
|
|
|
|
# Created owned by the account, because everything that writes into them runs as
|
|
# the account: the platform under pm2, the app store's compose files, the item
|
|
# store the agent authors into.
|
|
create_layout() {
|
|
local dir
|
|
while read -r dir; do
|
|
[[ -d "$dir" ]] || install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
|
|
done < <(layout_dirs)
|
|
return 0
|
|
}
|
|
|
|
# A directory that exists but belongs to somebody else is the failure this
|
|
# reports: it happens when an earlier run, or a hand-made directory, was created
|
|
# as root, and everything written into it afterwards fails in a way that reads as
|
|
# a permissions bug in the platform.
|
|
layout_wrong_owner() {
|
|
local dir
|
|
while read -r dir; do
|
|
[[ -d "$dir" ]] || continue
|
|
[[ "$(file_owner "$dir")" == "$USERNAME" ]] || echo "$dir"
|
|
done < <(layout_dirs)
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# The capabilities Officer ships
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# `capabilities/` is the file-based item store. The platform creates the six item
|
|
# directories at boot and writes `extensions/tool-loader` from its own source, so
|
|
# those are generated and not tracked anywhere. What IS tracked is the small set
|
|
# the platform ships — today one affordance, telling an agent that markdown
|
|
# images render and that the user's DEVICE fetches the URL.
|
|
#
|
|
# Without this a fresh install has no affordances at all, and every agent on it
|
|
# goes back to guessing what the client on the other end will do.
|
|
#
|
|
# Cloned rather than copied out of the platform repository, so it can be updated
|
|
# on its own — by `git pull` here, or by "Update all" on the marketplace screen.
|
|
CAPABILITIES_REPO="https://gitea.officer.dev/officerdev/capabilities.git"
|
|
|
|
# Three states, because a directory that already exists is the normal one: the
|
|
# layout step created it a moment ago, and a re-run finds a checkout.
|
|
capabilities_sync() {
|
|
local dir; dir="$(layout_items_dir)"
|
|
|
|
if [[ -d "$dir/.git" ]]; then
|
|
# `HEAD` rather than a branch name — it resolves to the remote's default, so standardising the
|
|
# repositories on one name does not break this the way naming `main` did.
|
|
(cd "$dir" && git pull --ff-only --quiet origin HEAD 2>/dev/null) || return 1
|
|
return 0
|
|
fi
|
|
|
|
# An empty directory can be cloned into directly. A non-empty one cannot, and
|
|
# it is not empty precisely because the platform has already run and written
|
|
# its generated directories — so fetch into place instead of refusing, and let
|
|
# the owner's own untracked items sit alongside untouched.
|
|
if [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then
|
|
git clone --quiet "$CAPABILITIES_REPO" "$dir" || return 1
|
|
else
|
|
(cd "$dir" \
|
|
&& git init --quiet \
|
|
&& git remote add origin "$CAPABILITIES_REPO" 2>/dev/null \
|
|
&& git fetch --quiet origin HEAD \
|
|
&& git checkout --quiet -B master FETCH_HEAD) || return 1
|
|
fi
|
|
}
|