move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index, prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree). The move is not just a rename. Three of these derive the repo root from their own location: setup.sh:51 PROJECT_DIR="$(dirname "$SCRIPT_DIR")" setup_mac_light.sh:51 same cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env" Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the real file. All three are now `../..` with a comment saying why the level matters. provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught. Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both in the same paragraph. The host one is now spelled with its full path at those sites. Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under DRY_RUN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Officer Remote Desktop teardown — the inverse of setup-desktop.sh.
|
||||
#
|
||||
# WHAT THIS DOES AND DOES NOT REMOVE, and why the distinction matters:
|
||||
#
|
||||
# Officer used to install a desktop of its OWN — XFCE on a TigerVNC Xvnc server — so tearing the
|
||||
# remote desktop down meant deleting a desktop environment nobody else was using. That is no longer
|
||||
# the model. Officer now MIRRORS the machine's existing physical session with x11vnc, which means
|
||||
# ubuntu-desktop, gdm3 and dbus-x11 are the machine's own desktop, not Officer's. Removing them here
|
||||
# would take the user's graphical session with it.
|
||||
#
|
||||
# So this script removes Officer's *configuration of* the desktop and the mirroring pieces, and
|
||||
# leaves the desktop itself alone:
|
||||
#
|
||||
# removed x11vnc, ~/.vnc, the GDM auto-login + forced-Xorg keys, the forced EDID and its kernel
|
||||
# command line, the login-time mode setter, the legacy officer-vnc systemd unit, and the
|
||||
# VNC entries in .env
|
||||
# kept ubuntu-desktop, gdm3, dbus-x11 — the machine's desktop
|
||||
# legacy XFCE and TigerVNC are purged if present, since a host set up by an older version of
|
||||
# setup-desktop.sh still carries them and they are exactly what this is undoing
|
||||
#
|
||||
# Brave is only removed with --purge-brave. setup-desktop.sh installs it, so removing it is the
|
||||
# symmetric thing to do, but by the time anyone runs this it is usually just the user's browser with
|
||||
# their profile in it. Opt in rather than surprise someone.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/setup/cleanup-desktop.sh [--purge-brave]
|
||||
#
|
||||
# Reboot afterwards: the GDM and GRUB changes are both boot-time.
|
||||
|
||||
PURGE_BRAVE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge-brave) PURGE_BRAVE=true ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=== Officer Remote Desktop teardown ==="
|
||||
echo ""
|
||||
|
||||
# --- Step 1: stop the mirror and remove x11vnc ---
|
||||
echo "[1/7] Stopping the screen mirror..."
|
||||
# The VNC sidecar spawns x11vnc; kill the server itself rather than the sidecar, which PM2 owns.
|
||||
pkill -x x11vnc 2>/dev/null || true
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge x11vnc >/dev/null 2>&1 || true
|
||||
rm -rf ~/.vnc
|
||||
echo " x11vnc removed, ~/.vnc deleted."
|
||||
|
||||
# --- Step 2: revert the GDM session configuration ---
|
||||
# setup-desktop.sh forces Xorg (x11vnc cannot capture Wayland) and enables auto-login (so a user
|
||||
# session owns the display for the mirror to attach to). Both are Officer's doing, so both go back.
|
||||
echo "[2/7] Reverting GDM auto-login and the forced Xorg session..."
|
||||
GDM_CONF=/etc/gdm3/custom.conf
|
||||
if [ -f "$GDM_CONF" ]; then
|
||||
sudo cp "$GDM_CONF" "$GDM_CONF.bak-$(date +%Y%m%d%H%M%S)"
|
||||
# Auto-login off, and drop the username line entirely rather than leave a dangling account name.
|
||||
sudo sed -i 's|^[[:space:]]*AutomaticLoginEnable=.*|AutomaticLoginEnable=false|' "$GDM_CONF"
|
||||
sudo sed -i '/^[[:space:]]*AutomaticLogin=/d' "$GDM_CONF"
|
||||
# Comment the Xorg forcing so the distro default (Wayland on Ubuntu) applies again.
|
||||
sudo sed -i 's|^[[:space:]]*WaylandEnable=false.*|#WaylandEnable=false|' "$GDM_CONF"
|
||||
echo " Auto-login disabled; Wayland restored to the distro default."
|
||||
echo " ! The next boot will use Wayland. Nothing can mirror it — that is the point of this script,"
|
||||
echo " but if you want the screen back later, re-run setup-desktop.sh."
|
||||
else
|
||||
echo " No $GDM_CONF — skipping."
|
||||
fi
|
||||
|
||||
# --- Step 3: drop the forced EDID from the kernel command line ---
|
||||
echo "[3/7] Removing the headless display forcing..."
|
||||
if grep -q "drm.edid_firmware=" /etc/default/grub 2>/dev/null; then
|
||||
sudo cp /etc/default/grub "/etc/default/grub.bak-$(date +%Y%m%d%H%M%S)"
|
||||
# Strip both parameters, then collapse the double space they leave behind.
|
||||
sudo sed -i -E 's/[[:space:]]*drm\.edid_firmware=[^" ]*//g; s/[[:space:]]*video=[^" ]*//g' /etc/default/grub
|
||||
sudo sed -i -E 's/GRUB_CMDLINE_LINUX_DEFAULT="[[:space:]]+/GRUB_CMDLINE_LINUX_DEFAULT="/; s/[[:space:]]+"$/"/' /etc/default/grub
|
||||
sudo update-grub >/dev/null 2>&1
|
||||
echo " Kernel command line cleaned and GRUB regenerated (takes effect on reboot)."
|
||||
else
|
||||
echo " No forced EDID in GRUB — skipping."
|
||||
fi
|
||||
sudo rm -f /lib/firmware/edid/officer-screen.bin /lib/firmware/edid/officer-connector
|
||||
# Only remove the directory if Officer's files were the only thing in it.
|
||||
sudo rmdir /lib/firmware/edid 2>/dev/null || true
|
||||
|
||||
# --- Step 4: remove the login-time mode setter ---
|
||||
echo "[4/7] Removing the display mode setter..."
|
||||
rm -f ~/.local/bin/officer-set-display.sh ~/.config/autostart/officer-set-display.desktop
|
||||
echo " Done."
|
||||
|
||||
# --- Step 5: legacy XFCE / TigerVNC residue ---
|
||||
# Only relevant on a host set up by the pre-mirror version of setup-desktop.sh. Harmless elsewhere.
|
||||
echo "[5/7] Removing legacy XFCE / TigerVNC residue..."
|
||||
# Anchored and spelled out on purpose. The tempting shortening to '^libxf' also matches libxfixes,
|
||||
# libxft and libxfont — core X11 libraries GNOME needs — so each xfce-family prefix is named instead.
|
||||
XFCE_PKGS=$(dpkg-query -W -f='${Package}\n' 2>/dev/null | grep -E '^(xfce|xfdesktop|xfwm|xfconf|thunar|libxfce|libxfconf|libgarcon|libexo|exo-utils|tumbler|elementary-xfce)' || true)
|
||||
if [ -n "$XFCE_PKGS" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge $XFCE_PKGS >/dev/null 2>&1 || true
|
||||
echo " Purged $(echo "$XFCE_PKGS" | wc -l) XFCE package(s)."
|
||||
else
|
||||
echo " No XFCE packages."
|
||||
fi
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge \
|
||||
tigervnc-standalone-server tigervnc-common tigervnc-tools >/dev/null 2>&1 || true
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get autoremove -y --purge >/dev/null 2>&1 || true
|
||||
rm -rf ~/.config/xfce4 ~/.cache/xfce4 ~/.local/share/xfce4 ~/.config/Thunar ~/.cache/sessions ~/.dmrc.bak-xfce
|
||||
# The pre-mirror setup ran x11vnc/Xvnc from a systemd unit rather than the sidecar.
|
||||
if systemctl list-unit-files officer-vnc.service &>/dev/null; then
|
||||
sudo systemctl stop officer-vnc 2>/dev/null || true
|
||||
sudo systemctl disable officer-vnc 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/officer-vnc.service
|
||||
sudo systemctl daemon-reload
|
||||
echo " Removed the legacy officer-vnc systemd unit."
|
||||
fi
|
||||
|
||||
# --- Step 6: Brave (opt-in) ---
|
||||
echo "[6/7] Browser..."
|
||||
if [ "$PURGE_BRAVE" = true ]; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge brave-browser >/dev/null 2>&1 || true
|
||||
sudo rm -f /usr/share/keyrings/brave-browser-archive-keyring.gpg \
|
||||
/etc/apt/sources.list.d/brave-browser-release.list \
|
||||
/usr/bin/brave-browser-stable
|
||||
sudo rm -rf /etc/brave
|
||||
rm -rf ~/.config/BraveSoftware
|
||||
echo " Brave removed, including its profile."
|
||||
else
|
||||
echo " Brave kept (pass --purge-brave to remove it and its profile)."
|
||||
fi
|
||||
|
||||
# --- Step 7: .env ---
|
||||
echo "[7/7] Cleaning .env..."
|
||||
# ../.. — this lives in scripts/setup/. A wrong level here is quiet in the other direction: the sed
|
||||
# below simply finds no file, reports "No .env" and leaves the real VNC_PASSWORD in place.
|
||||
ENV_FILE="$(cd "$(dirname "$0")/../.." && pwd)/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
sed -i '/^VNC_PASSWORD=/d; /^VNC_PORT=/d' "$ENV_FILE"
|
||||
echo " Removed VNC entries."
|
||||
else
|
||||
echo " No .env — skipping."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Teardown complete. ubuntu-desktop, gdm3 and dbus-x11 were left in place — they are this"
|
||||
echo "machine's desktop, not Officer's. The app code is untouched: the VNC sidecar keeps running under"
|
||||
echo "PM2, but with x11vnc gone it can no longer start a mirror, so opening /desktop will fail to"
|
||||
echo "connect rather than take the app down with it."
|
||||
echo "REBOOT to drop auto-login and the forced EDID."
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pick the largest sane mode on whichever output is connected, at session start.
|
||||
#
|
||||
# With no monitor attached the connector's EDID comes from drm.edid_firmware (see the kernel command
|
||||
# line). That EDID advertises plenty of modes but its *preferred* one is the captured screen's native
|
||||
# resolution, which may be tiny — GNOME picks preferred, so an 800x480 panel yields an 800x480
|
||||
# desktop. monitors.xml is the documented override but its monitor matching proved unreliable here,
|
||||
# so set the mode directly. No-ops when the mode is already right, so it is safe to run repeatedly.
|
||||
set -eu
|
||||
|
||||
MAX_WIDTH="${OFFICER_MAX_WIDTH:-1920}"
|
||||
|
||||
# The session's X server may not be up yet when autostart fires.
|
||||
for _ in $(seq 1 20); do
|
||||
xrandr --query >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
xrandr --query >/dev/null 2>&1 || exit 0
|
||||
|
||||
output=$(xrandr --query | awk '/ connected/ { print $1; exit }')
|
||||
[ -n "$output" ] || exit 0
|
||||
|
||||
# Modes are listed under the output but are not reliably sorted, so pick the widest that fits.
|
||||
mode=$(
|
||||
xrandr --query \
|
||||
| sed -n "/^${output} connected/,/^[^ ]/p" \
|
||||
| awk '/^[[:space:]]+[0-9]+x[0-9]+/ { print $1 }' \
|
||||
| awk -F x -v max="$MAX_WIDTH" '
|
||||
{ w = $1 + 0; h = $2 + 0 }
|
||||
w <= max && (w > bw || (w == bw && h > bh)) { bw = w; bh = h; best = $0 }
|
||||
END { if (best) print best }'
|
||||
)
|
||||
[ -n "$mode" ] || exit 0
|
||||
|
||||
current=$(xrandr --query | awk -v o="$output" '$1 == o { print $3 }' | cut -d+ -f1)
|
||||
[ "$current" = "$mode" ] && exit 0
|
||||
|
||||
xrandr --output "$output" --mode "$mode" || true
|
||||
@@ -0,0 +1,57 @@
|
||||
// Create the per-user root under DATA_PATH for the given accounts.
|
||||
//
|
||||
// bun scripts/setup/provision-user-dirs.ts a@b.com c@d.com
|
||||
// DRY_RUN=1 bun scripts/setup/provision-user-dirs.ts a@b.com
|
||||
//
|
||||
// Takes emails as arguments rather than reading the user table: the directory layout does not depend
|
||||
// on the database, and keeping the DB out means this runs with nothing else up. At invite time the
|
||||
// caller already knows the email.
|
||||
//
|
||||
// This is the directory skeleton ONLY. It is deliberately not a revival of the old provision.ts, which
|
||||
// also seeded shell rc files and wrote a generated CLAUDE.md + settings.json describing the user's
|
||||
// Docker container. That architecture is gone. What survives from it is the useful part: a user has a
|
||||
// root, and a home inside it.
|
||||
//
|
||||
// Idempotent — an existing directory is left exactly as it is, so re-running is safe.
|
||||
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
// The list and DATA_PATH itself come from the platform rather than being restated here. The owner's
|
||||
// create-account handler provisions the same skeleton, and a script that drifted from it would produce
|
||||
// accounts that differ by how they were made. Importing data-path.ts pulls in no database and no server.
|
||||
import { DATA_PATH, USER_DIRS } from '../../src/servers/data-path';
|
||||
|
||||
const DRY_RUN = process.env.DRY_RUN === '1';
|
||||
const emails = process.argv.slice(2).filter(Boolean);
|
||||
|
||||
if (!emails.length) {
|
||||
console.error('Usage: bun scripts/setup/provision-user-dirs.ts <email> [email...]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// A stray argument would create a junk directory next to real user roots, and it would look like a
|
||||
// user. Cheap to refuse.
|
||||
const invalid = emails.filter((e) => !/^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(e));
|
||||
if (invalid.length) {
|
||||
console.error(`Not valid email addresses: ${invalid.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`DATA_PATH = ${DATA_PATH}`);
|
||||
console.log(`${DRY_RUN ? 'Would provision' : 'Provisioning'} ${emails.length} user root(s)\n`);
|
||||
|
||||
for (const email of emails) {
|
||||
const root = join(DATA_PATH, email);
|
||||
console.log(`${email}${existsSync(root) ? '' : ' [new root]'}`);
|
||||
|
||||
for (const dir of USER_DIRS) {
|
||||
const path = join(root, dir);
|
||||
if (existsSync(path)) {
|
||||
console.log(` · ${dir} (exists)`);
|
||||
continue;
|
||||
}
|
||||
if (!DRY_RUN) mkdirSync(path, { recursive: true });
|
||||
console.log(` ${DRY_RUN ? '+' : '✓'} ${dir}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Officer Remote Desktop Setup — Ubuntu GNOME desktop on Xorg, mirrored over VNC.
|
||||
#
|
||||
# The platform mirrors the single physical display :0 with x11vnc (see vnc-manager.ts). x11vnc can only
|
||||
# capture an Xorg server, NOT a Wayland compositor — so we install the full GNOME desktop but force GDM
|
||||
# onto the Xorg session (WaylandEnable=false). Auto-login is enabled so a user session owns :0 for the
|
||||
# mirror to attach to. Switching the display manager takes effect on the next reboot.
|
||||
# Usage: ./scripts/setup/setup-desktop.sh
|
||||
|
||||
echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ==="
|
||||
echo ""
|
||||
|
||||
DESKTOP_USER="$(whoami)"
|
||||
|
||||
# --- Step 1: Install GNOME desktop + GDM + x11vnc ---
|
||||
echo "[1/6] Installing ubuntu-desktop, GDM, x11vnc..."
|
||||
sudo apt update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt install -y -qq \
|
||||
ubuntu-desktop \
|
||||
gdm3 \
|
||||
x11vnc \
|
||||
dbus-x11
|
||||
echo " Done."
|
||||
# No tigervnc package is needed. The VNC sidecar writes the .vnc/passwd rfbauth file with
|
||||
# `x11vnc -storepasswd` (see vnc-manager.ts). tigervnc-common was installed here for `vncpasswd`,
|
||||
# but it does not ship that binary — it only provides tigervncconfig — so the rfbauth file was never
|
||||
# created and the desktop could not authenticate.
|
||||
|
||||
# --- Step 2: Force GDM onto Xorg + enable auto-login (x11vnc cannot mirror Wayland) ---
|
||||
echo "[2/6] Forcing Xorg session and auto-login in GDM..."
|
||||
GDM_CONF=/etc/gdm3/custom.conf
|
||||
sudo mkdir -p /etc/gdm3
|
||||
[ -f "$GDM_CONF" ] || echo "[daemon]" | sudo tee "$GDM_CONF" > /dev/null
|
||||
# Ensure a [daemon] section exists to anchor the keys under.
|
||||
sudo grep -qE '^\[daemon\]' "$GDM_CONF" || echo "[daemon]" | sudo tee -a "$GDM_CONF" > /dev/null
|
||||
|
||||
# Set key=value under [daemon]: rewrite an existing (possibly commented) line, else insert after [daemon].
|
||||
set_gdm_key() {
|
||||
local key="$1" val="$2"
|
||||
if sudo grep -qE "^[[:space:]]*#?[[:space:]]*${key}=" "$GDM_CONF"; then
|
||||
sudo sed -i "s|^[[:space:]]*#\?[[:space:]]*${key}=.*|${key}=${val}|" "$GDM_CONF"
|
||||
else
|
||||
sudo sed -i "/^\[daemon\]/a ${key}=${val}" "$GDM_CONF"
|
||||
fi
|
||||
}
|
||||
|
||||
set_gdm_key WaylandEnable false
|
||||
set_gdm_key AutomaticLoginEnable true
|
||||
set_gdm_key AutomaticLogin "$DESKTOP_USER"
|
||||
echo " Xorg forced (WaylandEnable=false), auto-login as $DESKTOP_USER."
|
||||
|
||||
# --- Step 3: Make GDM the default display manager ---
|
||||
echo "[3/6] Setting GDM as the default display manager..."
|
||||
echo "/usr/sbin/gdm3" | sudo tee /etc/X11/default-display-manager > /dev/null
|
||||
sudo systemctl enable gdm3 >/dev/null 2>&1 || sudo systemctl enable gdm >/dev/null 2>&1 || true
|
||||
sudo systemctl set-default graphical.target >/dev/null 2>&1 || true
|
||||
# Disable any prior display manager so it doesn't fight GDM. lightdm is named because earlier versions
|
||||
# of this script installed XFCE; a host set up back then still has it and it would win the boot race.
|
||||
sudo systemctl disable lightdm >/dev/null 2>&1 || true
|
||||
echo " Done (takes effect on next reboot)."
|
||||
|
||||
# --- Step 4: Install Brave browser (native .deb, not snap) ---
|
||||
echo "[4/6] Installing Brave browser..."
|
||||
if ! command -v brave-browser-stable > /dev/null 2>&1; then
|
||||
sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg \
|
||||
https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/brave-browser-release.list > /dev/null
|
||||
sudo apt update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt install -y -qq brave-browser
|
||||
fi
|
||||
# Fix launcher symlink
|
||||
if [ -f /opt/brave.com/brave/brave-browser ]; then
|
||||
sudo rm -f /usr/bin/brave-browser-stable
|
||||
sudo ln -s /opt/brave.com/brave/brave-browser /usr/bin/brave-browser-stable
|
||||
fi
|
||||
# Tell Brave to use basic password store (no keyring prompts)
|
||||
sudo mkdir -p /etc/brave
|
||||
echo '--password-store=basic' | sudo tee /etc/brave/brave-flags.conf > /dev/null
|
||||
echo " Done."
|
||||
|
||||
# --- Step 5: Remove GNOME Keyring + set default browser (prevents password prompts on login) ---
|
||||
echo "[5/6] Removing GNOME Keyring and setting default browser..."
|
||||
sudo apt remove -y --purge gnome-keyring > /dev/null 2>&1 || true
|
||||
rm -rf ~/.local/share/keyrings
|
||||
if command -v brave-browser-stable > /dev/null 2>&1; then
|
||||
sudo update-alternatives --set x-www-browser /opt/brave.com/brave/brave 2>/dev/null || true
|
||||
echo " Brave set as default."
|
||||
else
|
||||
echo " No supported browser found, skipping default."
|
||||
fi
|
||||
|
||||
# --- Step 6: Headless display (keep a desktop when no monitor is attached) ---
|
||||
echo "[6/6] Configuring the headless display..."
|
||||
|
||||
# x11vnc mirrors :0, but with nothing plugged in the connector has no EDID and no CRTC, so GNOME
|
||||
# renders nothing and the mirror is black. Replaying a real monitor's EDID makes the connector look
|
||||
# permanently attached. The EDID has to be captured from a screen that is plugged in *now* — there is
|
||||
# nothing to copy otherwise — so this step is skipped on a headless run and can be re-run later.
|
||||
EDID_DIR=/lib/firmware/edid
|
||||
EDID_FILE="$EDID_DIR/officer-screen.bin"
|
||||
CONNECTED_SYSFS=""
|
||||
for c in /sys/class/drm/card*-*/status; do
|
||||
[ "$(cat "$c" 2>/dev/null)" = "connected" ] || continue
|
||||
CONNECTED_SYSFS="$(basename "$(dirname "$c")")"
|
||||
break
|
||||
done
|
||||
|
||||
if [ -z "$CONNECTED_SYSFS" ] && [ ! -f "$EDID_FILE" ]; then
|
||||
echo " ! No display connected and no EDID saved — skipping."
|
||||
echo " Plug a monitor in and re-run this script to capture one, otherwise the remote"
|
||||
echo " desktop will be black whenever nothing is attached."
|
||||
else
|
||||
# cardN-HDMI-A-1 -> HDMI-A-1, which is the name the kernel parameters use (card numbering can
|
||||
# change between boots; the connector name does not).
|
||||
if [ -n "$CONNECTED_SYSFS" ]; then
|
||||
CONNECTOR="${CONNECTED_SYSFS#*-}"
|
||||
sudo mkdir -p "$EDID_DIR"
|
||||
sudo cp "/sys/class/drm/$CONNECTED_SYSFS/edid" "$EDID_FILE"
|
||||
echo " Captured EDID from $CONNECTOR ($(stat -c%s "$EDID_FILE") bytes)."
|
||||
echo "$CONNECTOR" | sudo tee "$EDID_DIR/officer-connector" > /dev/null
|
||||
else
|
||||
CONNECTOR="$(cat "$EDID_DIR/officer-connector" 2>/dev/null || echo HDMI-A-1)"
|
||||
echo " Reusing the saved EDID for $CONNECTOR."
|
||||
fi
|
||||
|
||||
# drm.edid_firmware makes the connector report that EDID; the trailing "e" on video= forces it
|
||||
# enabled with nothing attached. amdgpu is not in the initramfs, so /lib/firmware is readable by
|
||||
# the time the driver loads and no initramfs rebuild is needed.
|
||||
KERNEL_ARGS="drm.edid_firmware=$CONNECTOR:edid/officer-screen.bin video=$CONNECTOR:1920x1080e"
|
||||
if grep -q "drm.edid_firmware=" /etc/default/grub; then
|
||||
echo " GRUB already carries a forced EDID — leaving it alone."
|
||||
else
|
||||
sudo cp /etc/default/grub "/etc/default/grub.bak-$(date +%Y%m%d%H%M%S)"
|
||||
CURRENT=$(grep "^GRUB_CMDLINE_LINUX_DEFAULT=" /etc/default/grub | sed 's/^[^"]*"//; s/"$//')
|
||||
sudo sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"${CURRENT:+$CURRENT }$KERNEL_ARGS\"|" /etc/default/grub
|
||||
sudo update-grub > /dev/null 2>&1
|
||||
echo " Added the forced EDID to the kernel command line (takes effect on reboot)."
|
||||
fi
|
||||
|
||||
# The EDID's preferred mode is whatever the captured panel was, which may be small. GNOME picks
|
||||
# preferred, so raise it at session start.
|
||||
install -d "$HOME/.local/bin" "$HOME/.config/autostart"
|
||||
install -m 755 "$(dirname "$0")/officer-set-display.sh" "$HOME/.local/bin/officer-set-display.sh"
|
||||
cat > "$HOME/.config/autostart/officer-set-display.desktop" <<AUTOSTART
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Officer display mode
|
||||
Comment=Raise the headless display to its largest usable mode at login
|
||||
Exec=$HOME/.local/bin/officer-set-display.sh
|
||||
X-GNOME-Autostart-enabled=true
|
||||
NoDisplay=true
|
||||
AUTOSTART
|
||||
echo " Installed the login-time mode setter."
|
||||
fi
|
||||
echo " Done."
|
||||
|
||||
# --- Cleanup old systemd service if it exists ---
|
||||
if systemctl list-unit-files officer-vnc.service &>/dev/null; then
|
||||
echo ""
|
||||
echo "Removing old officer-vnc systemd service..."
|
||||
sudo systemctl stop officer-vnc 2>/dev/null || true
|
||||
sudo systemctl disable officer-vnc 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/officer-vnc.service
|
||||
sudo systemctl daemon-reload
|
||||
echo " Done."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Setup complete. The screen mirror (x11vnc on :0) is managed by the VNC sidecar."
|
||||
echo "REBOOT to switch into the GNOME-on-Xorg session with auto-login."
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/bin/bash
|
||||
# Officer — Docker Compose service setup
|
||||
# Sets up Docker Compose services (Nginx Proxy Manager, PostgreSQL, Mailhog, Redis, SearXNG).
|
||||
# Outputs parseable key=value lines to stdout; all prompts go to stderr.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/setup/setup-dockers.sh
|
||||
# eval "$(bash scripts/setup/setup-dockers.sh)"
|
||||
#
|
||||
# Environment overrides:
|
||||
# SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none")
|
||||
# SETUP_DOCKER_NETWORK="services" — docker network name
|
||||
|
||||
set -e
|
||||
|
||||
# Resolve the real user's home even when running under sudo
|
||||
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||
REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
|
||||
else
|
||||
REAL_HOME="$HOME"
|
||||
fi
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}::${NC} $*" >&2; }
|
||||
ok() { echo -e " ${GREEN}✓${NC} $1" >&2; }
|
||||
warn() { echo -e " ${YELLOW}!${NC} $1" >&2; }
|
||||
fail() { echo -e " ${RED}✗${NC} $1" >&2; }
|
||||
|
||||
prompt_value() {
|
||||
local varname="$1" prompt="$2" default="$3"
|
||||
if [[ -n "$default" ]]; then
|
||||
echo -en " ${BOLD}${prompt}${NC} [${default}]: " >&2
|
||||
else
|
||||
echo -en " ${BOLD}${prompt}${NC}: " >&2
|
||||
fi
|
||||
read -r val
|
||||
if [[ -z "$val" ]]; then val="$default"; fi
|
||||
eval "$varname=\"\$val\""
|
||||
}
|
||||
|
||||
# ─── docker network ─────────────────────────────────────────────────────────
|
||||
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
|
||||
|
||||
# Ensure network exists
|
||||
if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then
|
||||
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
|
||||
ok "Created docker network '$DOCKER_NETWORK'"
|
||||
else
|
||||
ok "Docker network '$DOCKER_NETWORK' exists"
|
||||
fi
|
||||
|
||||
# ─── service selection ───────────────────────────────────────────────────────
|
||||
SERVICES="${SETUP_DOCKER_SERVICES:-}"
|
||||
|
||||
if [[ "$SERVICES" == "none" ]]; then
|
||||
info "Skipping Docker Compose services (SETUP_DOCKER_SERVICES=none)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "$SERVICES" ]]; then
|
||||
info "Select Docker Compose services to include:"
|
||||
echo " [1] Nginx Proxy Manager (jc21/nginx-proxy-manager:latest)" >&2
|
||||
echo " [2] PostgreSQL 18 (postgres:18-alpine)" >&2
|
||||
echo " [3] Mailhog (mailhog/mailhog:latest)" >&2
|
||||
echo " [4] Redis (redis:alpine)" >&2
|
||||
echo " [5] SearXNG (searxng/searxng:latest)" >&2
|
||||
|
||||
prompt_value SERVICES "Enter numbers separated by spaces (e.g. '1 2 4'), or 'all'" "all"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICES" == "all" ]]; then
|
||||
SERVICES="1 2 3 4 5"
|
||||
fi
|
||||
|
||||
# ─── compose directory ──────────────────────────────────────────────────────
|
||||
prompt_value COMPOSE_DIR "Docker Compose directory" "$REAL_HOME/.local/dockers"
|
||||
mkdir -p "$COMPOSE_DIR"
|
||||
|
||||
# ─── build compose file ─────────────────────────────────────────────────────
|
||||
cat > "$COMPOSE_DIR/docker-compose.yaml" <<'HEADER'
|
||||
services:
|
||||
HEADER
|
||||
|
||||
COMPOSE_SERVICES=()
|
||||
PG_PASSWORD=""
|
||||
MAILHOG_SELECTED=false
|
||||
|
||||
for svc in $SERVICES; do
|
||||
case "$svc" in
|
||||
1)
|
||||
COMPOSE_SERVICES+=("nginx-proxy-manager")
|
||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
||||
nginx-proxy-manager:
|
||||
image: jc21/nginx-proxy-manager:latest
|
||||
container_name: nginx-proxy-manager
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "81:81"
|
||||
volumes:
|
||||
- ./npm_data:/data
|
||||
- ./npm_letsencrypt:/etc/letsencrypt
|
||||
networks:
|
||||
- $DOCKER_NETWORK
|
||||
|
||||
SVC
|
||||
;;
|
||||
2)
|
||||
COMPOSE_SERVICES+=("postgres")
|
||||
PG_DEFAULT_PASS=$(openssl rand -base64 36 | tr -d '/+=' | head -c 36)
|
||||
prompt_value PG_PASSWORD "PostgreSQL password" "$PG_DEFAULT_PASS"
|
||||
prompt_value PG_DATABASE "PostgreSQL database name" "officer_dev"
|
||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
||||
postgres:
|
||||
image: postgres:18.3-alpine
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: $PG_PASSWORD
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- ./postgres_data:/var/lib/postgresql/data
|
||||
- ./db_dumps:/db_dumps
|
||||
networks:
|
||||
- $DOCKER_NETWORK
|
||||
|
||||
SVC
|
||||
;;
|
||||
3)
|
||||
COMPOSE_SERVICES+=("mailhog")
|
||||
MAILHOG_SELECTED=true
|
||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: mailhog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:1025:1025"
|
||||
- "127.0.0.1:8025:8025"
|
||||
networks:
|
||||
- $DOCKER_NETWORK
|
||||
|
||||
SVC
|
||||
;;
|
||||
4)
|
||||
COMPOSE_SERVICES+=("redis")
|
||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- ./redis_data:/data
|
||||
networks:
|
||||
- $DOCKER_NETWORK
|
||||
|
||||
SVC
|
||||
;;
|
||||
5)
|
||||
COMPOSE_SERVICES+=("searxng")
|
||||
mkdir -p "$COMPOSE_DIR/searxng"
|
||||
SEARXNG_SECRET=$(openssl rand -hex 32)
|
||||
cat > "$COMPOSE_DIR/searxng/settings.yml" <<SEARXCFG
|
||||
use_default_settings: true
|
||||
|
||||
server:
|
||||
secret_key: "$SEARXNG_SECRET"
|
||||
limiter: false
|
||||
|
||||
search:
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
SEARXCFG
|
||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: searxng
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- ./searxng:/etc/searxng
|
||||
environment:
|
||||
SEARXNG_BASE_URL: http://localhost:8080/
|
||||
networks:
|
||||
- $DOCKER_NETWORK
|
||||
|
||||
SVC
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Add networks block
|
||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<FOOTER
|
||||
networks:
|
||||
$DOCKER_NETWORK:
|
||||
external: true
|
||||
FOOTER
|
||||
|
||||
# ─── start services ─────────────────────────────────────────────────────────
|
||||
info "Starting docker compose services..."
|
||||
(cd "$COMPOSE_DIR" && docker compose up -d) >&2
|
||||
|
||||
ok "Docker services: ${COMPOSE_SERVICES[*]}"
|
||||
|
||||
# ─── create database if PostgreSQL was selected ─────────────────────────────
|
||||
if [[ -n "$PG_PASSWORD" ]]; then
|
||||
info "Waiting for PostgreSQL to be ready..."
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then
|
||||
# Create the database if it doesn't exist
|
||||
docker exec postgres psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$PG_DATABASE'" | grep -q 1 \
|
||||
|| docker exec postgres psql -U postgres -c "CREATE DATABASE $PG_DATABASE" >/dev/null 2>&1
|
||||
ok "Database '$PG_DATABASE' ready"
|
||||
else
|
||||
warn "PostgreSQL not ready after 15s — create database manually"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── output parseable values to stdout ───────────────────────────────────────
|
||||
echo "COMPOSE_DIR=$COMPOSE_DIR"
|
||||
|
||||
if [[ -n "$PG_PASSWORD" ]]; then
|
||||
echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}"
|
||||
fi
|
||||
|
||||
if [[ "$MAILHOG_SELECTED" == true ]]; then
|
||||
echo "MAIL_TRANSPORT=smtp://127.0.0.1:1025"
|
||||
fi
|
||||
Executable
+1196
File diff suppressed because it is too large
Load Diff
Executable
+539
@@ -0,0 +1,539 @@
|
||||
#!/bin/bash
|
||||
# Officer — macOS laptop setup.
|
||||
#
|
||||
# The barebones counterpart to scripts/setup/setup.sh (which targets an Ubuntu/Debian server and is left
|
||||
# alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat,
|
||||
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
|
||||
# no sudoers grant, no power-management changes.
|
||||
#
|
||||
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively:
|
||||
#
|
||||
# SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup/setup_mac_light.sh
|
||||
#
|
||||
# SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
|
||||
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI
|
||||
# SETUP_LINK_NODE brew link --force node@22 SETUP_DEPS bun install
|
||||
# SETUP_PM2 pm2 via npm SETUP_ENV write .env
|
||||
# SETUP_INIT gen:index + db:push SETUP_SERVICES pm2 startOrRestart
|
||||
#
|
||||
# Accepted values: 1/y/yes/true to run, anything else to skip. Unset = ask (or take the default when
|
||||
# stdin is not a terminal).
|
||||
#
|
||||
# This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note
|
||||
# that Homebrew's own installer does ask for an administrator password on a fresh Mac.
|
||||
#
|
||||
# Usage: bash scripts/setup/setup_mac_light.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||
warn() { echo -e " ${YELLOW}!${NC} $1"; }
|
||||
fail() { echo -e " ${RED}✗${NC} $1"; }
|
||||
skip() { echo -e " - $1"; }
|
||||
step() { echo ""; echo -e "${BOLD}── $1 ──${NC}"; }
|
||||
|
||||
has() { command -v "$1" &>/dev/null; }
|
||||
|
||||
# `set -e` is on, so anything allowed to fail must be guarded explicitly — either inside an `if`, or
|
||||
# with a trailing `|| true`. Note the classic trap this file avoids everywhere: a bare top-level
|
||||
# `cmd_a && cmd_b` list returns non-zero when cmd_a fails, which aborts the script. Those are all
|
||||
# written as `if cmd_a; then cmd_b; fi` instead.
|
||||
FAILURES=()
|
||||
note_failure() { FAILURES+=("$1"); fail "$1"; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# ../.. — this lives in scripts/setup/. See the note in setup.sh: PROJECT_DIR is where .env is written
|
||||
# and where bun install, gen:index, db:push and pm2 are pointed, and none of them fails loudly on the
|
||||
# wrong directory.
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
PG_FORMULA="postgresql@18"
|
||||
PG_DATABASE="officer_dev"
|
||||
NODE_FORMULA="node@22"
|
||||
ENV_FILE="$PROJECT_DIR/.env"
|
||||
ECOSYSTEM="$PROJECT_DIR/ecosystem.mac.light.config.cjs"
|
||||
|
||||
# Ask, unless the matching SETUP_* variable already decided. $1 = variable name, $2 = prompt,
|
||||
# $3 = default (y|n) used for a bare Enter and for non-interactive runs.
|
||||
confirm() {
|
||||
local var="$1" prompt="$2" default="$3" preset reply hint
|
||||
preset="${!var:-}"
|
||||
if [ -n "$preset" ]; then
|
||||
case "$preset" in
|
||||
1|y|Y|yes|YES|true) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ ! -t 0 ]; then
|
||||
if [ "$default" = "y" ]; then return 0; else return 1; fi
|
||||
fi
|
||||
hint="[y/N]"
|
||||
if [ "$default" = "y" ]; then hint="[Y/n]"; fi
|
||||
echo -en " ${BOLD}${prompt}${NC} ${hint}: "
|
||||
reply=""
|
||||
read -r reply || true
|
||||
reply="${reply:-$default}"
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Prompt for a value with a default. Never fails the script.
|
||||
ask() {
|
||||
local __var="$1" prompt="$2" default="$3" reply=""
|
||||
if [ ! -t 0 ]; then
|
||||
printf -v "$__var" '%s' "$default"
|
||||
return 0
|
||||
fi
|
||||
echo -en " ${BOLD}${prompt}${NC} [${default}]: "
|
||||
read -r reply || true
|
||||
printf -v "$__var" '%s' "${reply:-$default}"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo " Officer — macOS setup"
|
||||
echo "═══════════════════════════════════════════"
|
||||
|
||||
# ─── 0. preflight ─────────────────────────────────────────────────────────────
|
||||
step "Preflight"
|
||||
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
fail "This script is macOS-only. On Linux use scripts/setup/setup.sh."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Running under sudo would install bun/claude/opencode into /var/root and write HOME_DIR=/var/root
|
||||
# into .env. Nothing here needs root except the symlinks, which prompt individually.
|
||||
if [ "$(id -u)" = "0" ] || [ -n "${SUDO_USER:-}" ]; then
|
||||
fail "Do not run this under sudo — run it as your normal user."
|
||||
exit 1
|
||||
fi
|
||||
ok "macOS $(sw_vers -productVersion), running as $(whoami)"
|
||||
|
||||
# Xcode Command Line Tools. Not needed to compile anything: the only two native modules, node-pty and
|
||||
# argon2, both ship darwin-arm64/darwin-x64 prebuilds and fall back to node-gyp only when a prebuild
|
||||
# is missing for the running arch. (node-pty has no Linux prebuild — that is why setup.sh needs
|
||||
# build-essential and this one does not.)
|
||||
#
|
||||
# They still matter on a fresh Mac, because `git` comes from them and the file browser shells out to
|
||||
# it. Installing Homebrew below pulls the CLT in, so this is a note, not a gate.
|
||||
if xcode-select -p &>/dev/null; then
|
||||
ok "Xcode command line tools ($(xcode-select -p))"
|
||||
else
|
||||
warn "Xcode command line tools not found — Homebrew's installer will pull them in"
|
||||
echo " Standalone: xcode-select --install (also what a fresh Mac needs for git)"
|
||||
fi
|
||||
|
||||
if has brew; then
|
||||
ok "homebrew ($(brew --prefix))"
|
||||
elif confirm SETUP_BREW "Homebrew is not installed. Install it?" y; then
|
||||
if /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then
|
||||
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
|
||||
if [ -x "$candidate" ]; then
|
||||
eval "$("$candidate" shellenv)" || true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if has brew; then ok "homebrew installed"; else note_failure "homebrew install failed"; fi
|
||||
else
|
||||
skip "homebrew (declined — brew-based steps will be skipped)"
|
||||
fi
|
||||
|
||||
# macOS ships zip, unzip, curl, python3 and openssl (LibreSSL) in the base system — none are
|
||||
# installed here.
|
||||
for builtin_tool in zip unzip curl openssl nc; do
|
||||
if ! has "$builtin_tool"; then warn "$builtin_tool not found (expected to ship with macOS)"; fi
|
||||
done
|
||||
|
||||
# git needs its own check: /usr/bin/git is a stub on a Mac without the Command Line Tools, so
|
||||
# `command -v git` succeeds while running it only pops the "install developer tools" dialog. The file
|
||||
# browser shells out to git, so verify it actually executes. Invoking it here is deliberate — on a
|
||||
# fresh Mac it triggers that install prompt early, rather than at first use inside the app.
|
||||
if git --version &>/dev/null; then
|
||||
ok "git ($(git --version 2>/dev/null || true))"
|
||||
else
|
||||
warn "git is not usable yet — accept the Command Line Tools prompt, then re-run this script"
|
||||
fi
|
||||
|
||||
# ─── 1. homebrew packages ─────────────────────────────────────────────────────
|
||||
step "Packages"
|
||||
|
||||
NODE_PREFIX=""
|
||||
brew_install() {
|
||||
local formula="$1" probe="$2"
|
||||
if [ -n "$probe" ] && has "$probe"; then
|
||||
skip "$formula (already present: $(command -v "$probe"))"
|
||||
return 0
|
||||
fi
|
||||
if brew list --formula "$formula" &>/dev/null; then
|
||||
skip "$formula (already installed)"
|
||||
return 0
|
||||
fi
|
||||
echo " Installing $formula..."
|
||||
if brew install "$formula"; then ok "$formula installed"; else note_failure "$formula install failed"; fi
|
||||
}
|
||||
|
||||
if ! has brew; then
|
||||
skip "packages (homebrew unavailable)"
|
||||
elif confirm SETUP_PACKAGES "Install node@22, bun and ffmpeg via Homebrew?" y; then
|
||||
# node@22 exactly — package.json's preinstall rejects anything else (`v < 22 || v > 22`), so plain
|
||||
# `brew install node` (currently v24+) would break `bun install`.
|
||||
brew_install "$NODE_FORMULA" ""
|
||||
brew_install bun bun
|
||||
# ffmpeg/ffprobe: the only external binaries the file browser shells out to besides git and zip.
|
||||
brew_install ffmpeg ffmpeg
|
||||
else
|
||||
skip "packages (declined)"
|
||||
fi
|
||||
|
||||
if has brew; then
|
||||
NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
# ─── 2. node on PATH ──────────────────────────────────────────────────────────
|
||||
step "Node on PATH"
|
||||
|
||||
# node@22 is keg-only, so Homebrew does not put it on PATH. `brew link --force` symlinks it into the
|
||||
# Homebrew prefix (user-owned, already on PATH) — no sudo, and nothing lands in /usr/local.
|
||||
if ! has brew; then
|
||||
skip "node link (homebrew unavailable)"
|
||||
elif [ -z "$NODE_PREFIX" ] || [ ! -x "$NODE_PREFIX/bin/node" ]; then
|
||||
skip "node link ($NODE_FORMULA not installed)"
|
||||
elif [ "$(command -v node 2>/dev/null || true)" = "$NODE_PREFIX/bin/node" ]; then
|
||||
skip "node already resolves to $NODE_FORMULA"
|
||||
elif confirm SETUP_LINK_NODE "Link $NODE_FORMULA onto PATH (brew link --force)?" y; then
|
||||
if brew link --force --overwrite "$NODE_FORMULA" >/dev/null 2>&1; then
|
||||
ok "$NODE_FORMULA linked into $(brew --prefix)/bin"
|
||||
else
|
||||
note_failure "brew link $NODE_FORMULA failed — add $NODE_PREFIX/bin to PATH manually"
|
||||
fi
|
||||
else
|
||||
skip "node link (declined)"
|
||||
fi
|
||||
|
||||
# Verify node really is 22 — `bun install` fails its preinstall check otherwise.
|
||||
if has node; then
|
||||
NODE_MAJOR="$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//' || true)"
|
||||
if [ "$NODE_MAJOR" = "22" ]; then
|
||||
ok "node $(node -v)"
|
||||
else
|
||||
note_failure "node $(node -v) is on PATH but package.json requires exactly v22"
|
||||
fi
|
||||
else
|
||||
warn "node not on PATH"
|
||||
fi
|
||||
|
||||
# ─── 3. postgres ──────────────────────────────────────────────────────────────
|
||||
step "PostgreSQL"
|
||||
|
||||
# Detect a reachable server first — this machine may already run Postgres in Docker, in which case
|
||||
# there is nothing to install and nothing to start.
|
||||
PG_PREFIX=""
|
||||
PG_RUNNING=0
|
||||
if nc -z -G 2 127.0.0.1 5432 &>/dev/null; then
|
||||
PG_RUNNING=1
|
||||
ok "postgres already reachable on 127.0.0.1:5432 — nothing to install"
|
||||
else
|
||||
skip "no postgres on 127.0.0.1:5432"
|
||||
if ! has brew; then
|
||||
skip "postgres install (homebrew unavailable)"
|
||||
# Default yes: nothing is listening, so there is no Docker/remote server to reuse and Homebrew is
|
||||
# the only way this host gets a database.
|
||||
elif confirm SETUP_POSTGRES "Install and start $PG_FORMULA via Homebrew?" y; then
|
||||
brew_install "$PG_FORMULA" ""
|
||||
PG_PREFIX="$(brew --prefix "$PG_FORMULA" 2>/dev/null || true)"
|
||||
if [ -n "$PG_PREFIX" ] && [ -x "$PG_PREFIX/bin/pg_isready" ]; then
|
||||
echo " Starting $PG_FORMULA..."
|
||||
brew services start "$PG_FORMULA" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 20); do
|
||||
if "$PG_PREFIX/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if "$PG_PREFIX/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then
|
||||
PG_RUNNING=1
|
||||
ok "postgres running"
|
||||
# Homebrew's postgres trusts local connections for the current user, so no password needed.
|
||||
if "$PG_PREFIX/bin/psql" -h 127.0.0.1 -lqt 2>/dev/null | cut -d'|' -f1 | grep -qw "$PG_DATABASE"; then
|
||||
skip "database '$PG_DATABASE' exists"
|
||||
elif "$PG_PREFIX/bin/createdb" -h 127.0.0.1 "$PG_DATABASE" 2>/dev/null; then
|
||||
ok "database '$PG_DATABASE' created"
|
||||
else
|
||||
note_failure "could not create database '$PG_DATABASE'"
|
||||
fi
|
||||
else
|
||||
note_failure "postgres did not become ready — try: brew services start $PG_FORMULA"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "postgres install (declined — supply a POSTGRES_URL below)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── 4. pm2 ───────────────────────────────────────────────────────────────────
|
||||
step "pm2"
|
||||
|
||||
# User-local npm prefix so global installs never need sudo.
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
if has pm2; then
|
||||
skip "pm2 ($(command -v pm2))"
|
||||
elif ! has npm; then
|
||||
skip "pm2 (npm unavailable)"
|
||||
elif confirm SETUP_PM2 "Install pm2 (process manager for the server + sidecars)?" y; then
|
||||
npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true
|
||||
echo " Installing pm2..."
|
||||
if npm install -g pm2 >/dev/null 2>&1; then ok "pm2 installed"; else note_failure "pm2 install failed"; fi
|
||||
else
|
||||
skip "pm2 (declined)"
|
||||
fi
|
||||
# ~/.local/bin is not on the macOS default PATH (see /etc/paths). It is exported for this run, but a
|
||||
# later `pm2 logs` from a fresh shell needs it permanently.
|
||||
case ":${PATH}:" in
|
||||
*":$HOME/.local/bin:"*) ;;
|
||||
*) warn "add ~/.local/bin to your PATH so pm2 and claude stay available in new shells" ;;
|
||||
esac
|
||||
|
||||
# ─── 5. agents (claude, opencode) ─────────────────────────────────────────────
|
||||
step "Agents"
|
||||
|
||||
if has claude; then
|
||||
skip "claude ($(command -v claude))"
|
||||
elif confirm SETUP_CLAUDE "Install Claude Code?" y; then
|
||||
echo " Installing claude..."
|
||||
if curl -fsSL https://claude.ai/install.sh | bash; then
|
||||
if has claude; then ok "claude installed"; else note_failure "claude installed but not on PATH"; fi
|
||||
else
|
||||
note_failure "claude install failed"
|
||||
fi
|
||||
else
|
||||
skip "claude (declined)"
|
||||
fi
|
||||
# No symlink needed: the claude sidecar resolves its CLI from CLAUDE_BIN, then PATH, then the
|
||||
# installer's own locations (see resolveClaudeBin in sidecar/claude/claude-manager.ts).
|
||||
|
||||
if [ -x "$HOME/.opencode/bin/opencode" ]; then
|
||||
skip "opencode ($HOME/.opencode/bin/opencode)"
|
||||
elif confirm SETUP_OPENCODE "Install opencode?" y; then
|
||||
echo " Installing opencode..."
|
||||
if curl -fsSL https://opencode.ai/install | bash; then
|
||||
if [ -x "$HOME/.opencode/bin/opencode" ]; then
|
||||
ok "opencode installed"
|
||||
else
|
||||
note_failure "opencode installed but not at $HOME/.opencode/bin/opencode"
|
||||
fi
|
||||
else
|
||||
note_failure "opencode install failed"
|
||||
fi
|
||||
else
|
||||
skip "opencode (declined — officer-opencode will not start)"
|
||||
fi
|
||||
|
||||
# ─── 6. project dependencies ──────────────────────────────────────────────────
|
||||
step "Project dependencies"
|
||||
|
||||
if ! has bun; then
|
||||
skip "bun install (bun unavailable)"
|
||||
elif confirm SETUP_DEPS "Run bun install?" y; then
|
||||
echo " Running bun install..."
|
||||
if (cd "$PROJECT_DIR" && bun install); then
|
||||
ok "dependencies installed"
|
||||
else
|
||||
note_failure "bun install failed"
|
||||
fi
|
||||
else
|
||||
skip "bun install (declined)"
|
||||
fi
|
||||
|
||||
# ─── 7. environment (.env) ────────────────────────────────────────────────────
|
||||
step "Environment (.env)"
|
||||
|
||||
WRITE_ENV=0
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
if confirm SETUP_ENV "$ENV_FILE exists. Regenerate it?" n; then WRITE_ENV=1; else skip ".env (kept existing)"; fi
|
||||
elif confirm SETUP_ENV "Generate .env?" y; then
|
||||
WRITE_ENV=1
|
||||
else
|
||||
skip ".env (declined)"
|
||||
fi
|
||||
|
||||
ENV_PUBLIC_URL=""
|
||||
if [ "$WRITE_ENV" = "1" ]; then
|
||||
echo ""
|
||||
ask ENV_PORT "PORT" "9010"
|
||||
# Plain http://localhost needs nothing relaxed to work: browsers treat it as a secure context, so
|
||||
# passkeys, microphone capture and the clipboard are all available without TLS, and origin checking
|
||||
# is already off by default (ALLOW_ANY_ORIGIN). Reaching this from another device is the case that
|
||||
# needs an HTTPS proxy — http://192.168.x.x is not a secure context and those APIs fail there.
|
||||
ask ENV_PUBLIC_URL "PUBLIC_URL" "http://localhost:$ENV_PORT"
|
||||
ask ENV_DATA_PATH "DATA_PATH" "$HOME/.local/data"
|
||||
ask ENV_ITEMS_DIR "OFFICER_ITEMS_DIR" "$(dirname "$PROJECT_DIR")/officer-items"
|
||||
# A Docker postgres usually wants a password; brew's trusts the local user. Default to the plain
|
||||
# local form and let it be edited — this is the one value the script cannot infer reliably.
|
||||
ask ENV_POSTGRES_URL "POSTGRES_URL" "postgresql://$(whoami)@127.0.0.1:5432/$PG_DATABASE"
|
||||
|
||||
# jwt.ts throws at import time unless this is >= 32 chars, so verify rather than trust the pipeline.
|
||||
JWT_SECRET="$(openssl rand -base64 48 | tr -d '/+=' | head -c 48 || true)"
|
||||
if [ ${#JWT_SECRET} -lt 32 ]; then
|
||||
note_failure "could not generate a JWT_SECRET (got ${#JWT_SECRET} chars) — .env not written"
|
||||
else
|
||||
mkdir -p "$ENV_DATA_PATH" "$ENV_ITEMS_DIR" || warn "could not create DATA_PATH/OFFICER_ITEMS_DIR"
|
||||
if cat > "$ENV_FILE" <<ENVFILE
|
||||
PORT="$ENV_PORT"
|
||||
JWT_SECRET="$JWT_SECRET"
|
||||
PUBLIC_URL="$ENV_PUBLIC_URL"
|
||||
PUBLIC_BUILD_ENV="production"
|
||||
DATA_PATH="$ENV_DATA_PATH"
|
||||
OFFICER_ITEMS_DIR="$ENV_ITEMS_DIR"
|
||||
HOME_DIR="$HOME"
|
||||
POSTGRES_URL="$ENV_POSTGRES_URL"
|
||||
ENVFILE
|
||||
then
|
||||
# Holds the JWT signing secret and the database URL.
|
||||
chmod 600 "$ENV_FILE" || warn "could not chmod 600 $ENV_FILE"
|
||||
ok ".env written to $ENV_FILE (mode 600)"
|
||||
else
|
||||
note_failure "could not write $ENV_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── 8. project initialization ────────────────────────────────────────────────
|
||||
step "Project initialization"
|
||||
|
||||
if ! has bun || [ ! -f "$ENV_FILE" ]; then
|
||||
skip "initialization (bun or .env missing)"
|
||||
elif confirm SETUP_INIT "Generate index.gen.html and apply the database schema?" y; then
|
||||
# index.gen.html is gitignored and built from PUBLIC_URL, so it never exists on a fresh clone.
|
||||
echo " Generating index.gen.html..."
|
||||
if (cd "$PROJECT_DIR" && bun run gen:index); then
|
||||
ok "index.gen.html generated"
|
||||
else
|
||||
note_failure "gen:index failed — the app will not serve until this succeeds"
|
||||
fi
|
||||
|
||||
# Officer applies its schema with push; there are no migrations.
|
||||
echo " Applying database schema..."
|
||||
if (cd "$PROJECT_DIR" && bun db:push); then
|
||||
ok "schema applied"
|
||||
else
|
||||
note_failure "db:push failed — check POSTGRES_URL and that postgres is reachable"
|
||||
fi
|
||||
else
|
||||
skip "initialization (declined)"
|
||||
fi
|
||||
|
||||
# ─── 9. services ──────────────────────────────────────────────────────────────
|
||||
step "Services (pm2)"
|
||||
|
||||
if ! has pm2 || [ ! -f "$ECOSYSTEM" ]; then
|
||||
skip "services (pm2 or ecosystem.mac.light.config.cjs missing)"
|
||||
elif [ ! -f "$ENV_FILE" ]; then
|
||||
# Starting without .env gives a server on port 5000 with no database and a JWT_SECRET throw.
|
||||
skip "services (.env missing — they would crash-loop)"
|
||||
elif confirm SETUP_SERVICES "Start Officer and its sidecars with pm2?" y; then
|
||||
echo " Starting services..."
|
||||
if (cd "$PROJECT_DIR" && pm2 startOrRestart "$ECOSYSTEM"); then
|
||||
ok "services started"
|
||||
else
|
||||
note_failure "pm2 could not start the services — check 'pm2 logs'"
|
||||
fi
|
||||
if pm2 save >/dev/null 2>&1; then ok "process list saved"; else warn "pm2 save failed"; fi
|
||||
|
||||
# `pm2 save` on its own writes a process list that nothing ever reads. The Linux setup pairs it with
|
||||
# `pm2 startup`, which installs the boot unit that resurrects that list; without the pair, a reboot
|
||||
# silently leaves the machine with nothing running. On macOS the equivalent is a launchd agent rather
|
||||
# than a systemd unit. Kept optional because a laptop is not a server — you may not want the whole
|
||||
# stack coming back at every login — and non-fatal, because pm2's launchd integration can want an
|
||||
# elevated prompt that a scripted run should not force.
|
||||
if confirm SETUP_BOOT "Start Officer automatically at login (pm2 + launchd)?" y; then
|
||||
if pm2 startup launchd -u "$(whoami)" --hp "$HOME" >/dev/null 2>&1; then
|
||||
pm2 save >/dev/null 2>&1
|
||||
ok "services will start at login"
|
||||
else
|
||||
warn "Could not install the launchd agent — run: pm2 startup (and follow its instructions)"
|
||||
fi
|
||||
else
|
||||
skip "login startup (declined — 'pm2 startup' enables it later)"
|
||||
fi
|
||||
else
|
||||
skip "services (declined)"
|
||||
fi
|
||||
|
||||
# ─── verification ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo " Verification"
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
check() { if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi; }
|
||||
|
||||
echo "Required:"
|
||||
check node
|
||||
check npm
|
||||
check bun
|
||||
check git
|
||||
check zip
|
||||
check ffmpeg
|
||||
check ffprobe
|
||||
|
||||
echo ""
|
||||
echo "Database:"
|
||||
if nc -z -G 2 127.0.0.1 5432 &>/dev/null; then ok "postgres reachable on 127.0.0.1:5432"; else fail "postgres — NOT REACHABLE on 127.0.0.1:5432"; fi
|
||||
|
||||
echo ""
|
||||
echo "Agents:"
|
||||
check claude
|
||||
if [ -x "$HOME/.opencode/bin/opencode" ]; then ok "opencode"; else fail "opencode — NOT FOUND"; fi
|
||||
|
||||
echo ""
|
||||
echo "Process manager:"
|
||||
check pm2
|
||||
|
||||
if has pm2 && [ -f "$ECOSYSTEM" ]; then
|
||||
echo ""
|
||||
echo "Services:"
|
||||
# Read the names with node rather than grepping for `name:`. The profile derives its apps from
|
||||
# ecosystem.config.cjs and has no literal name keys to match, so a grep silently lists nothing —
|
||||
# which looks identical to "no services configured". Loading it also exercises the profile's own
|
||||
# consistency checks, which is exactly the moment you want to hear about a drifted include list.
|
||||
ECOSYSTEM_APPS=$(node -e "require('$ECOSYSTEM').apps.forEach(a=>console.log(a.name))" 2>/dev/null) \
|
||||
|| fail "$(basename "$ECOSYSTEM") could not be loaded — run: node -e \"require('./$(basename "$ECOSYSTEM")')\" to see why"
|
||||
for app in $ECOSYSTEM_APPS; do
|
||||
if [ -n "$(pm2 pid "$app" 2>/dev/null | tr -d '[:space:]' || true)" ]; then
|
||||
ok "$app"
|
||||
else
|
||||
fail "$app — not running (pm2 logs $app)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════"
|
||||
if [ ${#FAILURES[@]} -eq 0 ]; then
|
||||
echo -e " ${GREEN}Setup complete${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}Setup finished with ${#FAILURES[@]} problem(s)${NC}"
|
||||
for f in "${FAILURES[@]}"; do echo " • $f"; done
|
||||
fi
|
||||
echo "═══════════════════════════════════════════"
|
||||
|
||||
echo ""
|
||||
echo "Notes:"
|
||||
echo " • Open ${ENV_PUBLIC_URL:-http://localhost:9010} — the first-run screen creates the owner account"
|
||||
echo " • Logs: pm2 logs Restart: pm2 restart ecosystem.mac.light.config.cjs"
|
||||
echo " • Postgres must be running before the services start, or db:push and boot will fail"
|
||||
echo " • Not run on macOS: VNC desktop, email sync, music indexer, cliamp audio, and the sidecars"
|
||||
echo " that front a container or an external service — vault, slskd, headscale, transmission,"
|
||||
echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.light.config.cjs."
|
||||
echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to"
|
||||
echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup/setup_mac_light.sh"
|
||||
echo ""
|
||||
@@ -0,0 +1,45 @@
|
||||
add_newline = true
|
||||
command_timeout = 200
|
||||
format = """
|
||||
$username$hostname:[$directory$git_branch$git_status]($style)
|
||||
$character"""
|
||||
|
||||
[username]
|
||||
format = "[$user]($style)@"
|
||||
style_user = "bold green"
|
||||
show_always = true
|
||||
|
||||
[hostname]
|
||||
format = "[$hostname]($style)"
|
||||
style = "bold yellow"
|
||||
ssh_only = false
|
||||
|
||||
[character]
|
||||
error_symbol = "[✗](bold #61afef)"
|
||||
success_symbol = "[❯](bold #61afef)"
|
||||
|
||||
[directory]
|
||||
home_symbol = "~"
|
||||
truncation_length = 0
|
||||
truncate_to_repo = false
|
||||
|
||||
[git_branch]
|
||||
format = " [$branch]($style) "
|
||||
style = "italic #61afef"
|
||||
|
||||
|
||||
[git_status]
|
||||
format = '[$all_status]($style)'
|
||||
style = "#61afef"
|
||||
ahead = "⇡${count} "
|
||||
diverged = "⇕⇡${ahead_count}⇣${behind_count} "
|
||||
behind = "⇣${count} "
|
||||
conflicted = " "
|
||||
up_to_date = " "
|
||||
untracked = "? "
|
||||
modified = " "
|
||||
stashed = ""
|
||||
staged = ""
|
||||
renamed = ""
|
||||
deleted = ""
|
||||
|
||||
Reference in New Issue
Block a user