282 changed files with 3552 additions and 14800 deletions

No files matched your search

+2 -2
View File
@@ -183,8 +183,8 @@ currently parked (dir present, unit not active) — imported anyway, noted as
parked in the catalog. parked in the catalog.
**Excluded:** Watchtower (retired — replaced by the update timer after it took **Excluded:** Watchtower (retired — replaced by the update timer after it took
gitea down; its dir stays on junior until cleanup), the app source gitea down; its dir stays on junior until cleanup), Completeuphoria,
checkouts such as sierraandtyler and Bang (deployed by CI from their own repos — sierraandtyler, Bang (source checkouts deployed by CI from their own repos —
Panama owning a second copy of their compose would drift), `ci/`, `logs/`, Panama owning a second copy of their compose would drift), `ci/`, `logs/`,
and junior's loose scripts other than `update-containers.sh` (§4). and junior's loose scripts other than `update-containers.sh` (§4).
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/panama
@@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
drives every rule below. drives every rule below.
Before large work, read these in order: Read before large work, in this order:
- `README.md` — layout, the `panama` command, how installing and updating work - `README.md` — layout, the `panama` command, how installing and updating work
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one - `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
@@ -62,8 +62,7 @@ Every executable contract under `tests/` is classified in `tests/contracts.manif
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr. `PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
Successful stdout stays quiet. Successful stderr is surfaced as a warning. Successful stdout stays quiet. Successful stderr is surfaced as a warning.
- Contracts run directly too: `tests/setup/interview-contract`. - Contracts run directly too: `tests/setup/interview-contract`.
- After changing `PreferenceSchema.qml` or - After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
`config/dot/quickshell/services/SettingsRoutes.qml`, regenerate:
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and `config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links). `config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale. Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
+5 -11
View File
@@ -3,21 +3,15 @@
/config/bash/.env.lock /config/bash/.env.lock
# Server secrets and state never enter the repo: live .env files belong in # Server secrets and state never enter the repo: live .env files belong in
# ~/Server/<Name>/ (only compose.yml is symlinked back here), and data/ is # ~/Server/<Name>/ (only compose.yml is symlinked back here), and data/ is
# the bind-mount convention (the home server's older services use volume/ and # the bind-mount convention. Ignored anyway as a seatbelt -- the repo is
# volumes/). Ignored anyway as a seatbelt -- the repo is public, and # public, and tests/server/compose-secrets-contract is the check that this
# tests/server/compose-secrets-contract is the check that these lines still exist. # line still exists.
/server/**/.env /server/containers/**/.env
/server/**/data/ /server/containers/**/data/
/server/**/volume/
/server/**/volumes/
# Personal espanso triggers (name, email), seeded per-machine by setup-identity. # Personal espanso triggers (name, email), seeded per-machine by setup-identity.
/config/dot/espanso/match/identity.yml /config/dot/espanso/match/identity.yml
# Ignore backups of old config files # Ignore backups of old config files
/config/old /config/old
# MCP bearer tokens. The server list in user/agents/mcp/servers is tracked
# and names which variable carries each token; the tokens themselves are
# machine-local, because this repo is public.
/user/agents/mcp/env
# Ignore Wireguard config of course! # Ignore Wireguard config of course!
/config/wg/** /config/wg/**
# Ignore Neovim lazy-lock file # Ignore Neovim lazy-lock file
+5 -8
View File
@@ -102,13 +102,13 @@ in order, without stopping again:
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path | | `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen | | `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome | | `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
| `link-skills` | Links the agent skills in `skills/` into `~/.agents/skills` and `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash | | `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) | | `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
| `link-vicinae-scripts` | Publishes the Vicinae script commands | | `link-vicinae-scripts` | Publishes the Vicinae script commands |
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for | | `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
| `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot | | `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot |
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81, opened only where nothing already grants them), the `nginx-bridge` network, and the nightly image-update timer | | `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), the `nginx-bridge` network, and the nightly image-update timer |
| `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled | | `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled |
A server runs the shared stages plus its own two, and skips the rest — A server runs the shared stages plus its own two, and skips the rest —
@@ -235,13 +235,10 @@ server/ The server role: compose services (one directory per
service), the nightly image updater, and its units. See service), the nightly image updater, and its units. See
server/README.md server/README.md
skills/ Agent skills for operating this desktop, linked into skills/ Agent skills for operating this desktop, linked into
~/.agents/skills and ~/.claude/skills ~/.claude/skills
setup/ setup/
apps/ Applications built from source, one file each apps/ Applications built from source, one file each
keys/ Pinned signing keys, for publishers that ship no fetchable lib/ Shared by more than one stage; the extras catalog reader
one; setup/keys/README.md records where each came from
lib/ Shared by more than one stage; the extras catalog reader,
the machine role, the verified ChatGPT repository
packages/ One package per line; extras/ holds the optional categories packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install scripts/ Run in order by ./install
tests/ Contracts. See below tests/ Contracts. See below
@@ -250,7 +247,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
193 of them, under `tests/`. `tests/contracts.manifest` classifies every 188 of them, under `tests/`. `tests/contracts.manifest` classifies every
contract by the capabilities it needs. Run the hermetic set, or grant a contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it: specific external capability when automation needs it:
+2 -8
View File
@@ -134,6 +134,7 @@ ${BOLD}Examples:${RESET}
$PROGRAM upgrade $PROGRAM upgrade
$PROGRAM apps $PROGRAM apps
$PROGRAM app $PROGRAM app
$PROGRAM app chatgpt-desktop
EOF EOF
} }
@@ -450,16 +451,11 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
contract_paths() { contract_paths() {
local candidate local candidate
# The manifest is kept in byte order, so both the discovery sort and the
# comparison below have to be byte order too. A UTF-8 collation folds the
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
# a gate that passes or fails on the machine's LANG is not a gate.
while IFS= read -r candidate; do while IFS= read -r candidate; do
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue [[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}" printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
done < <(find "$PANAMA_DIR/tests" -type f \ done < <(find "$PANAMA_DIR/tests" -type f \
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort) -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
} }
contract_manifest_entries() { contract_manifest_entries() {
@@ -482,8 +478,6 @@ validate_contract_manifest() {
require_contract_manifest || return 1 require_contract_manifest || return 1
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST" local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
# Byte order, for the same reason contract_paths sorts in it.
local LC_ALL=C
local line capabilities path extra previous_comment="" previous_was_comment=0 local line capabilities path extra previous_comment="" previous_was_comment=0
local previous_path="" capability discovered local previous_path="" capability discovered
local -a capability_list=() findings=() local -a capability_list=() findings=()
+5 -40
View File
@@ -10,16 +10,9 @@
# start time, so choosing an agent in Settings takes effect on the next crash # start time, so choosing an agent in Settings takes effect on the next crash
# without restarting anything: # without restarting anything:
# #
# preferredAgent none | claude | codex | t3 ("none" is the default: silence) # preferredAgent none | claude | codex ("none" is the default: silence)
# agentAutoApprove true -> the agent starts in its own "don't stop to ask" # agentAutoApprove true -> the agent starts in its own "don't stop to ask"
# mode; false -> its normal prompting mode, untouched. # mode; false -> its normal prompting mode, untouched.
# T3 Code ignores it: a T3 thread runs in the mode its
# project is set to, which is a choice made inside T3.
#
# claude and codex open in a kitty window. t3 starts a thread in the Panama
# project on the running T3 Code server and brings it up in the desktop app,
# so the investigation lives beside every other thread instead of in a
# terminal of its own.
# #
# "none" exits 0 without a word. It is not an error to have no agent; it is the # "none" exits 0 without a word. It is not an error to have no agent; it is the
# shipped state, and a rung that shouted about it would be a rung that gets # shipped state, and a rung that shouted about it would be a rung that gets
@@ -102,9 +95,8 @@ usage() {
cat <<'EOF' cat <<'EOF'
Usage: panama-agent [--prompt "text"] Usage: panama-agent [--prompt "text"]
Opens the agent named by preferredAgent in the Panama checkout: a terminal for Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
claude and codex, a thread in the T3 Code app for t3. With no agent chosen, With no agent chosen, exits silently: choose one on Settings > System > Agents.
exits silently: choose one on Settings > System > Agents.
EOF EOF
} }
@@ -147,7 +139,7 @@ agent="$(read_setting preferredAgent none)"
auto_approve="$(read_setting agentAutoApprove true)" auto_approve="$(read_setting agentAutoApprove true)"
case "$agent" in case "$agent" in
claude | codex | t3) ;; claude | codex) ;;
*) *)
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2 printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
printf 'Choose one on Settings > System > Agents.\n' >&2 printf 'Choose one on Settings > System > Agents.\n' >&2
@@ -156,8 +148,7 @@ case "$agent" in
esac esac
# Resolved to a path before argv is built, so kitty is never asked to repeat a # Resolved to a path before argv is built, so kitty is never asked to repeat a
# PATH lookup this script has already done more carefully than kitty could. T3 # PATH lookup this script has already done more carefully than kitty could.
# Code's CLI is `t3`, which the desktop app writes into ~/.local/bin.
if ! agent_bin="$(resolve_agent "$agent")"; then if ! agent_bin="$(resolve_agent "$agent")"; then
printf 'panama-agent: %s is not installed.\n' "$agent" >&2 printf 'panama-agent: %s is not installed.\n' "$agent" >&2
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2 printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
@@ -165,32 +156,6 @@ if ! agent_bin="$(resolve_agent "$agent")"; then
exit 1 exit 1
fi fi
# T3 Code: no terminal. The thread starts on the running server in the
# project whose root is the checkout, with the project's default model, and
# the t3code:// link opens it in the app. `project add` refuses a root that is
# already a project, which is the common case, so its failure is not one. The
# prompt goes over stdin ("-"), so a prompt that begins with a dash is never
# read as a flag. With no prompt there is nothing to start, so the app just
# opens on the project.
if [[ "$agent" == "t3" ]]; then
repair_path
"$agent_bin" project add "$PANAMA_PATH" >/dev/null 2>&1 || true
if [[ -z "$prompt" ]]; then
exec setsid "$agent_bin" app "$PANAMA_PATH"
fi
if ! thread="$(printf '%s' "$prompt" | "$agent_bin" thread start "$PANAMA_PATH" -)"; then
printf 'panama-agent: T3 Code could not start a thread.\n' >&2
printf 'Is the T3 Code server running? Check: systemctl --user status t3code\n' >&2
exit 1
fi
environment_file="${T3CODE_HOME:-$HOME/.t3}/userdata/environment-id"
environment="$(cat "$environment_file" 2>/dev/null || true)"
if [[ -n "$environment" ]]; then
exec setsid xdg-open "t3code://threads/$environment/$thread"
fi
exec setsid "$agent_bin" app "$PANAMA_PATH"
fi
# The per-agent launch table. VERIFIED against the installed binaries' --help on # The per-agent launch table. VERIFIED against the installed binaries' --help on
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same # 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
# from release to release, so re-run --help before changing them. # from release to release, so re-run --help before changing them.
+18 -68
View File
@@ -2,12 +2,10 @@
# #
# panama-server – the compose services a server machine runs. # panama-server – the compose services a server machine runs.
# #
# The repository carries the catalog: server/vps/<Name>/ holds a # The repository carries the catalog: server/containers/<Name>/ holds a
# compose.yml, a podman-<name>.service user unit, and an .env.example naming # compose.yml, a podman-<name>.service user unit, and an .env.example naming
# what the service needs told. A service may also carry helper units (any # what the service needs told. The machine carries the rest: ~/Server/<Name>/
# other *.service or *.timer, e.g. a cert refresh) and helper files (scripts, # is a real directory owning the .env (secrets never sit inside the checkout,
# a README) beside them. The machine carries the rest: ~/Server/<Name>/ is a
# real directory owning the .env (secrets never sit inside the checkout,
# gitignored or not) and the bind-mounted data. Only the tracked files are # gitignored or not) and the bind-mounted data. Only the tracked files are
# symlinked, so a `git clean` in the repo can never reach a database. # symlinked, so a `git clean` in the repo can never reach a database.
# #
@@ -29,7 +27,7 @@ PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
# Overridable so the contract can point this at a fixture catalog and a # Overridable so the contract can point this at a fixture catalog and a
# throwaway HOME rather than the machine's real services. # throwaway HOME rather than the machine's real services.
SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/vps}" SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}"
TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}" TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions" STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions"
@@ -48,7 +46,7 @@ err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
usage() { usage() {
cat <<EOF cat <<EOF
${BOLD}$PROGRAM${RESET} – manage the compose services in server/vps/ ${BOLD}$PROGRAM${RESET} – manage the compose services in server/containers/
${BOLD}Usage:${RESET} ${BOLD}Usage:${RESET}
$PROGRAM list Every service in the catalog, and its state here $PROGRAM list Every service in the catalog, and its state here
@@ -85,12 +83,12 @@ require_service() {
fi fi
} }
# The one compose unit a service directory carries. Its filename is its # The one unit file a service directory carries. Its filename is its identity
# identity -- podman-<name>.service, exactly what lands in the systemd user # -- podman-<name>.service, exactly what lands in the systemd user directory --
# directory -- so nothing here invents a name that could drift from the file's. # so nothing here invents a name that could drift from the file's.
unit_path_for() { unit_path_for() {
local name="$1" unit local name="$1" unit
for unit in "$SERVER_DIR/$name"/podman-*.service; do for unit in "$SERVER_DIR/$name"/*.service; do
[[ -e "$unit" ]] || continue [[ -e "$unit" ]] || continue
printf '%s' "$unit" printf '%s' "$unit"
return 0 return 0
@@ -98,44 +96,6 @@ unit_path_for() {
return 1 return 1
} }
# Every other unit in the directory: helper services and the timers that fire
# them. They are linked beside the compose unit, and each timer is enabled.
helper_units_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service "$SERVER_DIR/$name"/*.timer; do
[[ -e "$unit" ]] || continue
[[ "$(basename "$unit")" == podman-*.service ]] && continue
printf '%s\n' "$unit"
done
}
# The files linked into ~/Server/<Name>/: every visible top-level file that is
# not a unit. That is compose.yml plus any helper script or README; dotfiles
# are skipped so .env.example (and a stray .env) never become links.
live_files_for() {
local name="$1" file
for file in "$SERVER_DIR/$name"/*; do
[[ -f "$file" ]] || continue
case "$file" in *.service|*.timer) continue ;; esac
printf '%s\n' "$file"
done
}
link_service_files() {
local name="$1" file unit
mkdir -p "$TARGET_DIR/$name" "$UNIT_DIR"
while IFS= read -r file; do
link_file "$file" "$TARGET_DIR/$name/$(basename "$file")"
done < <(live_files_for "$name")
while IFS= read -r unit; do
link_file "$unit" "$UNIT_DIR/$(basename "$unit")"
done < <(helper_units_for "$name")
}
helper_timers_for() {
helper_units_for "$1" | grep '\.timer$' | xargs -r -n1 basename
}
unit_name_for() { unit_name_for() {
local unit local unit
unit="$(unit_path_for "$1")" || return 1 unit="$(unit_path_for "$1")" || return 1
@@ -151,12 +111,10 @@ unit_installed() {
[[ -L "$UNIT_DIR/$unit_name" ]] [[ -L "$UNIT_DIR/$unit_name" ]]
} }
# Everything this machine links for the service, so a change to a helper
# script or timer is reported by relink just like a compose change.
definition_hash() { definition_hash() {
local name="$1" local name="$1" unit
{ live_files_for "$name"; unit_path_for "$name" && echo; helper_units_for "$name"; } \ unit="$(unit_path_for "$name")" || unit=/dev/null
| sort | xargs -r -d '\n' cat 2>/dev/null | sha256sum | cut -d' ' -f1 cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
} }
recorded_hash() { recorded_hash() {
@@ -240,7 +198,6 @@ cmd_enable() {
fi fi
mkdir -p "$UNIT_DIR" mkdir -p "$UNIT_DIR"
link_service_files "$name"
link_file "$unit_path" "$UNIT_DIR/$unit_name" link_file "$unit_path" "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload systemctl --user daemon-reload
if systemctl --user enable --now "$unit_name"; then if systemctl --user enable --now "$unit_name"; then
@@ -250,10 +207,6 @@ cmd_enable() {
err "$unit_name did not start; see: systemctl --user status $unit_name" err "$unit_name did not start; see: systemctl --user status $unit_name"
exit 1 exit 1
fi fi
local timer
for timer in $(helper_timers_for "$name"); do
systemctl --user enable --now "$timer" && ok "Enabled $timer"
done
} }
cmd_disable() { cmd_disable() {
@@ -263,15 +216,11 @@ cmd_disable() {
local unit_name local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; } unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
local timer unit
for timer in $(helper_timers_for "$name"); do
systemctl --user disable --now "$timer" 2>/dev/null || true
done
systemctl --user disable --now "$unit_name" 2>/dev/null || true systemctl --user disable --now "$unit_name" 2>/dev/null || true
for unit in "$UNIT_DIR/$unit_name" $(helper_units_for "$name" | xargs -r -n1 basename | sed "s|^|$UNIT_DIR/|"); do if [[ -L "$UNIT_DIR/$unit_name" ]]; then
[[ -L "$unit" ]] && rm "$unit" rm "$UNIT_DIR/$unit_name"
done systemctl --user daemon-reload
systemctl --user daemon-reload fi
ok "$name disabled. ~/Server/$name, its .env and its data were not touched." ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
} }
@@ -318,7 +267,8 @@ cmd_relink() {
unit_installed "$name" || continue unit_installed "$name" || continue
link_file "$unit_path" "$UNIT_DIR/$unit_name" link_file "$unit_path" "$UNIT_DIR/$unit_name"
link_service_files "$name" mkdir -p "$TARGET_DIR/$name"
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
refreshed=$((refreshed + 1)) refreshed=$((refreshed + 1))
# Changed since the last time this machine looked, which is what makes a # Changed since the last time this machine looked, which is what makes a
+89 -6
View File
@@ -45,6 +45,65 @@ checkout_command() {
fi fi
} }
# Git's index hints are performance promises, not trust evidence. In
# particular, assume-unchanged and skip-worktree can make porcelain status
# report a clean checkout whose files no longer match HEAD. Compare every
# tracked blob and Git mode with the verified commit before handing control to
# any file in the worktree.
checkout_matches_verified_commit() (
local checkout="$1" listing="" entry metadata mode type expected path actual
local link_target_with_sentinel link_target
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
listing=""
exit 1
fi
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
while IFS= read -r -d '' entry; do
[[ "$entry" == *$'\t'* ]] || exit 1
metadata="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
read -r mode type expected <<<"$metadata"
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
case "$mode" in
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& ! -x "$checkout/$path" ]] || exit 1 ;;
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& -x "$checkout/$path" ]] || exit 1 ;;
120000)
[[ -L "$checkout/$path" ]] || exit 1
# hash-object given a pathname follows a symlink. Git's 120000 blob is
# the link text itself, including any trailing newlines, so preserve
# those bytes with a sentinel and hash stdin instead.
link_target_with_sentinel="$(
readlink -n -- "$checkout/$path" && printf .
)" || exit 1
[[ "$link_target_with_sentinel" == *. ]] || exit 1
link_target="${link_target_with_sentinel%.}"
actual="$(
printf '%s' "$link_target" \
| checkout_command git -C "$checkout" hash-object --stdin
)" || exit 1
[[ "$actual" == "$expected" ]] || exit 1
continue
;;
*) exit 1 ;;
esac
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|| exit 1
[[ "$actual" == "$expected" ]] || exit 1
done <"$listing"
)
prepare_panama_checkout() { prepare_panama_checkout() {
local checkout="$1" actual_head checkout_status local checkout="$1" actual_head checkout_status
@@ -110,6 +169,30 @@ for arg in "$@"; do
esac esac
done done
# Keep the worktree comparison at the last possible boundary. Checkout
# preparation may invoke several commands and return to the caller; performing
# the byte/mode/link check here ensures a change in that interval is rejected
# before any tracked file is executed.
verified_install_handoff() {
local use_tty="$1"
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
return 1
fi
if [[ -n "$BOOTSTRAP_USER" ]]; then
if (( use_tty )); then
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
fi
if (( use_tty )); then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
}
# The public bootstrap contract runs this branch as an ordinary user with a # The public bootstrap contract runs this branch as an ordinary user with a
# stubbed root identity. Keep its filesystem adapter unavailable to a real root # stubbed root identity. Keep its filesystem adapter unavailable to a real root
# shell so it cannot redirect a real installation by accident. # shell so it cannot redirect a real installation by accident.
@@ -471,7 +554,7 @@ if [[ "$(id -u)" -eq 0 ]]; then
if ! command -v git >/dev/null 2>&1; then if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs" echo "Installing git, which the clone needs"
dnf install -y git dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi fi
# Create or advance the checkout as the target user. A root-owned .git in a # Create or advance the checkout as the target user. A root-owned .git in a
@@ -481,15 +564,14 @@ if [[ "$(id -u)" -eq 0 ]]; then
prepare_panama_checkout "$PANAMA_PATH" prepare_panama_checkout "$PANAMA_PATH"
echo "Handing off to install as $username" echo "Handing off to install as $username"
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \ verified_install_handoff 1
"$PANAMA_PATH/install" --server </dev/tty
fi fi
# git is the one dependency the clone itself needs. Everything else -- gum # git is the one dependency the clone itself needs. Everything else -- gum
# included -- is bootstrapped by `install`. # included -- is bootstrapped by `install`.
if ! command -v git >/dev/null 2>&1; then if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs" echo "Installing git, which the clone needs"
sudo dnf install -y git sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi fi
prepare_panama_checkout "$PANAMA_PATH" prepare_panama_checkout "$PANAMA_PATH"
@@ -500,7 +582,8 @@ prepare_panama_checkout "$PANAMA_PATH"
# so itself. # so itself.
# The probe actually opens /dev/tty rather than testing -r: a process with no # The probe actually opens /dev/tty rather than testing -r: a process with no
# controlling terminal passes -r and then fails the redirect. # controlling terminal passes -r and then fails the redirect.
handoff_tty=0
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty handoff_tty=1
fi fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} verified_install_handoff "$handoff_tty"
+2 -3
View File
@@ -22,7 +22,6 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
alias nlc='nvim leetcode.nvim' alias nlc='nvim leetcode.nvim'
alias clauded='claude --allow-dangerously-skip-permissions' alias clauded='claude --allow-dangerously-skip-permissions'
alias claudedr='claude --allow-dangerously-skip-permissions --resume' alias claudedr='claude --allow-dangerously-skip-permissions --resume'
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
alias commit-repo='git add -A && git commit -m "Update stuff" && git push' alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
# Docker Shortcuts # Docker Shortcuts
@@ -37,9 +36,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
alias fprint-off='sudo authselect disable-feature with-fingerprint' alias fprint-off='sudo authselect disable-feature with-fingerprint'
# File system # File system
alias ls='eza -lh --group-directories-first --icons=auto' alias ls='eza -lh --group-directories-first --icons'
alias lsa='ls -a' alias lsa='ls -a'
alias lt='eza --tree --level=2 --long --icons=auto --git' alias lt='eza --tree --level=2 --long --icons --git'
alias lta='lt -a' alias lta='lt -a'
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name. # Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
alias ff="fzf --preview 'bat --style=numbers --color=always {}'" alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
+4 -20
View File
@@ -16,23 +16,8 @@ unset PANAMA_ENV
# Editor used by CLI # Editor used by CLI
export EDITOR="nvim" export EDITOR="nvim"
export SUDO_EDITOR="$EDITOR" export SUDO_EDITOR="$EDITOR"
# A graphical askpass, and only where there is a session to draw it in. export SSH_ASKPASS=/usr/bin/ksshaskpass
# SSH_ASKPASS_REQUIRE=prefer means ssh and git reach for the dialog BEFORE the export SSH_ASKPASS_REQUIRE=prefer
# terminal prompt, so on a headless machine the prompt that would have worked
# is skipped in favour of one that cannot open: `git push` then fails reading
# a username rather than asking for it. The desktop keeps the dialog; a
# server, a serial console and a plain SSH session fall back to the terminal.
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] && [ -x /usr/bin/ksshaskpass ]; then
export SSH_ASKPASS=/usr/bin/ksshaskpass
export SSH_ASKPASS_REQUIRE=prefer
else
# Cleared, not merely left unset. An askpass inherited from a parent that
# did have a display -- an agent's shell, a forwarded environment, a login
# that predates this guard -- is exactly as unusable here as one this file
# set itself, and declining to add it does nothing about a value already in
# the environment.
unset SSH_ASKPASS SSH_ASKPASS_REQUIRE
fi
# Define Paths # Define Paths
export CARGO_PATH="$HOME/.cargo" export CARGO_PATH="$HOME/.cargo"
@@ -47,9 +32,8 @@ export DOTNETPATH="$HOME/.dotnet/tools"
# Podman # Podman
# export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock # export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
# Set complete path. ~/.opencode/bin is OpenCode 2's own installer location # Set complete path
# and sits ahead of Bun's bin, which can still hold an OpenCode 1 copy. export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$HOME/.opencode/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
# rustup writes this file, and initial-packages installs rustup rather than # rustup writes this file, and initial-packages installs rustup rather than
# running rustup-init -- so on a fresh machine it does not exist yet and an # running rustup-init -- so on a fresh machine it does not exist yet and an
-6
View File
@@ -29,9 +29,3 @@ require("autostart")
-- Per-machine overrides, loaded last so it wins. Ships effectively empty. -- Per-machine overrides, loaded last so it wins. Ships effectively empty.
require("overrides") require("overrides")
-- T3 Code SnapShots. T3's setup wrote this line and finds it again by its exact
-- shape (a bare hl.bind to a global), so keep it here and unwrapped. Going
-- through keybinds.lua's bind() would make T3 add a second copy. Change or
-- remove it from T3 Code under Settings > SnapShots.
hl.bind("CTRL + SHIFT + 2", hl.dsp.global("com.t3tools.T3Code:capture-window"))
+8 -12
View File
@@ -8,14 +8,6 @@
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions") local actions = require("actions")
-- Empty means "let libinput pick per device". Hyprland 0.57 rejects "" for
-- this option, so empty is expressed by not writing it at all: nil drops the
-- key from the table below and the option stays at its unset default.
local scroll_method = prefs.get("scrollMethod", "")
if scroll_method == "" then
scroll_method = nil
end
hl.config({ hl.config({
input = { input = {
kb_layout = prefs.get("keyboardLayout", "us"), kb_layout = prefs.get("keyboardLayout", "us"),
@@ -60,12 +52,16 @@ hl.config({
scroll_factor = prefs.get("scrollFactor", 1.0), scroll_factor = prefs.get("scrollFactor", 1.0),
left_handed = prefs.get("leftHanded", false), left_handed = prefs.get("leftHanded", false),
-- Empty (omitted, see above) is what `hyprctl getoption` reports as -- Empty is a value, not an omission: it is what `hyprctl getoption`
-- "[[EMPTY]]": libinput picks per device -- two fingers on a touchpad, -- reports as "[[EMPTY]]" before anything writes the option, and it means
-- the wheel on a mouse. Settings offers 2fg / edge / on_button_down / -- "let libinput pick per device" -- two fingers on a touchpad, the wheel
-- on a mouse. Writing it back explicitly is the same branch a stock
-- Hyprland takes, and it keeps the setting reversible: without an empty
-- choice in the schema there would be no way back from a scroll method
-- once one was picked. Settings offers 2fg / edge / on_button_down /
-- no_scroll alongside it, the four words the option's own description -- no_scroll alongside it, the four words the option's own description
-- names (it publishes no map). -- names (it publishes no map).
scroll_method = scroll_method, scroll_method = prefs.get("scrollMethod", ""),
-- Only consulted while scroll_method is on_button_down. 0 means the -- Only consulted while scroll_method is on_button_down. 0 means the
-- device's own middle button. -- device's own middle button.
+6 -9
View File
@@ -377,18 +377,15 @@ bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = t
-- The gesture needs three binds, not two. Tab steps the selection, and the -- The gesture needs three binds, not two. Tab steps the selection, and the
-- switch is only COMMITTED when the modifier is released -- which is the sole -- switch is only COMMITTED when the modifier is released -- which is the sole
-- way the compositor can tell the gesture is finished. That release bind is on -- way the compositor can tell the gesture is finished. That release bind is on
-- the Super keys themselves, so it fires on EVERY Super release in the -- the bare modifier, so it fires on EVERY Super release in the session; the
-- session; the handler returns immediately when no switch is open, which is -- handler returns immediately when no switch is open, which is why this is
-- why this is affordable. -- affordable.
-- --
-- A modifier-only bind has to name the keysym (Super_L / Super_R). Hyprland -- The release bind carries no description on purpose: it is not a shortcut
-- 0.57 rejects a chord that is a bare modifier ("A bind requires a trigger"), -- anyone would look up or rebind, and the Shortcuts page lists what it finds.
-- and the error stops this file, costing every bind below it.
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" }) bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" }) bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
for _, key in ipairs({ "Super_L", "Super_R" }) do bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
bind(mod .. " + " .. key, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
end
-- Jump back to the previously focused window. -- Jump back to the previously focused window.
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" }) bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
+4 -14
View File
@@ -244,20 +244,10 @@ local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5 local shipped_scale = 1.5
local shipped_transform = 0 local shipped_transform = 0
-- 8-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4 -- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
-- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset -- HBR3, so this relies on DSC. If the display fails to light up or falls back to
-- retrains it and blanks the screen. 8bpc keeps headroom on the link. -- a lower mode, drop this to 8 first.
-- local shipped_bitdepth = 10
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
-- format, and on amdgpu a format change is a full modeset. Compositing always
-- keeps the format fixed, so the link never retrains mid-game.
--
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
-- from it anyway.
local shipped_bitdepth = 8
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. -- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
local shipped_cm = "auto" local shipped_cm = "auto"
+2 -24
View File
@@ -125,16 +125,6 @@ hl.window_rule({
no_dim = true, no_dim = true,
}) })
-- Steam games never get true fullscreen. Everything Hyprland does only for a
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
-- window maximized internally while the game believes it is fullscreen, which
-- is what borderless windowed looks like from the game's side.
hl.window_rule({
match = { class = "^steam_app_\\d+$" },
fullscreen_state = "1 2",
})
-- Steam itself is a normal window, but its transient popups are a mess. -- Steam itself is a normal window, but its transient popups are a mess.
hl.window_rule({ hl.window_rule({
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" }, match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
@@ -279,28 +269,16 @@ hl.layer_rule({
ignore_alpha = 0.2, ignore_alpha = 0.2,
}) })
-- Overview and local screen-reading UI dim the desktop behind them. -- Overview, capture and local screen-reading UI dim the desktop behind them.
hl.layer_rule({ hl.layer_rule({
name = "qs-overlay", name = "qs-overlay",
match = { namespace = "^qs-(overview|screen-intelligence)$" }, match = { namespace = "^qs-(overview|capture|screen-intelligence)$" },
blur = true, blur = true,
ignore_alpha = 0.4, ignore_alpha = 0.4,
dim_around = true, dim_around = true,
no_screen_share = true, no_screen_share = true,
}) })
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
-- all, into every screenshot.
hl.layer_rule({
name = "qs-capture",
match = { namespace = "^qs-capture$" },
no_anim = true,
no_screen_share = true,
})
-- Notification toasts. Blurred like every other shell surface -- without this -- Notification toasts. Blurred like every other shell surface -- without this
-- the cards are a near-transparent fill sitting directly on the wallpaper and -- the cards are a near-transparent fill sitting directly on the wallpaper and
-- read as washed out rather than as glass. -- read as washed out rather than as glass.
@@ -184,8 +184,7 @@ Singleton {
options: [ options: [
{ value: "none", label: "None" }, { value: "none", label: "None" },
{ value: "claude", label: "Claude Code" }, { value: "claude", label: "Claude Code" },
{ value: "codex", label: "Codex" }, { value: "codex", label: "Codex" }
{ value: "t3", label: "T3 Code" }
] ]
}, },
{ {
@@ -206,7 +205,7 @@ Singleton {
{ {
key: "agentAutoApprove", type: "bool", def: true, group: "agents", key: "agentAutoApprove", type: "bool", def: true, group: "agents",
label: "Launched agents approve their own tools", label: "Launched agents approve their own tools",
detail: "Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all" detail: "Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
}, },
{ {
key: "agentUsageClaude", type: "bool", def: true, group: "agents", key: "agentUsageClaude", type: "bool", def: true, group: "agents",
@@ -1806,7 +1805,7 @@ Singleton {
"org.mozilla.thunderbird_esr", "com.slack.Slack", "org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk", "app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude", "io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
"chatgpt", "md.obsidian.Obsidian", "codex-desktop", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam" "com.obsproject.Studio", "steam"
] ]
}, },
@@ -27,13 +27,9 @@ PanelWindow {
right: true right: true
} }
// Ignore, and nothing else: reserve no space (or every window on the // Fullscreen overlays must not reserve space, or every window on the
// workspace resizes as the picker opens and closes) and respect nobody's // workspace gets resized as the picker opens and closes.
// (or the window starts under the bar, the full-output freeze frame is exclusiveZone: 0
// squeezed into a shorter box, and every selection lands one bar-height
// off in the real capture). Never pair this with exclusiveZone; see
// tests/quickshell/exclusion-idiom-contract.
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@@ -57,6 +57,7 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind // The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
@@ -55,6 +55,7 @@ PanelWindow {
color: "transparent" color: "transparent"
// A dock that reserved space would not be intellihiding. // A dock that reserved space would not be intellihiding.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename. // Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
@@ -40,6 +40,7 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted // Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
@@ -44,6 +44,7 @@ PanelWindow {
// Reserve nothing and respect nothing: the glow is drawn over the whole // Reserve nothing and respect nothing: the glow is drawn over the whole
// output including under the bar and the dock, which is what makes it // output including under the bar and the dock, which is what makes it
// visible from wherever the eyes happen to be. // visible from wherever the eyes happen to be.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
color: "transparent" color: "transparent"
@@ -29,6 +29,7 @@ PanelWindow {
screen: root.modelData screen: root.modelData
anchors.bottom: true anchors.bottom: true
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3 margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
implicitWidth: root.desiredWidth implicitWidth: root.desiredWidth
implicitHeight: 64 implicitHeight: 64
@@ -22,6 +22,7 @@ PanelWindow {
} }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename. // Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
@@ -34,23 +34,11 @@ Item {
visible: root.rowCount > 0 visible: root.rowCount > 0
implicitHeight: rows.implicitHeight implicitHeight: rows.implicitHeight
// Probing I2C takes on the order of a second, so it waits until the quick // Probing I2C takes on the order of a second, so it waits until the panel
// settings panel is actually on screen rather than running at shell // is actually on screen rather than running at shell startup. Monitors do
// startup. Monitors do not come and go, so once is enough. // not come and go, so once is enough.
// onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
// The trigger is the panel opening, not this item becoming visible: on a Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
// machine with no backlight, `visible` stays false until a scan has found
// a DDC display, so keying the scan off it would never scan at all.
Connections {
target: ShellState
function onQuickSettingsOpenChanged(): void { root.scanIfOpen(); }
}
Component.onCompleted: root.scanIfOpen()
function scanIfOpen(): void {
if (ShellState.quickSettingsOpen && !Brightness.scanned)
Brightness.refresh();
}
// `-m` is the machine-readable form: name,class,current,percent,max // `-m` is the machine-readable form: name,class,current,percent,max
Process { Process {
@@ -37,8 +37,8 @@ SettingsPage {
// them not to believe the page. // them not to believe the page.
// //
// Probed through a LOGIN shell rather than this one. Quickshell is started // Probed through a LOGIN shell rather than this one. Quickshell is started
// by systemd, whose PATH does not include ~/.local/bin -- where all three of // by systemd, whose PATH does not include ~/.local/bin -- where both of
// these usually land (T3 Code's CLI is `t3`) -- and a login shell is the environment the launcher // these usually land -- and a login shell is the environment the launcher
// hands the agent when it spawns a terminal. Asking with the shell's own // hands the agent when it spawns a terminal. Asking with the shell's own
// PATH would report "not installed" for an agent that starts perfectly. // PATH would report "not installed" for an agent that starts perfectly.
property var installed: ({}) property var installed: ({})
@@ -60,7 +60,7 @@ SettingsPage {
running: true running: true
command: ["bash", "-lc", command: ["bash", "-lc",
"for agent in claude codex t3; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"] "for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: root.absorbProbe(this.text) onStreamFinished: root.absorbProbe(this.text)
} }
@@ -70,7 +70,6 @@ SettingsPage {
switch (value) { switch (value) {
case "claude": return "starred-symbolic"; case "claude": return "starred-symbolic";
case "codex": return "utilities-terminal-symbolic"; case "codex": return "utilities-terminal-symbolic";
case "t3": return "view-dual-symbolic";
default: return "notifications-disabled-symbolic"; default: return "notifications-disabled-symbolic";
} }
} }
@@ -22,6 +22,7 @@ Variants {
implicitWidth: 260 implicitWidth: 260
implicitHeight: 172 implicitHeight: 172
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
mask: Region {} mask: Region {}
@@ -383,13 +383,6 @@ Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
0 on syntax and runtime errors too. The only trustworthy signal that a write 0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back. landed is reading the value back.
**`exclusionMode: ExclusionMode.Ignore` goes alone.** Quickshell's `exclusiveZone`
setter flips `exclusionMode` back to `Normal` as a side effect, so a window that
declares both is at the mercy of which property the QML engine applies last.
The 2026-09-14 Qt update changed that order and every full-screen overlay slid
under the bar. `tests/quickshell/exclusion-idiom-contract` fails any file that
pairs them.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides, **The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow. its control under the label below 520px. Test narrow.
@@ -78,6 +78,7 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "qs-popover-welcome" WlrLayershell.namespace: "qs-popover-welcome"
+1 -4
View File
@@ -341,10 +341,7 @@ esac'
property bool record: false property bool record: false
property bool intelligence: false property bool intelligence: false
// A handful of frames at 60Hz, enough for the compositor to recomposite // A handful of frames at 60Hz, enough for the compositor to recomposite
// the output without the overlay on it. Only enough because the // the output without the overlay on it.
// qs-capture layer rule in hypr/rules.lua sets no_anim: with the
// default 200ms layersOut fade the half-faded picker would still be
// on screen when grim reads it.
interval: 90 interval: 90
onTriggered: { onTriggered: {
const r = commitDelay.rect; const r = commitDelay.rect;
+5 -5
View File
@@ -487,11 +487,11 @@ Singleton {
if ((bind.modmask & modifier.bit) !== 0) if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name.toUpperCase()); parts.push(modifier.name.toUpperCase());
} }
// Guard against a bind with no key. Hyprland 0.57 refuses to create // A modifier-only bind has no key at all -- the window switcher commits
// one (the switcher's Super-release commit now names Super_L/Super_R), // on Super RELEASE. Appending an empty string left a dangling "SUPER + "
// but appending an empty string would leave a dangling "SUPER + " that // that matched neither the chord hypr/keybinds.lua binds nor the one an
// matches neither the chord hypr/keybinds.lua binds nor the one an // override would be keyed by, so that bind could never be rebound and
// override would be keyed by. // never found its category.
const key = String(bind.key ?? ""); const key = String(bind.key ?? "");
if (key !== "") if (key !== "")
parts.push(key); parts.push(key);
@@ -356,7 +356,6 @@ Singleton {
{ label: "AI assistant", detail: "Choose the agent this desktop hands crashes, failed reloads and red health checks to", page: "agents" }, { label: "AI assistant", detail: "Choose the agent this desktop hands crashes, failed reloads and red health checks to", page: "agents" },
{ label: "Claude Code", detail: "Use Claude Code as the agent the desktop escalates to, and watch its usage", page: "agents" }, { label: "Claude Code", detail: "Use Claude Code as the agent the desktop escalates to, and watch its usage", page: "agents" },
{ label: "Codex", detail: "Use Codex as the agent the desktop escalates to, and watch its usage", page: "agents" }, { label: "Codex", detail: "Use Codex as the agent the desktop escalates to, and watch its usage", page: "agents" },
{ label: "T3 Code", detail: "Open a T3 Code thread in the Panama project when the desktop escalates something", page: "agents" },
{ label: "Diagnose crashes", detail: "When a program dumps core, the notification carries a click that opens your AI assistant with the crash details", page: "agents" }, { label: "Diagnose crashes", detail: "When a program dumps core, the notification carries a click that opens your AI assistant with the crash details", page: "agents" },
{ label: "Ask the agent", detail: "A red System Health check with no repair left hands its snapshot to your AI assistant", page: "agents" }, { label: "Ask the agent", detail: "A red System Health check with no repair left hands its snapshot to your AI assistant", page: "agents" },
{ label: "Agent usage panel", detail: "Limits, resets and tokens for each agent, opened from the bar", page: "agents" }, { label: "Agent usage panel", detail: "Limits, resets and tokens for each agent, opened from the bar", page: "agents" },
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Agents in Settings. # @vicinae.description Open Agents in Settings.
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "t3 code"] # @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "diagnose crashes"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page agents exec "$HOME/.config/quickshell/scripts/panama-action" settings-page agents
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env zsh
# The Mac mini's shell. macOS stays on zsh: its bash is 3.2, and
# config/bash/shell leans on Linux-only tools (stat -c, ksshaskpass, the nvm
# profile script), so this is a small zsh counterpart rather than a port.
# Linked to ~/.zshrc. Homebrew's PATH comes from ~/.zprofile (brew shellenv).
export PANAMA_PATH="$HOME/.local/share/Panama"
export EDITOR="nvim"
export SUDO_EDITOR="$EDITOR"
export BUN_INSTALL="$HOME/.bun"
# ~/.opencode/bin is OpenCode 2's own installer location, ahead of Bun's bin.
export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$BUN_INSTALL/bin:$PANAMA_PATH/bin:$PATH"
# History shared across sessions, so tmux panes see each other's commands.
HISTFILE="$HOME/.zsh_history"
HISTSIZE=50000
SAVEHIST=50000
setopt SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE
# Emacs keys, like bash's readline: ctrl-a/e/r work as they do on Linux.
bindkey -e
autoload -Uz compinit && compinit
[ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun"
# Same SSH behaviour as config/bash/shell: an SSH login lands in a tmux
# session that survives a dropped connection. PANAMA_SSH_TMUX=off opts out.
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && -o interactive &&
"${PANAMA_SSH_TMUX:-on}" != "off" ]] && command -v tmux >/dev/null 2>&1; then
exec tmux new-session -A -s main
fi
alias c="clear"
alias n="nvim"
alias n.="nvim ."
alias ..="cd .."
alias ...="cd ../.."
alias ....="cd ../../.."
alias letscode="ranger ~/Documents/Documents/Code"
alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
alias ls='eza -lh --group-directories-first --icons=auto'
alias lsa='ls -a'
alias lt='eza --tree --level=2 --long --icons=auto --git'
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
compress() { tar -czf "${1%/}.tar.gz" "${1%/}"; }
alias decompress="tar -xzf"
# Xcode cannot sign from an SSH session while the login keychain is locked
# (errSecInternalComponent). Run this once per session before a build.
alias unlock-keychain='security unlock-keychain ~/Library/Keychains/login.keychain-db'
eval "$(zoxide init zsh)"
source <(fzf --zsh)
eval "$(oh-my-posh init zsh --config "$PANAMA_PATH/config/dot/ohmyposh/gib.omp.json")"
+2 -2
View File
@@ -28,11 +28,11 @@ Found on **System › Agents**.
| Setting | Default | What it does | | Setting | Default | What it does |
|---|---|---| |---|---|---|
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex, T3 Code. | | **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex. |
| **Offer to diagnose crashes**<br>`crashDiagnoseOffer` | true | When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand | | **Offer to diagnose crashes**<br>`crashDiagnoseOffer` | true | When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand |
| **Offer help when the shell fails to reload**<br>`reloadFailureOffer` | true | A broken change to the shell's own configuration offers the failing log to the agent | | **Offer help when the shell fails to reload**<br>`reloadFailureOffer` | true | A broken change to the shell's own configuration offers the failing log to the agent |
| **System Health hands off unrepairable checks**<br>`healthAgentHandoff` | true | A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot | | **System Health hands off unrepairable checks**<br>`healthAgentHandoff` | true | A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot |
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all | | **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
| **Collect Claude Code usage**<br>`agentUsageClaude` | true | Limits from Anthropic's usage endpoint, tokens from the local transcripts | | **Collect Claude Code usage**<br>`agentUsageClaude` | true | Limits from Anthropic's usage endpoint, tokens from the local transcripts |
| **Collect Codex usage**<br>`agentUsageCodex` | true | Limits over the Codex app-server, sessions from its local files | | **Collect Codex usage**<br>`agentUsageCodex` | true | Limits over the Codex app-server, sessions from its local files |
| **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. | | **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. |
+106 -105
View File
@@ -71,7 +71,7 @@ source "$PANAMA_PATH/bin/ascii"
# time. On an upgrade it is worth running only when its package lists or # time. On an upgrade it is worth running only when its package lists or
# reviewed installer trust inputs changed, so this hashes them and remembers # reviewed installer trust inputs changed, so this hashes them and remembers
# the result. The framed, sorted stream includes top-level package files, the # the result. The framed, sorted stream includes top-level package files, the
# package-stage adapter, every helper it sources, and regular provenance files; # package-stage adapter, the provenance helper, and regular provenance files;
# both relative paths and bytes are part of the state. # both relative paths and bytes are part of the state.
# #
# A content hash rather than a git range, because Panama is developed in place: # A content hash rather than a git range, because Panama is developed in place:
@@ -88,42 +88,75 @@ source "$PANAMA_PATH/bin/ascii"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
PACKAGES_HASH="$STATE_DIR/packages-hash" PACKAGES_HASH="$STATE_DIR/packages-hash"
hash_packages() { _collect_package_inputs() {
local file relative size fixed_input digest local destination="$1" raw="${1}.raw"
[[ -d "$PANAMA_PATH/setup/packages" \
for fixed_input in \ && ! -L "$PANAMA_PATH/setup/packages" \
"$PANAMA_PATH/setup/scripts/install-packages" \ && -d "$PANAMA_PATH/setup/provenance" \
"$PANAMA_PATH/setup/lib/artifact-provenance" \ && ! -L "$PANAMA_PATH/setup/provenance" ]] || return 1
"$PANAMA_PATH/setup/lib/chatgpt-package" \ {
"$PANAMA_PATH/setup/lib/extras-catalog" \ printf '%s\0' \
"$PANAMA_PATH/setup/lib/machine-role"; do "$PANAMA_PATH/setup/scripts/install-packages" \
[[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1 "$PANAMA_PATH/setup/lib/artifact-provenance" \
done "$PANAMA_PATH/setup/lib/extras-catalog" \
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
digest="$( # extras/ is deliberately excluded. A symlink or other non-directory
{ # object at this level is still an input error, not something discovery may
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1 # silently omit.
printf '%s\0' \ find "$PANAMA_PATH/setup/packages" -mindepth 1 -maxdepth 1 \
"$PANAMA_PATH/setup/scripts/install-packages" \ ! -type d -print0 || exit 1
"$PANAMA_PATH/setup/lib/artifact-provenance" \ find "$PANAMA_PATH/setup/provenance" -mindepth 1 \
"$PANAMA_PATH/setup/lib/chatgpt-package" \ ! -type d -print0 || exit 1
"$PANAMA_PATH/setup/lib/extras-catalog" \ } >"$raw" || return 1
"$PANAMA_PATH/setup/lib/machine-role" || exit 1 LC_ALL=C sort -z "$raw" >"$destination"
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
relative="${file#"$PANAMA_PATH"/}"
size="$(wc -c <"$file")" || exit 1
printf '%s\0%s\0' "$relative" "$size" || exit 1
cat -- "$file" || exit 1
printf '\0' || exit 1
done | sha256sum | cut -d' ' -f1
)" || return 1
printf '%s\n' "$digest"
} }
packages_needed() { _write_package_manifest() {
local current_hash="$1" recorded_hash local inputs="$1" destination="$2" file relative digest
: >"$destination" || return 1
while IFS= read -r -d '' file; do
[[ -f "$file" && ! -L "$file" && -r "$file" ]] || return 1
relative="${file#"$PANAMA_PATH"/}"
[[ "$relative" != "$file" ]] || return 1
digest="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
printf '%s\0%s\0' "$relative" "$digest" >>"$destination" || return 1
done <"$inputs"
}
# Read every input twice from the same enumerated set. A file or path that
# changes while the snapshot is built cannot produce a receipt.
hash_packages() (
local work="" inputs_before inputs_after manifest_before manifest_after
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
work="$(mktemp -u -d -t panama-packages-hash.XXXXXX)" || exit 1
if ! mkdir -m 700 -- "$work"; then
work=""
exit 1
fi
inputs_before="$work/inputs-before"
inputs_after="$work/inputs-after"
manifest_before="$work/manifest-before"
manifest_after="$work/manifest-after"
_collect_package_inputs "$inputs_before" || exit 1
_write_package_manifest "$inputs_before" "$manifest_before" || exit 1
_collect_package_inputs "$inputs_after" || exit 1
cmp -s -- "$inputs_before" "$inputs_after" || exit 1
_write_package_manifest "$inputs_after" "$manifest_after" || exit 1
cmp -s -- "$manifest_before" "$manifest_after" || exit 1
sha256sum -- "$manifest_before" | awk '{ print $1 }'
)
PACKAGE_START_HASH=""
packages_needed() {
local current_hash recorded_hash
current_hash="$(hash_packages)" || return 2
PACKAGE_START_HASH="$current_hash"
(( FORCE_PACKAGES )) && return 0 (( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0 (( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0 [[ -r "$PACKAGES_HASH" ]] || return 0
@@ -134,52 +167,41 @@ packages_needed() {
# Written only after the stage succeeds, mirroring the rule panama-migrate # Written only after the stage succeeds, mirroring the rule panama-migrate
# documents for its markers: a step that did not complete has not happened, and # documents for its markers: a step that did not complete has not happened, and
# recording it as done hides it forever. # recording it as done hides it forever.
record_packages_hash() { record_packages_hash() (
local starting_hash="$1" current_hash temporary_hash local expected_hash="$1" current_hash temporary_hash=""
trap '[[ -z "$temporary_hash" ]] || rm -f -- "$temporary_hash"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ "$expected_hash" =~ ^[0-9a-f]{64}$ ]] || return 1
current_hash="$(hash_packages)" || return 1 current_hash="$(hash_packages)" || return 1
[[ "$current_hash" == "$starting_hash" ]] || return 1 [[ "$current_hash" == "$expected_hash" ]] || return 1
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1 temporary_hash="$(mktemp -u "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then umask 077
mv -f -- "$temporary_hash" "$PACKAGES_HASH" if ! (set -o noclobber; : >"$temporary_hash") 2>/dev/null; then
else temporary_hash=""
rm -f -- "$temporary_hash"
return 1 return 1
fi fi
} printf '%s\n' "$expected_hash" >"$temporary_hash" || return 1
mv -f -- "$temporary_hash" "$PACKAGES_HASH" || return 1
temporary_hash=""
)
# Repository trust is checked before the installer can reach its bootstrap DNF. # Repository trust is checked before the installer can reach its bootstrap DNF.
# Status 78 is reserved for a trust-root failure. It suppresses every stage that # Status 78 is reserved for a trust-root failure and is propagated unchanged so
# opens DNF -- install-hardware included, which would otherwise pull drivers # no later stage, especially install-hardware, can invoke DNF with that repo.
# through the very repository in doubt -- and the run still exits 78 at the end.
#
# It suppresses nothing else. Linking dotfiles, skills and user content reads no
# repository and installs no package, and a machine whose Terra is in question
# still wants its configuration. Refusing the safe work because the unsafe work
# is unavailable does not make the machine safer, it just leaves the machine
# unconfigured with no way to fix itself. Exiting here instead meant link-skills
# never ran on a machine whose Terra was merely unadopted, so ~/.claude/skills
# stayed the whole-directory symlink it had been before skills were linked one
# by one, and not one shipped skill was reachable.
TERRA_TRUST_FAILURE_STATUS=78 TERRA_TRUST_FAILURE_STATUS=78
DNF_STAGES=(install-packages change-settings install-hardware)
package_trust_refused=0
stage_opens_dnf() {
local candidate="$1" dnf_stage
for dnf_stage in "${DNF_STAGES[@]}"; do
[[ "$candidate" == "$dnf_stage" ]] && return 0
done
return 1
}
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages" trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
if [[ ! -x "$trust_preflight" ]]; then if [[ ! -x "$trust_preflight" ]]; then
printf 'install: package repository trust preflight is unavailable\n' >&2 printf 'install: package repository trust preflight is unavailable\n' >&2
package_trust_refused=1 exit "$TERRA_TRUST_FAILURE_STATUS"
elif ! "$trust_preflight" --trust-preflight; then fi
if "$trust_preflight" --trust-preflight; then
:
else
trust_status=$?
printf 'install: package repository trust preflight failed\n' >&2 printf 'install: package repository trust preflight failed\n' >&2
package_trust_refused=1 exit "$trust_status"
fi fi
# ── The interview ──────────────────────────────────────────────────────────── # ── The interview ────────────────────────────────────────────────────────────
@@ -199,7 +221,7 @@ fi
# Gated exactly like the interview itself: under --upgrade no questions are # Gated exactly like the interview itself: under --upgrade no questions are
# asked, so nothing here is used, and a machine that cannot install gum must # asked, so nothing here is used, and a machine that cannot install gum must
# not have that stop an upgrade that never needed it. # not have that stop an upgrade that never needed it.
if (( ! UPGRADE && ! package_trust_refused )); then if (( ! UPGRADE )); then
bootstrap=() bootstrap=()
command -v gum >/dev/null 2>&1 || bootstrap+=(gum) command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
# The probe tools serve only the hardware questions, which a server is never # The probe tools serve only the hardware questions, which a server is never
@@ -214,7 +236,8 @@ if (( ! UPGRADE && ! package_trust_refused )); then
fi fi
if (( ${#bootstrap[@]} > 0 )); then if (( ${#bootstrap[@]} > 0 )); then
echo "Installing what the setup questions are built on: ${bootstrap[*]}" echo "Installing what the setup questions are built on: ${bootstrap[*]}"
sudo dnf install -y "${bootstrap[@]}" >/dev/null || { sudo dnf install -y --repo=fedora --repo=updates \
--from-repo=fedora,updates "${bootstrap[@]}" >/dev/null || {
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2 echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
exit 1 exit 1
} }
@@ -277,7 +300,12 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# is unset and each stage takes the empty-answer path it already documents -- # is unset and each stage takes the empty-answer path it already documents --
# which is why this is a flag rather than a rewrite of seven stage scripts. # which is why this is a flag rather than a rewrite of seven stage scripts.
if (( ! UPGRADE )); then if (( ! UPGRADE )); then
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" PANAMA_ANSWERS="$(mktemp -u -t panama-answers.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$PANAMA_ANSWERS") 2>/dev/null; then
PANAMA_ANSWERS=""
exit 1
fi
export PANAMA_ANSWERS export PANAMA_ANSWERS
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
@@ -343,7 +371,7 @@ fi
if [[ "$PANAMA_ROLE" == server ]]; then if [[ "$PANAMA_ROLE" == server ]]; then
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity) STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
else else
STAGES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts setup-identity install-hardware) STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
fi fi
# The two an upgrade drops. Both exist only to act on interview answers, and # The two an upgrade drops. Both exist only to act on interview answers, and
@@ -366,16 +394,11 @@ for stage in "${STAGES[@]}"; do
script="$PANAMA_PATH/setup/scripts/$stage" script="$PANAMA_PATH/setup/scripts/$stage"
[[ -x "$script" ]] || continue [[ -x "$script" ]] || continue
printf '\n=== %s ===\n' "$stage" printf '\n=== %s ===\n' "$stage"
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
echo "Skipped: the package repository trust check refused package work."
continue
fi
if [[ "$stage" == install-packages ]]; then if [[ "$stage" == install-packages ]]; then
package_start_hash=""
package_state_status=0 package_state_status=0
package_start_hash="$(hash_packages)" || package_state_status=2 packages_needed || package_state_status=$?
if (( package_state_status == 0 )); then package_start_hash="$PACKAGE_START_HASH"
packages_needed "$package_start_hash" || package_state_status=$?
fi
if (( package_state_status == 1 )); then if (( package_state_status == 1 )); then
echo "The package lists have not changed since the last run; skipping." echo "The package lists have not changed since the last run; skipping."
echo "Run with --packages to install them anyway." echo "Run with --packages to install them anyway."
@@ -395,12 +418,9 @@ for stage in "${STAGES[@]}"; do
fi fi
else else
stage_status=$? stage_status=$?
# A configuration change between the preflight and this stage. Suppress the
# remaining DNF stages, keep the safe ones, and carry the status to the end.
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2 printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
package_trust_refused=1 exit "$stage_status"
continue
fi fi
failed+=("$stage") failed+=("$stage")
printf '!!! %s failed\n' "$stage" >&2 printf '!!! %s failed\n' "$stage" >&2
@@ -426,16 +446,8 @@ done
# written for -- and baselining would skip every one of them forever. Every # written for -- and baselining would skip every one of them forever. Every
# migration is self-guarding and a no-op where it does not apply, so running # migration is self-guarding and a no-op where it does not apply, so running
# them is the safe direction. # them is the safe direction.
#
# Held back when package work was refused. A migration is free to run a DNF
# transaction -- the ChatGPT package replacement does exactly that -- so the
# repositories have to be trustworthy before any of them is allowed to run.
# They are not marked applied either, so the next run still has them pending.
migrate="$PANAMA_PATH/bin/panama-migrate" migrate="$PANAMA_PATH/bin/panama-migrate"
if (( package_trust_refused )) && [[ -x "$migrate" ]]; then if [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n'
echo "Skipped: the package repository trust check refused package work."
elif [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n' printf '\n=== migrations ===\n'
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
"$migrate" run || failed+=(migrations) "$migrate" run || failed+=(migrations)
@@ -483,17 +495,6 @@ else
retry='./install' retry='./install'
fi fi
# Reported last and on its own, because it is not an ordinary stage failure:
# everything safe did run, and what did not run is named rather than buried in a
# list. The exit status stays 78 so a caller can still tell the two apart.
if (( package_trust_refused )); then
printf 'Package work was refused: the Terra repository configuration on this\n' >&2
printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2
printf 'Everything that touches no repository was still applied.\n' >&2
printf 'Inspect it with: panama diagnose\n' >&2
exit "$TERRA_TRUST_FAILURE_STATUS"
fi
if (( ${#failed[@]} == 0 )); then if (( ${#failed[@]} == 0 )); then
if (( UPGRADE )); then if (( UPGRADE )); then
echo "Panama is up to date." echo "Panama is up to date."
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
# replace the community ChatGPT Desktop build with the official OpenAI package
#
# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop)
# from the upstream macOS disk image, complete with a local rebuild daemon.
# OpenAI ships an official Linux RPM now, and the installer takes that instead;
# this repairs machines still carrying the community build. The official
# package goes on before the community one comes off, so a failure part-way
# leaves the machine with an app, never without one.
#
# Rules, because the runner cannot enforce them:
#
# * Safe to run twice. The marker records success, not intent.
# * Tolerant of the repair already being correct -- the user may have fixed
# it by hand, or a later ./install may have put it back.
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
# so the password prompt names the repair.
# * Exit non-zero to be retried at the next login. Exit zero only when the
# machine is genuinely in the state this describes.
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# Machines that never had the community build are already correct. The official
# app is the installer's job, not this one's.
rpm -q codex-desktop >/dev/null 2>&1 || exit 0
# The same verified repository the installer establishes: the pinned signing
# key, then a repository that names it, so dnf checks OpenAI's signature before
# root installs anything. See setup/lib/chatgpt-package.
# shellcheck source=../setup/lib/chatgpt-package
source "$PANAMA_PATH/setup/lib/chatgpt-package"
sudo_cmd=(sudo)
if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then
sudo_cmd=(
"$PANAMA_PATH/bin/panama-sudo" --reason
"Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package"
--
)
fi
# The official package first, so the machine is never left without one.
if ! rpm -q chatgpt >/dev/null 2>&1; then
chatgpt_install_repository "${sudo_cmd[@]}"
"${sudo_cmd[@]}" dnf install -y chatgpt
fi
# The community package's updater is a user unit; stop it before dnf removes
# the unit file out from under it. Removal also takes the app in /opt, both
# binaries, and the polkit policy the local rebuilds needed.
systemctl --user disable --now codex-update-manager.service 2>/dev/null || true
"${sudo_cmd[@]}" dnf remove -y codex-desktop
systemctl --user daemon-reload 2>/dev/null || true
# The rebuild state the updater kept; the official package needs none of it.
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \
"${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \
"${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \
"${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager"
echo "Replaced the community codex-desktop build with the official chatgpt package."
+7 -74
View File
@@ -6,44 +6,18 @@ screen, plus rootless podman compose services managed as systemd user units.
``` ```
server/ server/
vps/ The catalog panama-server installs from, one directory containers/ One directory per service: compose.yml, the
per service: compose.yml, the podman-<name>.service user podman-<name>.service user unit, and an .env.example
unit, and an .env.example naming what the service needs told. naming what the service needs told
Loose files beside the directories are the VPS's own
host tooling (see "Host tooling" below)
home/ The home server's services in the same shape (see below)
scripts/ update-containers, the nightly image updater scripts/ update-containers, the nightly image updater
systemd/ The units behind it, linked by setup-server systemd/ The units behind it, linked by setup-server
``` ```
## The home server
`server/home/` mirrors `~/Server` on the home server, where the services still
run under Docker. Each directory has the same three files as the VPS catalog,
and the unit is drafted for the eventual move to rootless podman. It has never
run, and the composes still use Docker-only features (host networking, the
GPU, the Docker socket) that need converting first. Nothing here is
installable: panama-server reads `server/vps/` only, so a service moves into
the catalog when it actually moves to podman.
Paths keep their nesting (`server/home/Agents/Gitea` is `~/Server/Agents/Gitea`).
`Media/compact-tv/` is the nightly TV re-encode job that runs beside the Media
stack, with its own README.
Three services are checkouts of upstream repositories. For those, only the
local files are kept, and an `UPSTREAM` file names the repository and the exact
commit the checkout sits at: `PostHog`, `Sentry` and `Plausible`.
Both trees are snapshots of the live machines with every secret replaced by a
`${VAR}` named in `.env.example`, or by `CHANGE_ME` in files that are not
compose files. The live servers still carry some secrets inline, so a live
compose and its copy here can differ on those lines alone.
## The contract with ~/Server ## The contract with ~/Server
The repository carries the definitions; the machine carries the state. The repository carries the definitions; the machine carries the state.
`panama server enable <Name>` creates `~/Server/<Name>/` as a **real `panama server enable <Name>` creates `~/Server/<Name>/` as a **real
directory** and symlinks the service's visible top-level files into it: directory** and symlinks only `compose.yml` into it; the unit symlinks into
`compose.yml`, plus any helper script or README. The units symlink into
`~/.config/systemd/user/`. The `.env` (seeded once from `.env.example`, then `~/.config/systemd/user/`. The `.env` (seeded once from `.env.example`, then
yours) and the bind-mounted `data/` live in `~/Server/<Name>/` and are never yours) and the bind-mounted `data/` live in `~/Server/<Name>/` and are never
inside the checkout — so nothing git can do, `clean -fdx` included, can reach inside the checkout — so nothing git can do, `clean -fdx` included, can reach
@@ -61,18 +35,12 @@ is what `panama server enable` refuses to start on.
## Conventions ## Conventions
- Service directories are TitleCase, matching `~/Server` as it has always - Service directories are TitleCase, matching `~/Server` as it has always
looked: `server/vps/Gitea` becomes `~/Server/Gitea`. looked: `server/containers/Gitea` becomes `~/Server/Gitea`.
- Bind mounts live under `./data/` in new services. Imported services keep - Bind mounts live under `./data/` in new services. Imported services keep
the volume path they were running with until their cutover, because the the volume path they were running with until their cutover, because the
repo copy must render identically to the live one — renames happen with repo copy must render identically to the live one — renames happen with
the service stopped, not in git. the service stopped, not in git.
- Only 80, 443 and 81 are open, and `setup-server` opens each one only where - Only 80, 443 and 81 are open (setup-server's doing). Everything else is
nothing already grants it. A port reachable through an enabled service or an
accepting rich rule — a CDN-scoped grant, say — is left exactly as it is,
because turning a narrow grant into a public one during an unattended
`panama update` is not a decision an installer gets to make. A machine that
manages its own firewall opts out for good with
`echo manual > ~/.local/state/panama/firewall`. Everything else is
reverse-proxied over the `nginx-bridge` network by container name, with no reverse-proxied over the `nginx-bridge` network by container name, with no
published ports of its own. A service that truly needs another host port published ports of its own. A service that truly needs another host port
says so in a README in its directory, and the port is opened by hand. says so in a README in its directory, and the port is opened by hand.
@@ -99,46 +67,11 @@ ones — and verify it came back up. One service at a time, quiet hours.
## Adding a service ## Adding a service
Add `server/vps/<Name>/` with the three files, modelled on any Add `server/containers/<Name>/` with the three files, modelled on any
existing service. The unit is the standard oneshot `podman compose up -d` existing service. The unit is the standard oneshot `podman compose up -d`
shape (`WorkingDirectory=%h/Server/<Name>`); the shape contract pins the shape (`WorkingDirectory=%h/Server/<Name>`); the shape contract pins the
details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines. details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines.
A service that needs a scheduled job of its own carries it beside the
compose unit: a helper `<job>.service` and a `<job>.timer` with the same
name, plus the script it runs. Enable links all three and enables the timer.
Disable stops the timer and unlinks the units. `UseSend` is the example: its
`usesend-cert-refresh` timer keeps the SMTP proxy's certificate in step with
Nginx Proxy Manager's.
## Host tooling
Each server's own scripts and runbooks sit as loose files at the top of its
tree. They are reference copies for rebuilding a machine. Nothing installs
them.
- `vps/backup`, `vps/server-backup.service`, `vps/server-backup.timer`: the
VPS's nightly backup, run as a user unit. It dumps every database into
`~/Server/_backup_state/`, then rsyncs `~/Server` to the home server's ZFS
pool with `--fake-super`, so subuid ownership survives the trip.
- `vps/verify-rootless`: the post-reboot health check for the rootless
stack. It covers linger, the unprivileged port floor, unit state and boot
enablement, postgres, endpoints, and admin ports bound to the WireGuard
address.
- `vps/AGENTS.md`: the VPS runbook, with both public IPs redacted.
- `home/RESTORE.md`: how to restore the home server's `~/Server` from the
ZFS backup.
- `home/backup` and `home/systemd/`: the home server's backup trigger and its
root system units (`server-backup` nightly, `media-backup` weekly).
- `home/server-backup` and `home/media-backup`: the scripts those units run.
On that machine they live in `/usr/local/sbin`, root-owned, because a
root-run script must not sit in a directory the user can write to. Restore
them there, not into `~/Server`.
The VPS units carry `RequiresMountsFor=/home/gib/Media` because that host
keeps its podman graphroot on an attached volume mounted there. The setting
is harmless on a machine without the mount.
## Nightly updates ## Nightly updates
`server/scripts/update-containers` pulls images and restarts changed `server/scripts/update-containers` pulls images and restarts changed
File renamed without changes.
File renamed without changes.
@@ -18,8 +18,3 @@ AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10 AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain # Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=[email protected] AUTHENTIK_EMAIL__FROM=[email protected]
# Also set on the live machine
AUTHENTIK_IMAGE=CHANGE_ME
PG_DB=CHANGE_ME
PG_USER=CHANGE_ME
File renamed without changes.
@@ -35,11 +35,6 @@ services:
# No published port: NPM proxies to http://beszel:8090 over nginx-bridge. # No published port: NPM proxies to http://beszel:8090 over nginx-bridge.
environment: environment:
- TZ=America/New_York - TZ=America/New_York
# Login is SSO-only through Authentik (OIDC provider "Gibs Auth", app slug
# beszel). The OAuth config lives in data.db (users collection options),
# not here. The PocketBase superuser panel at /_/ keeps its own password,
# and is the way back in if Authentik is down: flip this to false there.
- DISABLE_PASSWORD_AUTH=true
# Lets the hub reach a host-network agent. Under rootless podman this resolves to # Lets the hub reach a host-network agent. Under rootless podman this resolves to
# the nginx-bridge gateway (172.18.0.1). Add the VPS in the hub UI with host # the nginx-bridge gateway (172.18.0.1). Add the VPS in the hub UI with host
# "host.docker.internal", port 45876. # "host.docker.internal", port 45876.
File renamed without changes.
@@ -7,7 +7,7 @@
# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack # BEFORE FIRST START, create its role and database on the SHARED postgres (this stack
# no longer ships its own): # no longer ships its own):
# podman exec -i postgresql psql -U npm <<'SQL' # podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE calcom LOGIN PASSWORD '617408b6790b546811cc38d0353ea506'; # CREATE ROLE calcom LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE calcom OWNER calcom; # CREATE DATABASE calcom OWNER calcom;
# SQL # SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql. # Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql.
@@ -52,7 +52,7 @@ services:
- EMAIL_SERVER_HOST=CHANGE_ME - EMAIL_SERVER_HOST=CHANGE_ME
- EMAIL_SERVER_PORT=587 - EMAIL_SERVER_PORT=587
- EMAIL_SERVER_USER=CHANGE_ME - EMAIL_SERVER_USER=CHANGE_ME
- EMAIL_SERVER_PASSWORD=CHANGE_ME - EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
labels: labels:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
tty: true tty: true
@@ -57,8 +57,3 @@ DO_NOT_REQUIRE_SSL=true
#ACTIONS_USER_TIMEOUT_SECS= #ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG= #RUST_LOG=
#RUST_BACKTRACE= #RUST_BACKTRACE=
# Also set on the live machine
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
@@ -29,7 +29,7 @@ services:
restart: unless-stopped restart: unless-stopped
convexmonorepo-backend: convexmonorepo-backend:
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest} image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend} container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}
hostname: ${BACKEND_CONTAINER_NAME:-convex-backend} hostname: ${BACKEND_CONTAINER_NAME:-convex-backend}
domainname: ${BACKEND_DOMAIN:-convex.gbrown.org} domainname: ${BACKEND_DOMAIN:-convex.gbrown.org}
@@ -58,7 +58,7 @@ services:
stop_signal: SIGINT stop_signal: SIGINT
convexmonorepo-dashboard: convexmonorepo-dashboard:
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest} image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard} container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard} hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.gbrown.org}} domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.gbrown.org}}
File renamed without changes.
@@ -9,7 +9,7 @@
# 1. Create its role and database on the SHARED postgres (this stack no longer # 1. Create its role and database on the SHARED postgres (this stack no longer
# ships its own): # ships its own):
# podman exec -i postgresql psql -U npm <<'SQL' # podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE documenso LOGIN PASSWORD 'e2bf60b34aff8ce259e5ed42da46c9c6'; # CREATE ROLE documenso LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE documenso OWNER documenso; # CREATE DATABASE documenso OWNER documenso;
# SQL # SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql so a # Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql so a
@@ -60,7 +60,7 @@ services:
- NEXT_PRIVATE_SMTP_HOST=CHANGE_ME - NEXT_PRIVATE_SMTP_HOST=CHANGE_ME
- NEXT_PRIVATE_SMTP_PORT=587 - NEXT_PRIVATE_SMTP_PORT=587
- NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME - NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME
- NEXT_PRIVATE_SMTP_PASSWORD=CHANGE_ME - NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD}
- NEXT_PRIVATE_SMTP_FROM_NAME=Documenso - NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
- [email protected] - [email protected]
labels: labels:
+25
View File
@@ -0,0 +1,25 @@
networks:
nginx-bridge:
external: true
services:
gitea:
image: docker.io/gitea/gitea:latest
container_name: gitea
hostname: gitea
domainname: git.gbrown.org
networks:
- nginx-bridge
ports:
- '2222:22'
environment:
- TZ=America/New_York
- USER_UID=1001
- USER_GID=1003
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volume:/data:Z
- /home/gib/Media/gitea-packages:/data/gitea/packages:Z
- ./ssh:/data/git/.ssh:Z
tty: true
restart: unless-stopped
@@ -140,6 +140,3 @@ KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false
# ClickHouse (optional) for audit log storage # ClickHouse (optional) for audit log storage
# CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical # CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical
# Also set on the live machine
CLIENT_GITLAB_LOGIN_URL=CHANGE_ME
File renamed without changes.
@@ -62,11 +62,3 @@ POSTGRES_CONTAINER_NAME=lashaddict-postgres
POSTGRES_USER=gib POSTGRES_USER=gib
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=lashaddict-payload POSTGRES_DB=lashaddict-payload
# Also set on the live machine
NEXT_PUBLIC_POSTHOG_KEY=CHANGE_ME
NEXT_PUBLIC_POSTHOG_HOST=CHANGE_ME
NEXT_PUBLIC_PLAUSIBLE_SRC=CHANGE_ME
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
@@ -6,12 +6,6 @@ services:
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
container_name: ${NEXT_CONTAINER_NAME} container_name: ${NEXT_CONTAINER_NAME}
hostname: ${NEXT_CONTAINER_NAME} hostname: ${NEXT_CONTAINER_NAME}
# Upstream DNS pinned to Cloudflare: Hetzner resolvers held a stale
# NXDOMAIN for api.lashaddictstudio.com for hours after it was created
# (2026-09-30). Container names still resolve through aardvark-dns.
dns:
- 1.1.1.1
- 1.0.0.1
domainname: ${NEXT_DOMAIN} domainname: ${NEXT_DOMAIN}
networks: networks:
- ${NETWORK:-nginx-bridge} - ${NETWORK:-nginx-bridge}
@@ -19,7 +13,7 @@ services:
- NODE_ENV=${NODE_ENV} - NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${CONVEX_SERVER_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}} - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org} - NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
@@ -33,13 +27,8 @@ services:
stdin_open: true stdin_open: true
restart: unless-stopped restart: unless-stopped
lashaddict-backend: lashaddict-backend:
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest} image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend} container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
dns:
- 1.1.1.1
- 1.0.0.1
hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend} hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org} domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org}
networks: networks:
@@ -67,13 +56,8 @@ services:
stop_grace_period: 10s stop_grace_period: 10s
stop_signal: SIGINT stop_signal: SIGINT
lashaddict-dashboard: lashaddict-dashboard:
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest} image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard} container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
dns:
- 1.1.1.1
- 1.0.0.1
hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard} hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}} domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}}
networks: networks:
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -37,6 +37,7 @@ services:
#networks: ['nginx-bridge'] #networks: ['nginx-bridge']
#environment: #environment:
#POSTGRES_USER: 'npm' #POSTGRES_USER: 'npm'
#POSTGRES_PASSWORD: '<see this service .env on the machine>'
#POSTGRES_DB: 'npm' #POSTGRES_DB: 'npm'
#labels: #labels:
#com.centurylinklabs.watchtower.enable: 'true' #com.centurylinklabs.watchtower.enable: 'true'
+2
View File
@@ -0,0 +1,2 @@
PENPOT_SECRET_KEY=CHANGE_ME
PENPOT_DATABASE_PASSWORD=CHANGE_ME
+120
View File
@@ -0,0 +1,120 @@
# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12.
#
# ⚠️ NOT STARTED. No systemd unit is enabled. It has never run on either machine, so
# there is no data to migrate. Bring it up with:
# systemctl --user enable --now podman-penpot.service
#
# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack
# no longer ships its own):
# podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE penpot LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE penpot OWNER penpot;
# SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql.
#
# NOTE: the home version used postgres:15-alpine with --data-checksums. The shared
# instance is postgres 17 without checksums. Penpot does not care, and since there is
# no existing data this is not a migration — but if you ever DO need checksums, they
# can only be set at initdb time for the whole cluster.
#
# Redis stays local to this stack: Penpot uses it for transient session and
# notification state only, so it is not worth centralising.
#
# Rootless adaptations from the home version:
# - Dropped its private postgres:15-alpine; uses the shared instance (AGENTS.md §2).
# - PENPOT_DATABASE_URI repointed from penpot-db to the shared postgresql.
# - :Z on the assets bind mount. It is shared between frontend and backend, so it
# uses LOWERCASE :z -- uppercase would give each container a private MCS category
# and the second to start would lock the first out (AGENTS.md §6).
# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8).
#
# Registration is disabled by default (PENPOT_FLAGS disable-registration), so create
# the first account from the backend container before you can log in.
#
# NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080
networks:
nginx-bridge:
external: true
services:
penpot-frontend:
image: docker.io/penpotapp/frontend:latest
container_name: penpot-frontend
hostname: penpot-frontend
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/assets:/opt/data/assets:z
depends_on:
- penpot-backend
- penpot-exporter
tty: true
stdin_open: true
restart: unless-stopped
penpot-backend:
image: docker.io/penpotapp/backend:latest
container_name: penpot-backend
hostname: penpot-backend
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp enable-prepl-server
- PENPOT_PUBLIC_URI=https://penpot.gbrown.org
- PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY}
# Shared postgres, not a private container
- PENPOT_DATABASE_URI=postgresql://postgresql/penpot
- PENPOT_DATABASE_USERNAME=penpot
- PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD}
- PENPOT_REDIS_URI=redis://penpot-redis/0
- PENPOT_ASSETS_STORAGE_BACKEND=assets-fs
- PENPOT_STORAGE_ASSETS_FS_DIRECTORY=/opt/data/assets
- PENPOT_TELEMETRY_ENABLED=false
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/assets:/opt/data/assets:z
depends_on:
- penpot-redis
tty: true
stdin_open: true
restart: unless-stopped
penpot-exporter:
image: docker.io/penpotapp/exporter:latest
container_name: penpot-exporter
hostname: penpot-exporter
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_PUBLIC_URI=http://penpot-frontend:8080
- PENPOT_REDIS_URI=redis://penpot-redis/0
labels:
com.centurylinklabs.watchtower.enable: "true"
depends_on:
- penpot-redis
tty: true
stdin_open: true
restart: unless-stopped
penpot-redis:
image: docker.io/library/redis:7-alpine
container_name: penpot-redis
hostname: penpot-redis
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
stdin_open: true
restart: unless-stopped
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
@@ -72,7 +72,7 @@ services:
- RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org - RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org - RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org - RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org
- RUSTDESK_API_RUSTDESK_KEY=${RUSTDESK_API_RUSTDESK_KEY} - RUSTDESK_API_RUSTDESK_KEY=WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=
- RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY} - RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY}
- RUSTDESK_API_LANG=en - RUSTDESK_API_LANG=en
- RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true - RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true
@@ -64,6 +64,3 @@ SPOON_AGENT_WORKER_ID=production-worker
SPOON_AGENT_WORKER_HTTP_PORT="3921" SPOON_AGENT_WORKER_HTTP_PORT="3921"
SPOON_AGENT_WORKER_INTERNAL_TOKEN=CHANGE_ME SPOON_AGENT_WORKER_INTERNAL_TOKEN=CHANGE_ME
SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921 SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921
# Also set on the live machine
PORT=CHANGE_ME
@@ -64,7 +64,7 @@ services:
security_opt: security_opt:
- label:disable - label:disable
spoon-backend: spoon-backend:
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest} image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend} container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend}
hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend} hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend}
domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org} domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org}
@@ -93,7 +93,7 @@ services:
stop_grace_period: 10s stop_grace_period: 10s
stop_signal: SIGINT stop_signal: SIGINT
spoon-dashboard: spoon-dashboard:
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest} image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard} container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard} hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}} domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}}
@@ -56,8 +56,3 @@ POSTGRES_URL=CHANGE_ME
#ACTIONS_USER_TIMEOUT_SECS= #ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG= #RUST_LOG=
#RUST_BACKTRACE= #RUST_BACKTRACE=
# Also set on the live machine
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
@@ -29,7 +29,7 @@ services:
restart: unless-stopped restart: unless-stopped
stpeteit-backend: stpeteit-backend:
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest} image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-stpeteit-backend} container_name: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
hostname: ${BACKEND_CONTAINER_NAME:-stpeteit-backend} hostname: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
domainname: ${BACKEND_DOMAIN:-convex.stpeteit.com} domainname: ${BACKEND_DOMAIN:-convex.stpeteit.com}
@@ -58,7 +58,7 @@ services:
stop_signal: SIGINT stop_signal: SIGINT
stpeteit-dashboard: stpeteit-dashboard:
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest} image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard} container_name: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard} hostname: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.stpete.com}} domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.stpete.com}}
@@ -19,7 +19,7 @@ services:
container_name: convertx container_name: convertx
hostname: convertx hostname: convertx
domainname: convert.tools.gbrown.org domainname: convert.tools.gbrown.org
networks: ["nginx-bridge"] networks: ['nginx-bridge']
environment: environment:
- TZ=America/New_York - TZ=America/New_York
- ALLOW_UNAUTHENTICATED=true - ALLOW_UNAUTHENTICATED=true
@@ -37,7 +37,7 @@ services:
container_name: it-tools container_name: it-tools
hostname: it-tools hostname: it-tools
domainname: tools.gbrown.org domainname: tools.gbrown.org
networks: ["nginx-bridge"] networks: ['nginx-bridge']
environment: environment:
- TZ=America/New_York - TZ=America/New_York
labels: labels:
@@ -45,12 +45,33 @@ services:
tty: true tty: true
restart: unless-stopped restart: unless-stopped
pdf-tools:
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
container_name: pdf-tools
hostname: pdf-tools
domainname: pdf.tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- LANGS=en_US
- DOCKER_ENABLE_SECURITY=true
volumes:
- ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z
- ./volumes/pdf-tools/extraConfigs:/configs:Z
- ./volumes/pdf-tools/customFiles:/customFiles:Z
- ./volumes/pdf-tools/logs:/logs/:Z
- ./volumes/pdf-tools/pipeline:/pipeline/:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
bento-pdf: bento-pdf:
image: ghcr.io/alam00000/bentopdf:latest image: ghcr.io/alam00000/bentopdf:latest
container_name: bento-pdf container_name: bento-pdf
hostname: bento-pdf hostname: bento-pdf
domainname: bento.tools.gbrown.org domainname: bento.tools.gbrown.org
networks: ["nginx-bridge"] networks: ['nginx-bridge']
environment: environment:
- TZ=America/New_York - TZ=America/New_York
- PUID=1000 - PUID=1000
@@ -61,28 +82,7 @@ services:
tty: true tty: true
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080"] test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080']
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
# pdf-tools:
# image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
# container_name: pdf-tools
# hostname: pdf-tools
# domainname: pdf.tools.gbrown.org
# networks: ["nginx-bridge"]
# environment:
# - TZ=America/New_York
# - LANGS=en_US
# - DOCKER_ENABLE_SECURITY=true
# volumes:
# - ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z
# - ./volumes/pdf-tools/extraConfigs:/configs:Z
# - ./volumes/pdf-tools/customFiles:/customFiles:Z
# - ./volumes/pdf-tools/logs:/logs/:Z
# - ./volumes/pdf-tools/pipeline:/pipeline/:Z
# labels:
# com.centurylinklabs.watchtower.enable: "true"
# tty: true
# restart: unless-stopped
File renamed without changes.
@@ -8,22 +8,15 @@ AWS_DEFAULT_REGION=us-east-2
DATABASE_URL=CHANGE_ME DATABASE_URL=CHANGE_ME
GITHUB_ID=Ov23liz34colyJed9t9S GITHUB_ID=Ov23liz34colyJed9t9S
GITHUB_SECRET=CHANGE_ME GITHUB_SECRET=CHANGE_ME
MINIO_ROOT_USER=usesend
MINIO_ROOT_PASSWORD=CHANGE_ME
NEXTAUTH_SECRET=CHANGE_ME NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_IS_CLOUD=false NEXT_PUBLIC_IS_CLOUD=false
POSTGRES_USER=usesend POSTGRES_USER=usesend
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=usesend POSTGRES_DB=usesend
REDIS_URL=redis://usesend-redis:6379 REDIS_URL=redis://usesend-redis:6379
RUSTFS_ACCESS_KEY=CHANGE_ME
RUSTFS_SECRET_KEY=CHANGE_ME
S3_COMPATIBLE_API_URL=https://storage.usesend.gbrown.org
S3_COMPATIBLE_PUBLIC_URL=https://storage.usesend.gbrown.org/unsend
S3_COMPATIBLE_BUCKET=unsend
SMTP_HOST=smtp.usesend.gbrown.org SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend SMTP_USER=usesend
USESEND_PORT=3000 USESEND_PORT=3000
USESEND_URL=https://usesend.gbrown.org USESEND_URL=https://usesend.gbrown.org
# Also set on the live machine
AWS_ACCESS_KEY_ID=CHANGE_ME
AWS_SECRET_ACCESS_KEY=CHANGE_ME
+88
View File
@@ -0,0 +1,88 @@
name: usesend
networks:
nginx-bridge:
external: true
services:
redis:
image: redis:7
container_name: usesend-redis
hostname: usesend-redis
networks:
- nginx-bridge
env_file: .env
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volumes/redis:/data:Z
command:
- redis-server
- --maxmemory-policy
- noeviction
tty: true
stdin_open: true
restart: unless-stopped
minio:
image: minio/minio
container_name: usesend-storage
hostname: usesend-storage
networks:
- nginx-bridge
env_file: .env
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volumes/minio:/data:Z
entrypoint: sh
command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002''
tty: true
stdin_open: true
restart: unless-stopped
smtp-server:
image: usesend/smtp-proxy:v1.6.6
container_name: usesend-smtp-server
hostname: usesend-smtp
networks:
- nginx-bridge
env_file: .env
environment:
- SMTP_AUTH_USERNAME=${SMTP_USER:?err}
- USESEND_BASE_URL=${USESEND_URL:?err}
labels:
com.centurylinklabs.watchtower.enable: 'true'
tty: true
stdin_open: true
restart: unless-stopped
usesend:
image: usesend/usesend:v1.6.6
container_name: usesend
hostname: usesend
domainname: ${USESEND_URL:?err}
networks:
- nginx-bridge
env_file: .env
environment:
- PORT=${USESEND_PORT:-3000}
- DATABASE_URL=${DATABASE_URL:?err}
- NEXTAUTH_URL=${USESEND_URL:?err}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err}
- AWS_ACCESS_KEY=${AWS_ACCESS_KEY:?err}
- AWS_SECRET_KEY=${AWS_SECRET_KEY:?err}
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
- GITHUB_ID=${GITHUB_ID:?err}
- GITHUB_SECRET=${GITHUB_SECRET:?err}
- REDIS_URL=${REDIS_URL:?err}
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
- SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org}
- SMTP_USER=${SMTP_USER:-usesend}
labels:
com.centurylinklabs.watchtower.enable: 'true'
depends_on:
redis:
condition: service_started
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1 @@
ADMIN_TOKEN=CHANGE_ME
+55
View File
@@ -0,0 +1,55 @@
# Vaultwarden — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/vaultwarden,
# staged 2026-08-12.
#
# ⚠️ STAGED, NOT AUTHORITATIVE. The home instance is still live and still serving
# vault.gbrown.org. This copy holds a point-in-time snapshot of the SQLite database.
# DO NOT log into this one and add credentials before cutover -- the two would diverge
# and one side's changes would be lost.
#
# Cutover procedure (do it in this order, it matters):
# 1. stop vaultwarden on the home server (clean SQLite, no torn WAL)
# 2. rsync -a server.gib:~/Server/vaultwarden/volume/ ~/Server/Vaultwarden/volume/
# 3. systemctl --user restart podman-vaultwarden
# 4. point vault.gbrown.org at the VPS, add the NPM proxy host
# 5. verify a login + an item decrypts, THEN leave home stopped
#
# The SQLite copy taken while home was running is crash-consistent only. Step 1 is what
# makes it clean -- don't skip it.
#
# Rootless adaptations:
# - :Z on the data mount (SELinux Enforcing).
# - /etc/localtime mount dropped in favour of TZ (AGENTS.md §8).
# - No published port; NPM proxies to http://vaultwarden:80 over nginx-bridge.
# - Image starts as root and drops privileges internally, so no userns_mode needed.
#
# NOTE: ADMIN_TOKEN is inline here, matching the home server's file verbatim. It grants
# access to /admin. Worth moving to a mode-600 .env at some point -- compose files get
# rsynced offsite by ~/Server/backup.
networks:
nginx-bridge:
external: true
services:
vaultwarden:
image: docker.io/vaultwarden/server:latest
container_name: vaultwarden
hostname: vaultwarden
domainname: vault.gbrown.org
networks:
- nginx-bridge
environment:
- TZ=America/New_York
- DOMAIN=https://vault.gbrown.org
- LOGIN_RATELIMIT_MAX_BURST=10
- LOGIN_RATELIMIT_SECONDS=60
- WEB_VAULT_ENABLED=true
- SIGNUPS_ALLOWED=false
- WEBSOCKET_ENABLED=true
- ADMIN_TOKEN=${ADMIN_TOKEN}
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volume:/data:Z
tty: true
restart: unless-stopped
-48
View File
@@ -1,48 +0,0 @@
# Beszel AGENT ONLY — the hub now lives on the VPS.
#
# The hub (web UI + database) was moved to the VPS on 2026-08-11 and is reachable at
# https://beszel.gibbyb.com. This host keeps only the agent, which collects metrics and
# serves them to that hub over the WireGuard link (VPS 192.168.2.2 -> here 192.168.1.2:45876).
#
# KEY below is the VPS hub's public key. If the VPS hub is ever rebuilt its keypair
# changes, and this value must be updated to match:
# ssh-keygen -y -f ~/Server/Beszel/volumes/hub/id_ed25519 (run on the VPS)
#
# Previous config, including the local hub service, is in compose.yml.bak-2026-08-11.
#
# The agent uses network_mode: host — that is the documented setup and is what lets it
# read real host network interfaces rather than a container's veth. Docker forbids setting
# hostname/domainname together with host networking, so those keys are absent.
services:
beszel-agent:
image: henrygd/beszel-agent-nvidia:latest
container_name: beszel-agent
network_mode: host
# Give the agent read access to NVIDIA's management interface so Beszel can
# report GPU utilization, temperature, power draw, and VRAM usage.
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [utility]
environment:
- TZ=America/New_York
- PORT=45876
- KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBWNqxKGRltqXJZg+wlDOtICeLo19ftQ6P3P8+uJg8y
# /boot is a separate 974M partition (nvme1n1p1), not part of the LUKS
# root the agent finds on its own. It filling up is what broke a 1032
# package dnf transaction on 2026-08-09, so it is worth alerting on.
- EXTRA_FILESYSTEMS=/boot
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/agent:/var/lib/beszel-agent
- /var/run/docker.sock:/var/run/docker.sock:ro
- /boot:/boot:ro
- /etc/localtime:/etc/localtime:ro
tty: true
stdin_open: true
restart: unless-stopped
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more