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.
139 lines
5.2 KiB
Bash
139 lines
5.2 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# officer-setup — the environment file
|
|
# =============================================================================
|
|
#
|
|
# Definitions only.
|
|
#
|
|
# ── No secrets are written here ──
|
|
#
|
|
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
|
|
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
|
|
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
|
|
#
|
|
# So this file holds no credential except POSTGRES_URL, which is a connection
|
|
# string to a database bound to loopback.
|
|
#
|
|
# ── Derived, not asked ──
|
|
#
|
|
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing
|
|
# replaces them. The platform derives the install root as the parent of its own
|
|
# working directory, so data/, capabilities/ and dockers/ follow from the layout
|
|
# on disk, and the owner's home comes from the OS. They were three environment
|
|
# variables that had to agree with each other and with the directory tree.
|
|
|
|
[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0
|
|
OFFICER_SETUP_ENV_LOADED=1
|
|
|
|
env_file() { echo "$(platform_dir)/.env"; }
|
|
|
|
env_exists() { [[ -f "$(env_file)" ]]; }
|
|
|
|
# One value out of an existing .env, without sourcing it — the file holds
|
|
# secrets and arbitrary shell would run as root.
|
|
env_get() {
|
|
[[ -r "$(env_file)" ]] || return 0
|
|
awk -F= -v k="$1" '
|
|
$1 == k {
|
|
v = substr($0, index($0, "=") + 1)
|
|
gsub(/^"|"$/, "", v)
|
|
print v
|
|
exit
|
|
}' "$(env_file)"
|
|
}
|
|
|
|
write_env() {
|
|
local dest
|
|
dest="$(env_file)"
|
|
|
|
[[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup"
|
|
|
|
# Restrictive from the moment it exists rather than chmod'd afterwards, so the
|
|
# secrets are never briefly world-readable. Restored straight after: umask is
|
|
# not scoped to a function, and leaving it at 077 would quietly make every file
|
|
# a later section creates owner-only.
|
|
local prior_umask
|
|
prior_umask="$(umask)"
|
|
umask 077
|
|
cat >"$dest" <<ENVF
|
|
# Written by officer-setup.
|
|
#
|
|
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
|
|
# holds the token-signing secret and the database credential.
|
|
#
|
|
# EVERY NAME IS PREFIXED \`OFFICERDEV_\`. Bun auto-loads this file into every
|
|
# process started in the platform directory, so a bare PORT or POSTGRES_URL
|
|
# reaches the sidecars, the tools they spawn and anything an agent runs from
|
|
# here — names half the software in the world already uses. Prefixed, they
|
|
# cannot be picked up by accident.
|
|
#
|
|
# Every consumer reads \`OFFICERDEV_X ?? X\`, so an older unprefixed file still
|
|
# works. \`bun run fix:env\` converts one in place.
|
|
|
|
OFFICERDEV_PORT="${ENV_PORT}"
|
|
|
|
# Where Officer is reached from a browser. Not derivable — see the section.
|
|
OFFICERDEV_PUBLIC_URL="${ENV_PUBLIC_URL}"
|
|
|
|
OFFICERDEV_POSTGRES_URL="${POSTGRES_URL}"
|
|
|
|
ENVF
|
|
|
|
umask "$prior_umask"
|
|
|
|
chown "${USERNAME}:$(user_group)" "$dest"
|
|
chmod 600 "$dest"
|
|
return 0
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# A sensible default for PUBLIC_URL
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# localhost is the wrong default on a machine with a tailnet, and quietly so:
|
|
# it works from the machine itself and from nowhere else, so the mistake shows up
|
|
# on the first phone, not during setup.
|
|
#
|
|
# The tailnet is where Officer is actually reached — it is the perimeter the
|
|
# whole security model rests on — so its address is the honest default.
|
|
#
|
|
# The SHORT MagicDNS name — `officer-dev`, not `officer-dev.ts.example.dev` and
|
|
# not the raw 100.x address. All three resolve inside the tailnet; the short one
|
|
# is the one anybody actually types, and PUBLIC_URL ends up baked into the page's
|
|
# OpenGraph tags by `bun gen:index`, so it is read by people as well as machines.
|
|
#
|
|
# It relies on the tailnet's search domain, which every Tailscale client sets when
|
|
# MagicDNS is on. A device that has somehow lost it resolves the FQDN and not the
|
|
# short name — the fix there is to type the longer one, not to default to it.
|
|
#
|
|
# Falls back to localhost when there is no tailnet, which is correct rather than
|
|
# merely tolerable: a machine with no private network has no other address that
|
|
# is any better a guess.
|
|
tailnet_hostname() {
|
|
local dns ip
|
|
# jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD
|
|
# grep at all (confirmed live — errors visibly), which used to leak straight to the
|
|
# terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is
|
|
# already an unconditional dependency elsewhere in this script (proxy.sh), so this is
|
|
# strictly a correctness improvement even setting the portability problem aside.
|
|
dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')"
|
|
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
|
|
dns="${dns%%.*}" # and we want the short name
|
|
if [[ -n "$dns" ]]; then
|
|
echo "$dns"
|
|
return 0
|
|
fi
|
|
ip="$(tailscale ip -4 2>/dev/null | head -1)"
|
|
[[ -n "$ip" ]] && echo "$ip"
|
|
}
|
|
|
|
default_public_url() {
|
|
local host
|
|
host="$(tailnet_hostname)"
|
|
if [[ -n "$host" ]]; then
|
|
echo "http://${host}:${1}"
|
|
else
|
|
echo "http://localhost:${1}"
|
|
fi
|
|
}
|