Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ omacase uninstall remove Omacase-managed config; keep applications
Set `OMACASE_DRYRUN=1` before `omacase install` to preview changes. Set
`OMACASE_SKIP_MISE_UPGRADE=1` before `omacase update` to leave mise-managed
tools at their current versions. Set `OMACASE_INSTALL_GROK=1` to explicitly
allow xAI's unpinned Grok installer; it is skipped by default.
allow xAI's unpinned Grok installer; it is skipped by default. Set
`OMACASE_CHANNEL=dev` to make `omacase update` pull the default branch
(maintainer machines); the default `stable` channel checks out the latest
`v*` release tag. `omacase update --check` lists pending changes without
applying them; `omacase update --rollback` returns to the previous payload.

## Agent multiplexing

Expand Down
34 changes: 34 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Releasing Omacase

Versioned tags are the unit of review. Public installs (`OMACASE_CHANNEL=stable`,
the default) check out the greatest `v*` tag. Maintainer machines set
`OMACASE_CHANNEL=dev` and keep `git pull --ff-only` on the default branch.

## Cut a release

1. Bump `VERSION` to `X.Y.Z`.
2. Commit the bump (and any pin updates below) on `main`.
3. `git tag -a vX.Y.Z -m "<notes>"`
4. `git push --follow-tags`

The first stable target after this model landed is `v0.2.0` (matches `VERSION`).

## Homebrew installer pin

`boot.sh` / `site/install` fetch a **commit-pinned** `install.sh` from
`Homebrew/install` (that repo has no tags) and verify its sha256. When the
installer needs a bump:

1. Pick a reviewed commit on `Homebrew/install`.
2. `curl -fsSL https://raw.githubusercontent.com/Homebrew/install/<commit>/install.sh | shasum -a 256`
3. Update `HOMEBREW_INSTALLER_VERSION` and `HOMEBREW_INSTALLER_SHA256` in
`boot.sh`, then `cp boot.sh site/install`.

A checksum mismatch fails closed and tells the user to update Omacase or
install Homebrew by hand from brew.sh.

## mise / npm pins

`home/dot_config/mise/config.toml` uses exact versions. `mise outdated` lists
candidates. Bump pins in a dedicated commit; do not restore `@latest`.
Do not pin tools that self-update by design (Claude Code, grok).
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Security

Omacase installs software and runs it on a personal Mac. The trust boundary is
the **reviewed git tag** (`vX.Y.Z`). `OMACASE_CHANNEL=dev` opts out of that
and tracks the default branch.

There is no GPG/SSH tag-signature verification yet. Distributing a signing key
inside this same repository is circular; revisit when there is an out-of-band
channel (for example a Homebrew formula). TLS protects transport.

## Trust model

| Surface | Mutable? | Why |
|---|---|---|
| Omacase payload (`omacase update`) | **Pinned** on `stable` to the greatest `v*` tag. `dev` pulls the default branch. | Tags are the unit of review. `--check` inspects pending changes; `--rollback` returns one SHA. |
| Homebrew installer (`boot.sh`) | **Pinned** to a `Homebrew/install` commit + sha256 | That repo has no tags. Fail closed on mismatch; install Homebrew from brew.sh by hand if needed. |
| Homebrew formulae / casks | **Mutable** by design | Brew's own trust chain. Brewfile pins names, not versions; brew has no supported lockfile. |
| mise / npm CLIs | **Pinned** to exact versions in `mise/config.toml` | Bumps are commits. `mise upgrade` converges to pins. |
| Claude Code | **Vendor-rolling** | Self-updating, vendor-signed. Out of mise. |
| Grok CLI | **Vendor-rolling, opt-in** | `OMACASE_INSTALL_GROK=1`. Installer is unversioned; a checksum would break on every vendor release with no signal to us. Opt-in is the control. |
| Omarchy theme assets (`$OMACASE_DATA/upstream`) | Content, not code | Parsed as TOML / images, never executed. |
| herdr tap | Maintainer-owned | Declared third-party tap, trusted by exact formula/cask name. |
2 changes: 1 addition & 1 deletion bin/omacase
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Omacase — opinionated tiling macOS, managed from one command
usage: omacase <command> [args]

install Full idempotent setup (re-runnable)
update git pull + brew bundle + re-apply dotfiles & defaults + migrations
update Apply latest payload (stable tag or dev pull) + brew + mise [--check|--rollback]
outdated Print the count of outdated Homebrew packages
migrate Apply pending one-time migrations (also run by update)
theme [name] Apply a theme everywhere (no name = list/pick)
Expand Down
58 changes: 55 additions & 3 deletions boot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,14 @@ if ! command -v brew >/dev/null 2>&1; then
installer="$(mktemp)"
trap 'rm -f "$installer"' EXIT
info "Installing Homebrew…"
# Homebrew/install publishes no tags; pin a reviewed commit + sha256.
HOMEBREW_INSTALLER_VERSION=cced90146ea6d3057c03a636b668fef177415eb3
HOMEBREW_INSTALLER_SHA256=12479a24be3f5307eecac7cde670fad7118640f031229e964f544b1367b52a41
curl --proto '=https' --tlsv1.2 -fsSL \
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh \
"https://raw.githubusercontent.com/Homebrew/install/${HOMEBREW_INSTALLER_VERSION}/install.sh" \
-o "$installer"
printf '%s %s\n' "$HOMEBREW_INSTALLER_SHA256" "$installer" | shasum -a 256 -c -- >/dev/null 2>&1 \
|| abort "Homebrew installer checksum mismatch — refusing to run it. (Upstream may have released a new version; update omacase or install Homebrew manually from brew.sh, then re-run.)"
NONINTERACTIVE=1 /bin/bash "$installer"
rm -f "$installer"
trap - EXIT
Expand All @@ -79,6 +84,52 @@ else
fi

# 3. Clone or update the payload.
# stable (default) checks out the greatest v* tag; OMACASE_CHANNEL=dev tracks
# the default branch. A missing tag (pre-first-release) stays on the default
# branch rather than aborting bootstrap.
_omacase_remote_default_branch() {
local root="$1" ref
ref="$(git -C "$root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
ref="${ref#origin/}"
printf '%s\n' "${ref:-main}"
}

# stable leaves a detached tag checkout. Dev must attach to the remote
# default branch without reset --hard / checkout -B (those discard work).
_omacase_attach_dev() {
local root="$1" branch
branch="$(_omacase_remote_default_branch "$root")"
git -C "$root" fetch origin "$branch"
if git -C "$root" symbolic-ref -q HEAD >/dev/null; then
git -C "$root" merge --ff-only "origin/$branch"
return
fi
info "Attaching detached checkout to origin/$branch (dev channel)…"
if git -C "$root" show-ref --verify --quiet "refs/heads/$branch"; then
git -C "$root" checkout -q "$branch" \
|| abort "Could not check out $branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable."
else
git -C "$root" checkout -q --track "origin/$branch" \
|| abort "Could not attach to origin/$branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable."
fi
git -C "$root" merge --ff-only "origin/$branch" \
|| abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating."
}

_omacase_checkout_channel() {
local root="$1" tag
if [ "${OMACASE_CHANNEL:-stable}" = dev ]; then
_omacase_attach_dev "$root"
return
fi
git -C "$root" fetch --tags --depth 1 origin 2>/dev/null || true
tag="$(git -C "$root" tag --list 'v*' --sort=-v:refname | head -1)"
if [ -n "$tag" ]; then
git -C "$root" checkout -q "$tag"
else
info "No release tags found; staying on the default branch (set OMACASE_CHANNEL=dev to keep tracking it)."
fi
}
# Older public installs cloned into the data root itself. Keep using that
# checkout rather than forking a second copy next to its caches.
if [ -z "${OMACASE_PREFIX:-}" ] && [ -d "$HOME/.local/share/omacase/.git" ]; then
Expand All @@ -88,11 +139,12 @@ fi
if [ -d "$PREFIX/.git" ]; then
info "Updating existing omacase payload at $PREFIX…"
_recover_legacy_login_items "$PREFIX"
git -C "$PREFIX" pull --ff-only
_omacase_checkout_channel "$PREFIX"
else
info "Cloning omacase → $PREFIX…"
mkdir -p "$(dirname "$PREFIX")"
git clone --depth 1 "$REPO" "$PREFIX"
git clone --tags --depth 1 "$REPO" "$PREFIX"
_omacase_checkout_channel "$PREFIX"
fi

# 4. Hand off.
Expand Down
7 changes: 6 additions & 1 deletion completions/_omacase
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ _omacase() {
command)
local -a subcommands=(
'install:full idempotent setup (re-runnable)'
'update:git pull + brew bundle + re-apply dotfiles & defaults'
'update:apply latest payload + brew + mise (--check / --rollback)'
'migrate:apply pending one-time migrations (also run by update)'
'outdated:print the count of outdated Homebrew packages'
'theme:apply a theme everywhere (no name = list/pick)'
Expand Down Expand Up @@ -79,6 +79,11 @@ _omacase() {
wm)
_values 'wm action' 'menu[open the OmniWM app menu]' 'palette[open the OmniWM command palette]' 'settings[open the OmniWM settings window]' && ret=0
;;
update)
_values 'update flag' \
'--check[fetch and list pending changes without applying]' \
'--rollback[return to the SHA from the last payload switch]' && ret=0
;;
restore)
local backups=${OMACASE_STATE:-$HOME/.local/state/omacase}/backups
local -a snapshots=( $backups/*(N/:t) ) flags=( '--list:list snapshots' )
Expand Down
12 changes: 6 additions & 6 deletions home/dot_config/mise/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
# PATH via its shims.
#
# Why these live here and not in the Brewfile: they ship on npm sooner than they
# reach Homebrew. Pinned to "latest"; `omacase update` runs `mise upgrade` to pull
# the newest versions the moment they're published — no waiting on a brew bump.
# reach Homebrew. Versions are exact pins; bump them deliberately (see
# RELEASING.md). `omacase update` runs `mise upgrade` to converge to the pins.
# (Self-updating tools like Claude Code stay on their own installer; native
# binaries like codex stay on Homebrew.)
[tools]
node = "lts" # one isolated node; runs the npm: CLIs below
"npm:@google/gemini-cli" = "latest" # gemini — Google Gemini CLI
"npm:@mermaid-js/mermaid-cli" = "latest" # mmdc — Mermaid diagram renderer
"npm:@earendil-works/pi-coding-agent" = "latest" # pi — pi.dev coding agent (successor to @mariozechner/*)
node = "24.19.0" # one isolated node; runs the npm: CLIs below
"npm:@google/gemini-cli" = "0.55.1" # gemini — Google Gemini CLI
"npm:@mermaid-js/mermaid-cli" = "11.16.0" # mmdc — Mermaid diagram renderer
"npm:@earendil-works/pi-coding-agent" = "0.84.2" # pi — pi.dev coding agent (successor to @mariozechner/*)
156 changes: 150 additions & 6 deletions lib/update.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,158 @@
# shellcheck shell=bash
# `omacase update` — pull latest payload, then re-run the install engine.
# `omacase update` — move the payload to the selected channel, then re-run
# the install engine.
#
# OMACASE_CHANNEL=stable (default) fetch tags, check out the greatest v*
# OMACASE_CHANNEL=dev pull --ff-only on the default branch
#
# omacase update --check fetch and print pending changes; no checkout
# omacase update --rollback return to the SHA recorded before the
# last payload switch, then re-run install

OMACASE_CHANNEL="${OMACASE_CHANNEL:-stable}"

_latest_release_tag() {
git -C "$OMACASE_ROOT" tag --list 'v*' --sort=-v:refname | head -1
}

_current_exact_tag() {
git -C "$OMACASE_ROOT" describe --tags --exact-match 2>/dev/null || true
}

_update_record_prev() {
is_dryrun && return 0
mkdir -p "$OMACASE_STATE"
git -C "$OMACASE_ROOT" rev-parse HEAD > "$OMACASE_STATE/update-prev"
}

_update_target_ref() {
if [ "$OMACASE_CHANNEL" = dev ]; then
git -C "$OMACASE_ROOT" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null \
|| printf '%s\n' origin/main
else
_latest_release_tag
fi
}

_update_check() {
[ -d "$OMACASE_ROOT/.git" ] || abort "No git checkout at $OMACASE_ROOT."
if [ "$OMACASE_CHANNEL" = dev ]; then
git -C "$OMACASE_ROOT" fetch origin
else
git -C "$OMACASE_ROOT" fetch --tags origin
fi
local target current tag
target="$(_update_target_ref)"
current="$(git -C "$OMACASE_ROOT" rev-parse --short HEAD)"
[ -n "$target" ] || abort "No update target (channel=$OMACASE_CHANNEL). Cut a v* tag or set OMACASE_CHANNEL=dev."
tag="$(_current_exact_tag)"
printf 'channel: %s\n' "$OMACASE_CHANNEL"
if [ -n "$tag" ]; then
printf 'current: %s (%s)\n' "$current" "$tag"
else
printf 'current: %s\n' "$current"
fi
printf 'target: %s\n' "$target"
if [ "$(git -C "$OMACASE_ROOT" rev-parse HEAD)" = "$(git -C "$OMACASE_ROOT" rev-parse "$target^{commit}")" ]; then
info "Already up to date."
return 0
fi
local n
n="$(git -C "$OMACASE_ROOT" rev-list --count "HEAD..$target" 2>/dev/null || echo 0)"
printf 'pending: %s commit(s)\n' "$n"
git -C "$OMACASE_ROOT" log --oneline "HEAD..$target"
git -C "$OMACASE_ROOT" diff --stat "HEAD..$target"
}

_update_rollback() {
local prev="$OMACASE_STATE/update-prev"
[ -f "$prev" ] || abort "No previous update SHA recorded. (omacase update --rollback is one level deep.)"
local sha
sha="$(cat "$prev")"
[ -n "$sha" ] || abort "Empty rollback SHA in $prev."
warn "Rolling back payload to $sha"
git -C "$OMACASE_ROOT" checkout -q "$sha" \
|| abort "git checkout $sha failed."
is_dryrun && return 0
exec "$OMACASE_ROOT/bin/omacase" install
}

_update_remote_default_branch() {
local ref
ref="$(git -C "$OMACASE_ROOT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
ref="${ref#origin/}"
printf '%s\n' "${ref:-main}"
}

# stable leaves a detached tag checkout. Dev must attach to the remote
# default branch without reset --hard / checkout -B (those discard work).
_update_attach_dev() {
local branch
branch="$(_update_remote_default_branch)"
if is_dryrun; then
log "[dry-run] would attach to origin/$branch and fast-forward"
return 0
fi
git -C "$OMACASE_ROOT" fetch origin "$branch" \
|| abort "git fetch origin $branch failed."
if git -C "$OMACASE_ROOT" symbolic-ref -q HEAD >/dev/null; then
git -C "$OMACASE_ROOT" merge --ff-only "origin/$branch" \
|| abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating."
return
fi
info "Attaching detached checkout to origin/$branch (dev channel)…"
if git -C "$OMACASE_ROOT" show-ref --verify --quiet "refs/heads/$branch"; then
git -C "$OMACASE_ROOT" checkout -q "$branch" \
|| abort "Could not check out $branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable."
else
git -C "$OMACASE_ROOT" checkout -q --track "origin/$branch" \
|| abort "Could not attach to origin/$branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable."
fi
git -C "$OMACASE_ROOT" merge --ff-only "origin/$branch" \
|| abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating."
}

_update_switch_payload() {
# A legacy login-items edit dirties a tracked file, which blocks both the dev
# ff-only pull and the stable tag checkout — recover it before any movement.
_recover_legacy_login_items "$OMACASE_ROOT"
case "$OMACASE_CHANNEL" in
dev)
step "Pulling latest omacase (dev channel)"
_update_record_prev
_update_attach_dev ;;
stable)
step "Fetching omacase release tags (stable channel)"
run git -C "$OMACASE_ROOT" fetch --tags origin \
|| abort "git fetch --tags failed."
local tag current
tag="$(_latest_release_tag)"
[ -n "$tag" ] || abort "No v* release tags found. Cut a release or set OMACASE_CHANNEL=dev."
current="$(_current_exact_tag)"
if [ "$tag" = "$current" ]; then
info "Already on $tag"
else
_update_record_prev
run git -C "$OMACASE_ROOT" checkout -q "$tag" \
|| abort "git checkout $tag failed."
fi ;;
*)
abort "Unknown OMACASE_CHANNEL='$OMACASE_CHANNEL' (use stable or dev)." ;;
esac
}

omacase_update() {
ensure_brew_env
dryrun_banner
case "${1:-}" in
--check) _update_check; return ;;
--rollback) _update_rollback; return ;;
"" ) ;;
*) abort "unknown update flag: $1 (try --check or --rollback)" ;;
esac
if [ -d "$OMACASE_ROOT/.git" ] && [ -z "${OMACASE_UPDATE_REEXECED:-}" ]; then
step "Pulling latest omacase"
_recover_legacy_login_items "$OMACASE_ROOT"
run git -C "$OMACASE_ROOT" pull --ff-only || abort "git pull failed (local changes?). Resolve it before updating."
# Everything sourced so far (common.sh, this file) came from the pre-pull
_update_switch_payload
# Everything sourced so far (common.sh, this file) came from the pre-switch
# checkout; re-exec into the fresh tree so the rest of the update runs a
# single, consistent version instead of a mix of old and new lib files.
if ! is_dryrun; then
Expand All @@ -31,7 +175,7 @@ omacase_update() {
info "Skipping mise tool upgrades (OMACASE_SKIP_MISE_UPGRADE is set)."
elif have mise; then
step "Upgrading mise tools (node + npm CLIs)"
warn "mise tools include npm packages pinned to latest; set OMACASE_SKIP_MISE_UPGRADE=1 to skip."
warn "mise upgrade converges to the pinned versions; set OMACASE_SKIP_MISE_UPGRADE=1 to skip."
require "mise upgrade" mise upgrade
fi
step "Upgrading outdated formulae & casks"
Expand Down
Loading