diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index 46339f9de..000000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/sh -set -eu - -target="roles/codex/files/config.toml" - -if ! git diff --cached --name-only --diff-filter=ACMR -- "$target" | grep -qx "$target"; then - exit 0 -fi - -staged_file=$(mktemp) -sanitized_file=$(mktemp) - -cleanup() { - rm -f "$staged_file" "$sanitized_file" -} - -trap cleanup EXIT HUP INT TERM - -git show ":$target" >"$staged_file" - -awk ' -function flush_pending_blanks() { - while (pending_blank_count > 0) { - print "" - pending_blank_count-- - } -} - -function is_table(line) { - return line ~ /^\[[^][]+\][[:space:]]*$/ -} - -function marketplace_is_local(line) { - return line ~ /^[[:space:]]*source_type[[:space:]]*=[[:space:]]*"local"[[:space:]]*$/ || - line ~ /^[[:space:]]*source[[:space:]]*=[[:space:]]*"\/Users\// -} - -function start_marketplace(line) { - in_marketplace = 1 - marketplace_local = 0 - marketplace_line_count = 1 - marketplace_lines[marketplace_line_count] = line -} - -function buffer_marketplace(line) { - marketplace_lines[++marketplace_line_count] = line - if (marketplace_is_local(line)) { - marketplace_local = 1 - } -} - -function flush_marketplace() { - if (!in_marketplace) { - return - } - - if (!marketplace_local) { - flush_pending_blanks() - for (marketplace_line_index = 1; marketplace_line_index <= marketplace_line_count; marketplace_line_index++) { - print marketplace_lines[marketplace_line_index] - } - } - - delete marketplace_lines - in_marketplace = 0 - marketplace_local = 0 - marketplace_line_count = 0 -} - -in_marketplace && is_table($0) { - flush_marketplace() -} - -in_marketplace { - buffer_marketplace($0) - next -} - -/^\[projects\.[^][]+\][[:space:]]*$/ { - skip_projects = 1 - next -} - -skip_projects && is_table($0) { - skip_projects = 0 -} - -skip_projects { - next -} - -/^\[marketplaces\.[^][]+\][[:space:]]*$/ { - start_marketplace($0) - next -} - -/^[[:space:]]*trust_level[[:space:]]*=/ { - next -} - -/^[[:space:]]*$/ { - pending_blank_count++ - next -} - -{ - flush_pending_blanks() - print -} - -END { - flush_marketplace() -} -' "$staged_file" >"$sanitized_file" - -mode=$(git ls-files -s -- "$target" | awk 'NR == 1 { print $1 }') - -if [ -z "$mode" ]; then - echo "pre-commit: unable to determine index mode for $target" >&2 - exit 1 -fi - -blob=$(git hash-object -w --path "$target" "$sanitized_file") -git update-index --cacheinfo "$mode,$blob,$target" diff --git a/.github/assets/dry-run.png b/.github/assets/dry-run.png new file mode 100644 index 000000000..7d095218c Binary files /dev/null and b/.github/assets/dry-run.png differ diff --git a/.github/assets/failure.png b/.github/assets/failure.png new file mode 100644 index 000000000..a97dd3674 Binary files /dev/null and b/.github/assets/failure.png differ diff --git a/.github/assets/header-dark.png b/.github/assets/header-dark.png new file mode 100644 index 000000000..060c0982f Binary files /dev/null and b/.github/assets/header-dark.png differ diff --git a/.github/assets/header-light.png b/.github/assets/header-light.png new file mode 100644 index 000000000..98682d9b9 Binary files /dev/null and b/.github/assets/header-light.png differ diff --git a/.github/assets/hero.png b/.github/assets/hero.png new file mode 100644 index 000000000..79cdb5c70 Binary files /dev/null and b/.github/assets/hero.png differ diff --git a/.github/assets/render/capture-fixtures.sh b/.github/assets/render/capture-fixtures.sh new file mode 100755 index 000000000..4436110ae --- /dev/null +++ b/.github/assets/render/capture-fixtures.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Regenerates fixtures/ from read-only mise queries. render.py does NOT need this +# (the fixtures are committed); run it only to refresh them after the modules change. +# +# plan.json, dot.json, tasks `mise bootstrap plan --json`, `mise dot status --json`, +# `mise tasks ls --name-only` against the real HOME. All three +# only read; the JSON is reduced to the fields bin/dotfiles uses +# (no paths). +# dry-.txt `mise bootstrap --dry-run --only ` against an isolated, +# empty HOME (a fresh-machine rehearsal, MISE_OFFLINE=1), with +# the temporary prefixes rewritten to /home/techdufus. Offline +# mise cannot resolve versions, so dry-tools.txt is built from +# `mise ls --json` in mise's "@ ⇢ would install" +# format and the offline version warnings are dropped. +# +# Never touches the real HOME's config, state or packages. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../../.." && pwd) +fx=$here/fixtures +mkdir -p "$fx" + +cd "$HOME" +mise bootstrap plan --json | jq -c '{resources: [.resources[] | {id, action}]}' >"$fx/plan.json" +mise dot status --json | jq -c '{files: [(.files // [])[] | {state}], edits: [(.edits // [])[] | {state}]}' >"$fx/dot.json" +mise tasks ls --name-only >"$fx/tasks" + +sb=$(mktemp -d "${TMPDIR:-/tmp}/dotfiles-fixtures.XXXXXX") +trap 'rm -rf "$sb"' EXIT +mkdir -p "$sb/home" "$sb/mise" "$sb/data" "$sb/state" "$sb/cache" +ln -s "$repo/modules" "$sb/mise/conf.d" +printf 'auto_env = true\nenv = ["omarchy", "personal"]' >"$sb/mise/miserc.local.toml" + +dry() { # dry + (cd "$sb/home" && env -i HOME="$sb/home" PATH="$PATH" TERM=dumb NO_COLOR=1 \ + XDG_CONFIG_HOME="$sb/home/.config" XDG_DATA_HOME="$sb/data" XDG_STATE_HOME="$sb/state" XDG_CACHE_HOME="$sb/cache" \ + MISE_CONFIG_DIR="$sb/mise" MISE_DATA_DIR="$sb/data/mise" MISE_STATE_DIR="$sb/state/mise" MISE_CACHE_DIR="$sb/cache/mise" \ + MISE_OFFLINE=1 MISE_TASK_RUN_AUTO_INSTALL=0 MISE_TRUSTED_CONFIG_PATHS="$sb" \ + mise bootstrap --yes --dry-run --only "$2" 2>&1 || true) | + sed "s#$sb/mise#/home/techdufus/.config/mise#g; s#$sb/home#/home/techdufus#g; s#$sb#/home/techdufus#g" >"$fx/dry-$1.txt" +} +dry packages accounts,plugins,packages +dry system files,services,firewall,compose +dry dotfiles repos,dotfiles +dry shell mise-shell-activate,macos-defaults,macos-launchd-agents,linux-systemd-units,user +dry modules task,final-hook +sed -i '/^mise WARN/d' "$fx/dry-modules.txt" +{ + echo 'mise bootstrap: tools' + mise ls --json | jq -r 'to_entries[] | select(.value | length > 0) | "mise \(.key)@\(.value[0].version) ⇢ would install"' | sort +} >"$fx/dry-tools.txt" +wc -l "$fx"/dry-*.txt diff --git a/.github/assets/render/fixtures/apply-dotfiles.txt b/.github/assets/render/fixtures/apply-dotfiles.txt new file mode 100644 index 000000000..6c35d7a21 --- /dev/null +++ b/.github/assets/render/fixtures/apply-dotfiles.txt @@ -0,0 +1,3 @@ +mise bootstrap: repos +mise bootstrap: dotfiles +mise dotfiles: 2 updated, 86 already in place diff --git a/.github/assets/render/fixtures/apply-modules.txt b/.github/assets/render/fixtures/apply-modules.txt new file mode 100644 index 000000000..24dfceb75 --- /dev/null +++ b/.github/assets/render/fixtures/apply-modules.txt @@ -0,0 +1,19 @@ +mise bootstrap: running `bootstrap` task +[bootstrap] $ mise run --jobs 1 'bootstrap:*' +[bootstrap:1password] sources up-to-date, skipping +[bootstrap:btop] $ bash settings.sh +btop: set owned keys in ~/.config/btop/btop.conf +[bootstrap:cursor] $ bash bootstrap.sh +[bootstrap:devtools] $ bash bootstrap.sh +[bootstrap:gh] $ bash bootstrap.sh +[bootstrap:git] $ python3 bootstrap.py +[bootstrap:k9s] $ bash bootstrap.sh +[bootstrap:obsidian] sources up-to-date, skipping +[bootstrap:omarchy] $ bash bootstrap.sh +[bootstrap:omarchy-plugins] $ bash bootstrap.sh +[bootstrap:omarchy-theme] sources up-to-date, skipping +[bootstrap:omp] $ bash bootstrap.sh +[bootstrap:secrets] sources up-to-date, skipping +[bootstrap:system] sources up-to-date, skipping +[bootstrap:taskfile] sources up-to-date, skipping +[bootstrap:tern] $ bash bootstrap.sh diff --git a/.github/assets/render/fixtures/apply-packages.txt b/.github/assets/render/fixtures/apply-packages.txt new file mode 100644 index 000000000..feb28adb6 --- /dev/null +++ b/.github/assets/render/fixtures/apply-packages.txt @@ -0,0 +1,3 @@ +mise bootstrap: system packages +mise pacman: 50 package(s) already installed +mise pacman: installing lazygit diff --git a/.github/assets/render/fixtures/apply-shell.txt b/.github/assets/render/fixtures/apply-shell.txt new file mode 100644 index 000000000..d9e63ea9c --- /dev/null +++ b/.github/assets/render/fixtures/apply-shell.txt @@ -0,0 +1,2 @@ +mise bootstrap: login shell +mise login_shell: already set to /usr/bin/zsh diff --git a/.github/assets/render/fixtures/apply-system.txt b/.github/assets/render/fixtures/apply-system.txt new file mode 100644 index 000000000..0c1bd35be --- /dev/null +++ b/.github/assets/render/fixtures/apply-system.txt @@ -0,0 +1,2 @@ +mise bootstrap: system files +mise system files: already converged diff --git a/.github/assets/render/fixtures/apply-tools.txt b/.github/assets/render/fixtures/apply-tools.txt new file mode 100644 index 000000000..059d74360 --- /dev/null +++ b/.github/assets/render/fixtures/apply-tools.txt @@ -0,0 +1,5 @@ +mise bootstrap: tools +mise actionlint@1.7.12 download actionlint_1.7.12_linux_amd64.tar.gz +mise actionlint@1.7.12 install +mise cosign@3.1.3 download cosign-linux-amd64 +mise cosign@3.1.3 install diff --git a/.github/assets/render/fixtures/dot.json b/.github/assets/render/fixtures/dot.json new file mode 100644 index 000000000..dd0ac6334 --- /dev/null +++ b/.github/assets/render/fixtures/dot.json @@ -0,0 +1 @@ +{"files":[{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"differs"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"}],"edits":[{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"applied"},{"state":"differs"},{"state":"applied"},{"state":"applied"}]} diff --git a/.github/assets/render/fixtures/dry-dotfiles.txt b/.github/assets/render/fixtures/dry-dotfiles.txt new file mode 100644 index 000000000..ecb1f3ff2 --- /dev/null +++ b/.github/assets/render/fixtures/dry-dotfiles.txt @@ -0,0 +1,93 @@ +mise bootstrap: repos +git clone https://github.com/abhijeet-swami/omarchy-ayaka-theme.git /home/techdufus/.config/omarchy/themes/ayaka +git clone https://github.com/iamanro/omarchy-omp-usage.git /home/techdufus/.config/omarchy/plugins/iamanro.omp-usage +git clone https://github.com/robzolkos/omarchy-github.git /home/techdufus/.config/omarchy/plugins/robzolkos.github +git clone https://github.com/piyush97/omaswitch.git /home/techdufus/.config/omarchy/plugins/piyush.omaswitch +mise bootstrap: dotfiles +mise WARN [dotfiles]."~/.cursor/cli-config.json": ~/.cursor/cli-config.json does not exist; permissions not set +mise WARN [dotfiles]."~/.omp/agent/mcp.json": ~/.omp/agent/mcp.json does not exist; permissions not set +mise WARN [dotfiles]."~/.ssh": ~/.ssh does not exist; permissions not set +cp /home/techdufus/.config/mise/conf.d/1password/agent.toml ~/.config/1Password/ssh/agent.toml +ln -sf /home/techdufus/.config/mise/conf.d/claude/settings.json ~/.claude/settings.json +ln -sf /home/techdufus/.config/mise/conf.d/claude/scripts ~/.claude/scripts +ln -sf /home/techdufus/.config/mise/conf.d/claude/commands ~/.claude/commands +ln -sf /home/techdufus/.config/mise/conf.d/claude/hooks ~/.claude/hooks +ln -sf /home/techdufus/.config/mise/conf.d/claude/agents ~/.claude/agents +ln -sf /home/techdufus/.config/mise/conf.d/claude/lsp.json ~/.claude/.lsp.json +cp -r /home/techdufus/.config/mise/conf.d/claude/skills/skill-creator ~/.claude/skills/skill-creator +ln -sf /home/techdufus/.config/mise/conf.d/codex/AGENTS.md ~/.codex/AGENTS.md +ln -sf /home/techdufus/.config/mise/conf.d/codex/skills/work ~/.codex/skills/work +ln -sf /home/techdufus/.config/mise/conf.d/codex/skills/github-issue-hierarchy ~/.codex/skills/github-issue-hierarchy +ln -sf /home/techdufus/.config/mise/conf.d/codex/skills/intent ~/.codex/skills/intent +ln -sf /home/techdufus/.config/mise/conf.d/codex/skills/commit ~/.codex/skills/commit +cp -r /home/techdufus/.config/mise/conf.d/codex/agents ~/.codex/agents +ln -sf /home/techdufus/.config/mise/conf.d/cursor/statusline.sh ~/.cursor/statusline.sh +ln -sf /home/techdufus/.config/mise/conf.d/cursor/agents/* into ~/.cursor/agents/ (5 files) +ln -sf /home/techdufus/.config/mise/conf.d/cursor/rules/* into ~/.cursor/rules/ (3 files) +ln -sf /home/techdufus/.config/mise/conf.d/cursor/skills/commit ~/.cursor/skills/commit +ln -sf /home/techdufus/.config/mise/conf.d/cursor/skills/orchestrate ~/.cursor/skills/orchestrate +ln -sf /home/techdufus/.config/mise/conf.d/cursor/skills/ultraresearch ~/.cursor/skills/ultraresearch +ln -sf /home/techdufus/.config/mise/conf.d/cursor/skills/ultrathink ~/.cursor/skills/ultrathink +ln -sf /home/techdufus/.config/mise/conf.d/cursor/skills/verification ~/.cursor/skills/verification +ln -sf /home/techdufus/.config/mise/conf.d/gh/gh-dash/config.yaml ~/.config/gh-dash/config.yaml +ln -sf /home/techdufus/.config/mise/conf.d/ghostty/config ~/.config/ghostty/config +ln -sf /home/techdufus/.config/mise/conf.d/ghostty/shaders/* into ~/.config/ghostty/shaders/ (1 files) +ln -sf /home/techdufus/.config/mise/conf.d/ghostty/themes/* into ~/.config/ghostty/themes/ (1 files) +ln -sf /home/techdufus/.config/mise/conf.d/git/dotfiles.gitconfig ~/.config/git/dotfiles.gitconfig +ln -sf /home/techdufus/.config/mise/conf.d/git/commit_template ~/.config/git/commit_template +ln -sf /home/techdufus/.config/mise/conf.d/lfk/config.yaml ~/.config/lfk/config.yaml +ln -sf /home/techdufus/.config/mise/conf.d/lsd/config.yaml ~/.config/lsd/config.yaml +ln -sf /home/techdufus/.config/mise/conf.d/lsd/colors.yaml ~/.config/lsd/colors.yaml +ln -sf /home/techdufus/.config/mise/conf.d/neovim/nvim ~/.config/nvim +ln -sf /home/techdufus/.config/mise/conf.d/omp/config.yml ~/.omp/agent/config.yml +ln -sf /home/techdufus/.config/mise/conf.d/omp/lsp.json ~/.omp/agent/lsp.json +ln -sf /home/techdufus/.config/mise/conf.d/omp/RULES.md ~/.omp/agent/RULES.md +ln -sf /home/techdufus/.config/mise/conf.d/omp/WATCHDOG.md ~/.omp/agent/WATCHDOG.md +ln -sf /home/techdufus/.config/mise/conf.d/omp/overlays/herd.yml ~/.omp/agent/overlays/herd.yml +ln -sf /home/techdufus/.config/mise/conf.d/omp/overlays/cursor.yml ~/.omp/agent/overlays/cursor.yml +ln -sf /home/techdufus/.config/mise/conf.d/omp/overlays/claude.yml ~/.omp/agent/overlays/claude.yml +ln -sf /home/techdufus/.config/mise/conf.d/omp/overlays/claude-sol.yml ~/.omp/agent/overlays/claude-sol.yml +ln -sf /home/techdufus/.config/mise/conf.d/omp/overlays/codex.yml ~/.omp/agent/overlays/codex.yml +ln -sf /home/techdufus/.config/mise/conf.d/omp/agents/* into ~/.omp/agent/agents/ (5 files) +ln -sf /home/techdufus/.config/mise/conf.d/omp/extensions/* into ~/.omp/agent/extensions/ (2 files) +ln -sf /home/techdufus/.config/mise/conf.d/omp/skills/test-audit ~/.omp/agent/skills/test-audit +ln -sf /home/techdufus/.config/mise/conf.d/omp/skills/herdr-workflow ~/.omp/agent/skills/herdr-workflow +ln -sf /home/techdufus/.config/mise/conf.d/omp/skills/semantic-compression ~/.omp/agent/skills/semantic-compression +ln -sf /home/techdufus/.config/mise/conf.d/omp/skills/verification ~/.omp/agent/skills/verification +ln -sf /home/techdufus/.config/mise/conf.d/omp/skills/commit ~/.omp/agent/skills/commit +ln -sf /home/techdufus/.config/mise/conf.d/omp/bin/omp-cursor ~/.local/bin/omp-cursor +ln -sf /home/techdufus/.config/mise/conf.d/omp/bin/omp-claude ~/.local/bin/omp-claude +ln -sf /home/techdufus/.config/mise/conf.d/omp/bin/omp-claude-sol ~/.local/bin/omp-claude-sol +ln -sf /home/techdufus/.config/mise/conf.d/omp/bin/omp-codex ~/.local/bin/omp-codex +ln -sf /home/techdufus/.config/mise/conf.d/omp/AGENTS.md ~/.omp/agent/AGENTS.md +ln -sf /home/techdufus/.config/mise/conf.d/worktrunk/config.toml ~/.config/worktrunk/config.toml +ln -sf /home/techdufus/.config/mise/conf.d/zsh/zshenv ~/.zshenv +ln -sf /home/techdufus/.config/mise/conf.d/zsh/zshrc ~/.zshrc +ln -sf /home/techdufus/.config/mise/conf.d/zsh/p10k.zsh ~/.p10k.zsh +ln -sf /home/techdufus/.config/mise/conf.d/zsh/zsh/* into ~/.config/zsh/ (39 files) +ln -sf /home/techdufus/.config/mise/conf.d/k9s/config.yaml ~/.config/k9s/config.yaml +ln -sf /home/techdufus/.config/mise/conf.d/k9s/plugins.yaml ~/.config/k9s/plugins.yaml +ln -sf /home/techdufus/.config/mise/conf.d/k9s/aliases.yaml ~/.config/k9s/aliases.yaml +ln -sf /home/techdufus/.config/mise/conf.d/kitty/kitty.conf ~/.config/kitty/kitty.conf +ln -sf /home/techdufus/.config/mise/conf.d/taskfile/Taskfile.yml ~/Taskfile.yml +ln -sf /home/techdufus/.config/mise/conf.d/zsh/os/linux/os_functions.zsh ~/.config/zsh/os_functions.zsh +ln -sf /home/techdufus/.config/mise/conf.d/k9s/k9s.yaml.tpl ~/.config/omarchy/themed/k9s.yaml.tpl +ln -sf /home/techdufus/.config/mise/conf.d/k9s/omarchy-skin.hook ~/.config/omarchy/hooks/theme-set.d/dotfiles-k9s-skin.hook +ln -sf /home/techdufus/.config/mise/conf.d/omarchy/hypr/* into ~/.config/hypr/dotfiles/ (9 files) +cp /home/techdufus/.config/mise/conf.d/omarchy/fcitx5/keyboard.conf ~/.config/fcitx5/conf/keyboard.conf +ln -sf /home/techdufus/.config/mise/conf.d/omarchy/plugin-update.hook ~/.config/omarchy/hooks/post-update.d/dotfiles-plugin-update.hook +chmod 0700 ~/.omp +chmod 0700 ~/.omp/agent +chmod 0700 ~/.omp/agent/overlays +mise bootstrap: dotfile edits +edit ~/.codex/config.toml (merge:shared) +edit ~/.cursor/cli-config.json (merge:managed) +edit ~/.npmrc (line:author) +edit ~/.npmrc (line:license) +edit ~/.npmrc (line:save-exact) +edit ~/.config/herdr/config.toml (merge:shared) +edit ~/.config/jj/config.toml (merge:shared) (if changed) +edit ~/.omp/agent/mcp.json (merge:managed) +edit ~/.config/tern/settings.json (merge:shared) +edit ~/.config/tern/settings.json (merge:keybinds) +edit ~/.config/hypr/autostart.lua (block:dotfiles) diff --git a/.github/assets/render/fixtures/dry-modules.txt b/.github/assets/render/fixtures/dry-modules.txt new file mode 100644 index 000000000..d781c7959 --- /dev/null +++ b/.github/assets/render/fixtures/dry-modules.txt @@ -0,0 +1,2 @@ +mise bootstrap: running `bootstrap` task +[bootstrap] $ mise run --jobs 1 'bootstrap:*' diff --git a/.github/assets/render/fixtures/dry-packages.txt b/.github/assets/render/fixtures/dry-packages.txt new file mode 100644 index 000000000..09c6a1c91 --- /dev/null +++ b/.github/assets/render/fixtures/dry-packages.txt @@ -0,0 +1,2 @@ +mise bootstrap: system packages +mise pacman: 51 package(s) already installed diff --git a/.github/assets/render/fixtures/dry-shell.txt b/.github/assets/render/fixtures/dry-shell.txt new file mode 100644 index 000000000..d9e63ea9c --- /dev/null +++ b/.github/assets/render/fixtures/dry-shell.txt @@ -0,0 +1,2 @@ +mise bootstrap: login shell +mise login_shell: already set to /usr/bin/zsh diff --git a/.github/assets/render/fixtures/dry-system.txt b/.github/assets/render/fixtures/dry-system.txt new file mode 100644 index 000000000..0c1bd35be --- /dev/null +++ b/.github/assets/render/fixtures/dry-system.txt @@ -0,0 +1,2 @@ +mise bootstrap: system files +mise system files: already converged diff --git a/.github/assets/render/fixtures/dry-tools.txt b/.github/assets/render/fixtures/dry-tools.txt new file mode 100644 index 000000000..03dc5794d --- /dev/null +++ b/.github/assets/render/fixtures/dry-tools.txt @@ -0,0 +1,61 @@ +mise bootstrap: tools +mise actionlint@1.7.12 ⇢ would install +mise ansible@14.4.0 ⇢ would install +mise aqua:gitleaks/gitleaks@8.30.1 ⇢ would install +mise aqua:kubernetes-sigs/kubebuilder@4.16.0 ⇢ would install +mise aqua:prometheus/alertmanager@0.34.1 ⇢ would install +mise aqua:shssoichiro/oxipng@10.2.1 ⇢ would install +mise bun@1.4.3 ⇢ would install +mise cilium-cli@0.20.1 ⇢ would install +mise claude@2.1.305 ⇢ would install +mise codex@0.163.0 ⇢ would install +mise cosign@3.1.3 ⇢ would install +mise flux2@2.9.5 ⇢ would install +mise flux-operator@0.60.0 ⇢ would install +mise flux-operator-mcp@0.60.0 ⇢ would install +mise gh@2.102.0 ⇢ would install +mise github:can1357/oh-my-pi@18.11.3 ⇢ would install +mise github:home-operations/flate@0.6.5 ⇢ would install +mise github:janosmiko/lfk@0.19.6 ⇢ would install +mise go@1.27.1 ⇢ would install +mise go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint@2.13.1 ⇢ would install +mise go:github.com/mrjosh/helm-ls@0.5.4 ⇢ would install +mise go:golang.org/x/tools/gopls@0.24.0 ⇢ would install +mise helm@4.3.0 ⇢ would install +mise java@temurin-17.0.20+101 ⇢ would install +mise jq@1.8.2 ⇢ would install +mise kind@0.32.0 ⇢ would install +mise kubeconform@0.8.0 ⇢ would install +mise kubectl@1.36.1 ⇢ would install +mise node@24.20.0 ⇢ would install +mise npm:@ansible/ansible-language-server@26.6.0 ⇢ would install +mise npm:@astrojs/language-server@2.17.2 ⇢ would install +mise npm:bash-language-server@5.8.1 ⇢ would install +mise npm:@biomejs/biome@2.5.15 ⇢ would install +mise npm:clawpatch@0.8.2 ⇢ would install +mise npm:dockerfile-language-server-nodejs@0.15.0 ⇢ would install +mise npm:graphql-language-service-cli@3.5.0 ⇢ would install +mise npm:intelephense@1.18.5 ⇢ would install +mise npm:npm-check-updates@23.1.1 ⇢ would install +mise npm:@olrtg/emmet-language-server@2.8.0 ⇢ would install +mise npm:@prisma/language-server@31.13.0 ⇢ would install +mise npm:pyright@1.1.414 ⇢ would install +mise npm:serve@14.2.6 ⇢ would install +mise npm:svelte-language-server@0.18.5 ⇢ would install +mise npm:@tailwindcss/language-server@0.16.0 ⇢ would install +mise npm:typescript@7.0.2 ⇢ would install +mise npm:typescript-language-server@6.0.2 ⇢ would install +mise npm:vscode-langservers-extracted@4.10.0 ⇢ would install +mise npm:@vue/language-server@3.3.13 ⇢ would install +mise npm:yaml-language-server@2.0.0 ⇢ would install +mise npm:yarn@1.22.22 ⇢ would install +mise opentofu@1.12.6 ⇢ would install +mise pipx:check-jsonschema@0.38.2 ⇢ would install +mise pipx:yamllint@1.38.0 ⇢ would install +mise pnpm@12.14.0 ⇢ would install +mise shellcheck@0.9.0 ⇢ would install +mise talosctl@1.14.2 ⇢ would install +mise task@3.53.1 ⇢ would install +mise trivy@0.74.0 ⇢ would install +mise uv@0.13.1 ⇢ would install +mise yq@4.53.6 ⇢ would install diff --git a/.github/assets/render/fixtures/fail-tools.txt b/.github/assets/render/fixtures/fail-tools.txt new file mode 100644 index 000000000..8f8f60379 --- /dev/null +++ b/.github/assets/render/fixtures/fail-tools.txt @@ -0,0 +1,7 @@ +mise bootstrap: tools +mise aqua:gitleaks/gitleaks@8.30.1 install +mise aqua:gitleaks/gitleaks@8.30.1 download gitleaks_8.30.1_linux_x64.tar.gz +mise ERROR Failed to install aqua:gitleaks/gitleaks@8.30.1: error sending request for url (https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz) +mise ERROR Caused by: operation timed out +mise ERROR Version: 2026.10.12 linux-x64 (2026-10-16) +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information diff --git a/.github/assets/render/fixtures/missing b/.github/assets/render/fixtures/missing new file mode 100644 index 000000000..57a068bd6 --- /dev/null +++ b/.github/assets/render/fixtures/missing @@ -0,0 +1,2 @@ +actionlint 1.7.12 ~/.config/mise/conf.d/devtools/mise.toml latest (missing) +cosign 3.1.3 ~/.config/mise/conf.d/devtools/mise.toml latest (missing) diff --git a/.github/assets/render/fixtures/plan.json b/.github/assets/render/fixtures/plan.json new file mode 100644 index 000000000..e77789b25 --- /dev/null +++ b/.github/assets/render/fixtures/plan.json @@ -0,0 +1 @@ +{"resources":[{"id":{"kind":"package","name":"pacman:bat"},"action":"noop"},{"id":{"kind":"package","name":"pacman:btop"},"action":"noop"},{"id":{"kind":"package","name":"pacman:clang"},"action":"noop"},{"id":{"kind":"package","name":"pacman:bun"},"action":"noop"},{"id":{"kind":"package","name":"pacman:helm"},"action":"noop"},{"id":{"kind":"package","name":"pacman:ansible-core"},"action":"noop"},{"id":{"kind":"package","name":"pacman:github-cli"},"action":"noop"},{"id":{"kind":"package","name":"pacman:ghostty"},"action":"noop"},{"id":{"kind":"package","name":"pacman:git"},"action":"noop"},{"id":{"kind":"package","name":"pacman:jujutsu"},"action":"noop"},{"id":{"kind":"package","name":"pacman:k9s"},"action":"noop"},{"id":{"kind":"package","name":"pacman:kitty"},"action":"noop"},{"id":{"kind":"package","name":"pacman:lsd"},"action":"noop"},{"id":{"kind":"package","name":"pacman:ansible-lint"},"action":"noop"},{"id":{"kind":"package","name":"pacman:base-devel"},"action":"noop"},{"id":{"kind":"package","name":"pacman:cmake"},"action":"noop"},{"id":{"kind":"package","name":"pacman:libtool"},"action":"noop"},{"id":{"kind":"package","name":"pacman:pkgconf"},"action":"noop"},{"id":{"kind":"package","name":"pacman:ripgrep"},"action":"noop"},{"id":{"kind":"package","name":"pacman:tree-sitter-cli"},"action":"noop"},{"id":{"kind":"package","name":"pacman:unzip"},"action":"noop"},{"id":{"kind":"package","name":"pacman:obsidian"},"action":"noop"},{"id":{"kind":"package","name":"pacman:discord"},"action":"noop"},{"id":{"kind":"package","name":"pacman:spotify-launcher"},"action":"noop"},{"id":{"kind":"package","name":"pacman:signal-desktop"},"action":"noop"},{"id":{"kind":"package","name":"pacman:fzf"},"action":"noop"},{"id":{"kind":"package","name":"pacman:lazygit"},"action":"noop"},{"id":{"kind":"package","name":"pacman:ncdu"},"action":"noop"},{"id":{"kind":"package","name":"pacman:tmate"},"action":"noop"},{"id":{"kind":"package","name":"pacman:zoxide"},"action":"noop"},{"id":{"kind":"package","name":"pacman:sshfs"},"action":"noop"},{"id":{"kind":"package","name":"pacman:podman"},"action":"noop"},{"id":{"kind":"package","name":"pacman:podman-compose"},"action":"noop"},{"id":{"kind":"package","name":"pacman:fontconfig"},"action":"noop"},{"id":{"kind":"package","name":"pacman:go"},"action":"noop"},{"id":{"kind":"package","name":"pacman:lua"},"action":"noop"},{"id":{"kind":"package","name":"pacman:python"},"action":"noop"},{"id":{"kind":"package","name":"pacman:python-pip"},"action":"noop"},{"id":{"kind":"package","name":"pacman:python-virtualenv"},"action":"noop"},{"id":{"kind":"package","name":"pacman:terraform"},"action":"noop"},{"id":{"kind":"package","name":"pacman:terragrunt"},"action":"noop"},{"id":{"kind":"package","name":"pacman:kubectl"},"action":"noop"},{"id":{"kind":"package","name":"pacman:openssh"},"action":"noop"},{"id":{"kind":"package","name":"pacman:diffutils"},"action":"noop"},{"id":{"kind":"package","name":"pacman:jq"},"action":"noop"},{"id":{"kind":"package","name":"pacman:open-iscsi"},"action":"noop"},{"id":{"kind":"package","name":"pacman:worktrunk"},"action":"noop"},{"id":{"kind":"package","name":"pacman:zsh"},"action":"noop"},{"id":{"kind":"package","name":"pacman:rocm-smi-lib"},"action":"noop"},{"id":{"kind":"package","name":"pacman:herdr"},"action":"noop"},{"id":{"kind":"package","name":"pacman:brave-bin"},"action":"noop"},{"id":{"kind":"file","name":"/etc/udev/rules.d/90-disable-logitech-receiver-wakeup.rules"},"action":"noop"}]} diff --git a/.github/assets/render/fixtures/tasks b/.github/assets/render/fixtures/tasks new file mode 100644 index 000000000..46365a8af --- /dev/null +++ b/.github/assets/render/fixtures/tasks @@ -0,0 +1,28 @@ +bootstrap +bootstrap:1password +bootstrap:btop +bootstrap:cursor +bootstrap:devtools +bootstrap:gh +bootstrap:git +bootstrap:k9s +bootstrap:obsidian +bootstrap:omarchy +bootstrap:omarchy-plugins +bootstrap:omarchy-theme +bootstrap:omp +bootstrap:secrets +bootstrap:system +bootstrap:taskfile +bootstrap:tern +check +check:system-sudoers +test:base +test:btop +test:codex +test:cursor +test:git +test:omarchy +test:omp +test:tern +test:zsh diff --git a/.github/assets/render/header.py b/.github/assets/render/header.py new file mode 100755 index 000000000..f7fed134c --- /dev/null +++ b/.github/assets/render/header.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Render the README header banners (header-dark.png / header-light.png). + +An enlarged version of the CLI's own header box (`draw_header` in bin/dotfiles): +rounded outline, a gradient ◆ mark, the `dotfiles` wordmark and a mono meta +line, plus the tagline and a Catppuccin accent strip. Transparent PNG output +only: the wordmark uses Berkeley Mono (commercial), so no SVG or font is kept. + +Needs: rsvg-convert, BerkeleyMono Nerd Font (found through fontconfig). +""" + +import pathlib +import subprocess +import sys +import tempfile + +W, H = 1600, 400 # CSS-pixel canvas; rasterized at 2x -> 3200x800 +FONT = "BerkeleyMono Nerd Font" +ADVANCE = 0.6 # Berkeley Mono advance width in em + +# Catppuccin accents in canonical order (14). +ACCENTS = ( + "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", + "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender", +) + +PALETTES = { + "dark": { # Mocha + "card": "#1e1e2e", "card_opacity": 0.65, "stroke": "#585b70", + "text": "#cdd6f4", "sub": "#a6adc8", "sep": "#6c6f85", + "grad": ("#cba6f7", "#89b4fa"), + "rosewater": "#f5e0dc", "flamingo": "#f2cdcd", "pink": "#f5c2e7", + "mauve": "#cba6f7", "red": "#f38ba8", "maroon": "#eba0ac", + "peach": "#fab387", "yellow": "#f9e2af", "green": "#a6e3a1", + "teal": "#94e2d5", "sky": "#89dceb", "sapphire": "#74c7ec", + "blue": "#89b4fa", "lavender": "#b4befe", + }, + "light": { # Latte + "card": "#eff1f5", "card_opacity": 0.85, "stroke": "#acb0be", + "text": "#4c4f69", "sub": "#5c5f77", "sep": "#9ca0b0", + "grad": ("#8839ef", "#1e66f5"), + "rosewater": "#dc8a78", "flamingo": "#dd7878", "pink": "#ea76cb", + "mauve": "#8839ef", "red": "#d20f39", "maroon": "#e64553", + "peach": "#fe640b", "yellow": "#df8e1d", "green": "#40a02b", + "teal": "#179299", "sky": "#04a5e5", "sapphire": "#209fb5", + "blue": "#1e66f5", "lavender": "#7287fd", + }, +} + +PROFILES = ("omarchy", "macos", "cachyos") +TAGLINE = "one command, every machine \u2014 applied with mise" + + +def mono_width(text: str, size: float) -> float: + return len(text) * size * ADVANCE + + +def build_svg(p: dict) -> str: + # Card geometry (mirrors the CLI's `╭ ◆ dotfiles meta ╮` box). + card_w, card_h = 1120, 172 + card_x, card_y = (W - card_w) / 2, 46 + mid_y = card_y + card_h / 2 + + word_size, meta_size, tag_size = 92, 27, 28 + mark = 66 # diamond half-height * 2 + gap_mark, gap_meta = 30, 52 + meta_text = " \u00b7 ".join(PROFILES) + + word_w = mono_width("dotfiles", word_size) + meta_w = mono_width(meta_text, meta_size) + total = mark + gap_mark + word_w + gap_meta + meta_w + x0 = (W - total) / 2 + + cx = x0 + mark / 2 + r = mark / 2 + diamond = ( + f"M{cx:.1f},{mid_y - r:.1f} L{cx + r * 0.82:.1f},{mid_y:.1f} " + f"L{cx:.1f},{mid_y + r:.1f} L{cx - r * 0.82:.1f},{mid_y:.1f} Z" + ) + word_x = x0 + mark + gap_mark + meta_x = word_x + word_w + gap_meta + + # Baselines: cap/x-height centering for mono text (cap height ~0.7em). + word_y = mid_y + word_size * 0.35 + meta_y = mid_y + meta_size * 0.35 + 4 + tag_y = card_y + card_h + 66 + + meta_spans = f' \u00b7 '.join( + f'{name}' for name in PROFILES + ) + + # Accent strip: 14 rounded pills. + pill_w, pill_h, pill_gap = 58, 24, 16 + strip_w = len(ACCENTS) * pill_w + (len(ACCENTS) - 1) * pill_gap + strip_x = (W - strip_w) / 2 + strip_y = tag_y + 36 + pills = "\n".join( + f'' + for i, name in enumerate(ACCENTS) + ) + + g0, g1 = p["grad"] + return f""" + + + + + + + + +dotfiles +{meta_spans} +{TAGLINE} +{pills} + +""" + + +def render(out_dir: pathlib.Path) -> list[pathlib.Path]: + out_dir = pathlib.Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + outputs = [] + with tempfile.TemporaryDirectory() as tmp: + for variant, palette in PALETTES.items(): + svg = pathlib.Path(tmp) / f"header-{variant}.svg" + png = out_dir / f"header-{variant}.png" + svg.write_text(build_svg(palette), encoding="utf-8") + subprocess.run( + ["rsvg-convert", "--zoom", "2", "-o", str(png), str(svg)], + check=True, + ) + outputs.append(png) + return outputs + + +if __name__ == "__main__": + for path in render(pathlib.Path(__file__).resolve().parent.parent): + print(path, file=sys.stderr) diff --git a/.github/assets/render/render.py b/.github/assets/render/render.py new file mode 100755 index 000000000..46a193533 --- /dev/null +++ b/.github/assets/render/render.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +"""Regenerate every README image in .github/assets/ (synthetic but reproducible). + + python3 .github/assets/render/render.py + +Writes header-dark.png, header-light.png (header.py), hero.png, dry-run.png and +failure.png, and prints each path. Nothing is captured from a live desktop: + +* The screens are the REAL bin/dotfiles UI. It runs in an interactive zsh inside a + private tmux server (`tmux -L dotfiles-banner`) with the real Powerlevel10k prompt, + against a sandbox HOME, a throw-away git remote a few commits ahead (so the repo row + pulls), a stub `mise` (stub-mise + fixtures/, recorded by capture-fixtures.sh) and a + stub `sudo`. Real mise, sudo and the real HOME are never touched. +* The other hero tiles are real programs in tmux panes: Neovim with this repo's config + (plugins cached under ~/.cache/dotfiles-banner/nvim; first run clones them), fastfetch + and lsd. +* tmux's `capture-pane -e` output is parsed by termsvg.py into SVG text on an exact grid + (BerkeleyMono Nerd Font, Catppuccin Mocha) and composed over the Omarchy catppuccin + wallpaper with rsvg-convert. + +Requirements: python3 (stdlib only), tmux >= 3.2, rsvg-convert, git, jq, zsh, nvim, +fastfetch, lsd, the BerkeleyMono Nerd Font (commercial; never embedded, only rasterised), +and an Omarchy machine with these dotfiles applied (zinit's powerlevel10k, the catppuccin +wallpaper in /usr/share/omarchy). The sandbox and the tmux server are removed on exit. +Output is deterministic apart from the commit hashes of the checkout it runs in. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.dont_write_bytecode = True # no __pycache__ next to the committed scripts +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import termsvg # noqa: E402 + +REPO = HERE.parents[2] +ASSETS_DIR = HERE.parent +REAL_HOME = Path(os.environ.get("HOME", "/")) +WALLPAPER = Path("/usr/share/omarchy/themes/catppuccin/backgrounds/2-waves.png") +NVIM_CACHE = Path(os.environ.get("XDG_CACHE_HOME", REAL_HOME / ".cache")) / "dotfiles-banner" / "nvim" +SOCKET = "dotfiles-banner" +PATH = "/usr/local/bin:/usr/bin:/bin" + +# One cell size for every terminal in every image (Ghostty's 11 pt BerkeleyMono, 2x-integer device pixels). +CELL_W, CELL_H = 9.0, 18.0 +CLI_COLS = 82 # bin/dotfiles draws at most 80 columns; two spare keep the prompt's clock off the edge +FIXED_CLOCK = "10:24:07" # the prompt clock, matching the desktop bar's "Thursday 10:24" + + +class RenderError(RuntimeError): + pass + + +# ----------------------------------------------------------------------------- sandbox + + +def log(msg: str) -> None: + print(f" {msg}", flush=True) + + +def run(cmd, **kw) -> subprocess.CompletedProcess: + kw.setdefault("check", True) + kw.setdefault("text", True) + kw.setdefault("stdout", subprocess.PIPE) + kw.setdefault("stderr", subprocess.STDOUT) + return subprocess.run([str(c) for c in cmd], **kw) + + +GIT_ENV = {**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"} + + +def git(*args, cwd=None) -> str: + return run(["git", *args], cwd=cwd, env=GIT_ENV).stdout.strip() + + +class Sandbox: + """Throw-away HOME, XDG dirs, stubs, fixtures and a clone of this repo.""" + + def __init__(self) -> None: + self.root = Path(tempfile.mkdtemp(prefix="dotfiles-banner.")) + self.home = self.root / "home" + self.bin = self.root / "bin" + self.fixtures = self.root / "fixtures" + self.zdotdir = self.root / "zdotdir" + self.tmp = self.root / "tmp" + self.origin = self.root / "origin.git" + self.repo = self.home / ".dotfiles" + for d in (self.home, self.bin, self.zdotdir, self.tmp): + d.mkdir(parents=True) + shutil.copy2(HERE / "stub-mise", self.bin / "mise") + shutil.copy2(HERE / "stub-sudo", self.bin / "sudo") + shutil.copytree(HERE / "fixtures", self.fixtures) + self._craft_plan() + self._write_zshrc() + + def _craft_plan(self) -> None: + """Real `mise bootstrap plan` data, with one package marked missing so the + packages row shows an install (the only crafted number besides the tool list).""" + plan = json.loads((self.fixtures / "plan.json").read_text()) + for res in plan["resources"]: + if res["id"]["name"] == "pacman:lazygit": + res["action"] = "create" + (self.fixtures / "plan.json").write_text(json.dumps(plan)) + + def make_clone(self, behind: int) -> None: + """origin.git holds HEAD as main; the clone sits `behind` commits back with origin/main as upstream.""" + if self.repo.exists(): + shutil.rmtree(self.repo) + if self.origin.exists(): + shutil.rmtree(self.origin) + git("init", "--bare", "-q", "-b", "main", str(self.origin)) + git("fetch", "-q", str(REPO), "HEAD:refs/heads/main", cwd=self.origin) + git("clone", "-q", "-b", "main", str(self.origin), str(self.repo)) + if behind: + git("reset", "-q", "--hard", f"HEAD~{behind}", cwd=self.repo) + + def _write_zshrc(self) -> None: + """Interactive zsh with only what the prompt needs. The real ~/.zshrc is not used: + it loads zinit snippets (network on a cold cache) and 1Password-backed secret + functions. Prompt, plugins and aliases are the same files the real one loads.""" + p10k_plugin = REAL_HOME / ".local/share/zinit/plugins/romkatv---powerlevel10k/powerlevel10k.zsh-theme" + highlight = REAL_HOME / ".local/share/zinit/plugins/zsh-users---zsh-syntax-highlighting/zsh-syntax-highlighting.zsh" + if not p10k_plugin.exists(): + raise RenderError(f"powerlevel10k not found at {p10k_plugin} (apply the dotfiles zsh module first)") + gitstatus_cache = REAL_HOME / ".cache" / "gitstatus" + rc = f"""\ +export HISTFILE=/dev/null HISTSIZE=0 SAVEHIST=0 +setopt EXTENDED_GLOB INTERACTIVE_COMMENTS +export GITSTATUS_CACHE_DIR={gitstatus_cache} +source {p10k_plugin} +source {REPO}/modules/zsh/p10k.zsh +# The staged prompt: fixed clock, earlier prompts kept above the command (no transient collapse). +typeset -g POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD=true +typeset -g POWERLEVEL9K_TIME_FORMAT={FIXED_CLOCK} +typeset -g POWERLEVEL9K_TRANSIENT_PROMPT=off +[[ -r {highlight} ]] && source {highlight} +source {REPO}/modules/zsh/zsh/lsd_aliases.zsh +# Hidden setup: `dotfiles` is this checkout's bin/dotfiles against the sandbox. +dotfiles() {{ + command env DOTFILES_PROFILE=omarchy DOTFILES_DIR="$HOME/.dotfiles" bash {REPO}/bin/dotfiles "$@" +}} +""" + (self.zdotdir / ".zshrc").write_text(rc) + + def pane_env(self, **extra: str) -> list[str]: + env = { + "HOME": str(self.home), + "USER": "techdufus", + "LOGNAME": "techdufus", + "SHELL": "/usr/bin/zsh", + "PATH": f"{self.bin}:{PATH}", + "TERM": "tmux-256color", + "COLORTERM": "truecolor", + "LANG": "en_US.UTF-8", + "TMPDIR": str(self.tmp), + "ZDOTDIR": str(self.zdotdir), + "STUB_FIXTURES": str(self.fixtures), + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + **extra, + } + return [f"{k}={v}" for k, v in env.items()] + + def cleanup(self) -> None: + # tools run by nvim's first-run install (go, npm) can leave read-only trees behind + run(["chmod", "-R", "u+rwX", self.root], check=False) + shutil.rmtree(self.root, ignore_errors=True) + + +# ----------------------------------------------------------------------------- tmux + + +def tmux(*args: str, check: bool = True) -> str: + return run(["tmux", "-L", SOCKET, "-f", "/dev/null", "-2", *args], check=check).stdout + + +def tmux_start() -> None: + tmux("kill-server", check=False) + time.sleep(0.2) + tmux("new-session", "-d", "-s", "boot", "-x", "80", "-y", "24", "sleep", "3600") + for opt in ( + ("-g", "default-terminal", "tmux-256color"), + ("-g", "status", "off"), + ("-g", "escape-time", "0"), + ("-g", "history-limit", "2000"), + ("-g", "remain-on-exit", "off"), + ("-g", "window-size", "manual"), + ("-g", "allow-passthrough", "off"), + ("-g", "mouse", "off"), + ): + tmux("set", *opt) + tmux("set", "-as", "terminal-features", ",*:RGB") + + +def tmux_stop() -> None: + tmux("kill-server", check=False) + + +def new_pane(name: str, cols: int, rows: int, cwd: Path, argv: list[str]) -> None: + tmux("new-session", "-d", "-s", name, "-x", str(cols), "-y", str(rows), "-c", str(cwd), *argv) + + +def screen_text(name: str) -> str: + return tmux("capture-pane", "-p", "-t", name) + + +def screen_ansi(name: str) -> str: + # No -J: it would join the wrapped rows of the dry run's long notice and shift the grid. + return tmux("capture-pane", "-p", "-e", "-t", name) + + +def cursor_of(name: str) -> tuple[int, int, bool]: + x, y, visible = tmux("display-message", "-p", "-t", name, "#{cursor_x} #{cursor_y} #{cursor_flag}").split() + return int(x), int(y), visible == "1" + + +def wait_for(name: str, pattern: str, timeout: float = 60.0, what: str = "") -> str: + deadline = time.time() + timeout + rx = re.compile(pattern, re.M) + while time.time() < deadline: + text = screen_text(name) + if rx.search(text): + return text + time.sleep(0.25) + raise RenderError(f"timed out waiting for {what or pattern!r} in pane {name}:\n{screen_text(name)}") + + +def wait_stable(name: str, quiet: float = 2.0, timeout: float = 60.0) -> None: + """Return once the pane has not changed for `quiet` seconds.""" + deadline = time.time() + timeout + last, since = None, time.time() + while time.time() < deadline: + cur = screen_ansi(name) + if cur != last: + last, since = cur, time.time() + elif time.time() - since >= quiet: + return + time.sleep(0.25) + raise RenderError(f"pane {name} never settled") + + +class Capture: + def __init__(self, name: str, cols: int, rows: int) -> None: + self.cols, self.rows = cols, rows + self.grid = termsvg.parse_capture(screen_ansi(name), cols, rows) + self.cursor = cursor_of(name) + + def trim(self) -> "Capture": + """Drop blank rows below the last prompt line (the window is sized to its content).""" + keep = self.rows - termsvg.blank_rows_at_end(self.grid) + keep = max(keep, self.cursor[1] + 1) + self.grid = self.grid[:keep] + self.rows = keep + return self + + def drop_wrapped_notice(self, prefix: str) -> "Capture": + """Remove a one-off plain-text notice that is wider than the 80-column UI (and so wraps in + this pane) together with its wrapped continuation. Only the dry run prints one, before + the UI starts: "dotfiles: previewing local modules; no setup, pull, or conflict moves + (mise may evaluate config templates and update metadata, state and caches)".""" + texts = ["".join(c.ch for c in row) for row in self.grid] + start = next((i for i, t in enumerate(texts) if t.startswith(prefix)), None) + if start is None: + raise RenderError(f"notice {prefix!r} not found on screen") + end = start + while end < len(texts) and texts[end].strip(): + end += 1 + del self.grid[start:end] + self.rows -= end - start + x, y, vis = self.cursor + self.cursor = (x, y - (end - start), vis) + return self + + +def zsh_pane(sb: Sandbox, name: str, cols: int, rows: int, cwd: Path | None = None, **env: str) -> None: + new_pane(name, cols, rows, cwd or sb.repo, ["env", "-i", *sb.pane_env(**env), "zsh", "-i"]) + # Prompt drawn and gitstatus chip loaded (the branch name appears once it has). + wait_for(name, r"❯", 30, "the first prompt") + wait_for(name, r"main", 30, "the git chip") + time.sleep(0.6) + + +def type_command(name: str, cmd: str) -> None: + tmux("send-keys", "-t", name, "-l", cmd) + time.sleep(0.4) + tmux("send-keys", "-t", name, "Enter") + + +def finished(name: str, timeout: float = 90.0) -> None: + """The footer is printed and a fresh prompt follows it.""" + wait_for(name, r"(converged|dry run|failed) in [0-9.ms ]+\n(?:.*\n){2,6}.*❯", timeout, "the footer and the next prompt") + time.sleep(1.2) # command_execution_time chip, git chip refresh + + +def cli_capture(sb: Sandbox, name: str, args: str, rows: int, *, behind: int, fail_stage: str = "", trim: bool = True) -> Capture: + sb.make_clone(behind) + env = {"STUB_FAIL_STAGE": fail_stage} if fail_stage else {} + zsh_pane(sb, name, CLI_COLS, rows, **env) + type_command(name, args) + finished(name) + cap = Capture(name, CLI_COLS, rows) + tmux("kill-session", "-t", name, check=False) + return cap.trim() if trim else cap + + +def prompt_command(sb: Sandbox, name: str, cols: int, rows: int, command: str, *, cwd: Path | None = None, **env: str) -> Capture: + """Type `command` at the real prompt, wait for the next prompt, capture the screen.""" + zsh_pane(sb, name, cols, rows, cwd, **env) + type_command(name, command) + wait_for(name, r"(?s)❯ " + re.escape(command) + r".*\n.*❯", 30, "the next prompt") + time.sleep(1.0) + cap = Capture(name, cols, rows) + tmux("kill-session", "-t", name, check=False) + return cap + + +NVIM_FILE = "modules/omarchy/hypr/cells.lua" +NVIM_LINE = 179 # `inset`: floating cells shrunk by gaps and border + + +def prepare_nvim_cache(sb: Sandbox) -> dict[str, str]: + """Neovim runs with the repo's config copied next to a persistent plugin cache, never + the real ~/.config, ~/.local/share or any tokens (HOME is the sandbox).""" + config = NVIM_CACHE / "config" + for sub in ("data", "state", "cache"): + (NVIM_CACHE / sub).mkdir(parents=True, exist_ok=True) + shutil.rmtree(config / "nvim", ignore_errors=True) + marker = NVIM_CACHE / ".synced" + shutil.copytree(REPO / "modules/neovim/nvim", config / "nvim") + # lualine's clock is os.date("%R"); pin it to the staged time like the prompt and the bar + statusline = config / "nvim/lua/plugins/statusline.lua" + statusline.write_text(statusline.read_text().replace('os.date("%R")', f'"{FIXED_CLOCK[:5]}"')) + env = { + "XDG_CONFIG_HOME": str(config), + "XDG_DATA_HOME": str(NVIM_CACHE / "data"), + "XDG_STATE_HOME": str(sb.root / "nvim-state"), + "XDG_CACHE_HOME": str(NVIM_CACHE / "cache"), + } + if not (NVIM_CACHE / "data/nvim/lazy/lazy.nvim").exists() or not marker.exists(): + # First run: clone the plugins (network), build the treesitter parsers, and give mason + # time to fetch its language servers so no install popups appear in the screenshot. + print(" nvim: installing plugins into", NVIM_CACHE, "(first run only, a few minutes)", flush=True) + quiet_env = {"PATH": PATH, "TERM": "tmux-256color", "LANG": "en_US.UTF-8", **env, "HOME": str(sb.home)} + run(["nvim", "--headless", "+Lazy! sync", "+qa"], env=quiet_env, timeout=900) + run(["nvim", "--headless", "+lua vim.defer_fn(function() vim.cmd('qa!') end, 240000)"], env=quiet_env, timeout=900) + marker.write_text("primed\n") + return env + + +def nvim_capture(sb: Sandbox, cols: int, rows: int) -> Capture: + env = prepare_nvim_cache(sb) + pane_env = sb.pane_env(**env) + new_pane("nvim", cols, rows, sb.repo, ["env", "-i", *pane_env, "nvim", "-R", "-n", "-i", "NONE", f"+{NVIM_LINE}", NVIM_FILE]) + wait_for("nvim", r"cells\.lua", 60, "the nvim statusline") + # lua_ls indexes the checkout and mason may report; wait until no progress spinner or + # install message is on screen and the pane has been still for a while. + busy = re.compile(r"Diagnosing|Loading|Indexing|[Ii]nstalling|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]") + deadline, last, since = time.time() + 300, None, time.time() + while time.time() < deadline: + cur = screen_ansi("nvim") + if cur != last or busy.search(screen_text("nvim")): + last, since = cur, time.time() + elif time.time() - since >= 6: + break + time.sleep(0.5) + else: + raise RenderError("nvim never settled:\n" + screen_text("nvim")) + # Inline git blame appears after a race; switch it off so every run looks alike. Then clear + # popups and put the function head at the top of the window. + tmux("send-keys", "-t", "nvim", ":lua pcall(function() require('gitsigns').toggle_current_line_blame(false) end)", "Enter") + tmux("send-keys", "-t", "nvim", ":Noice dismiss", "Enter", "z", "t") + wait_stable("nvim", quiet=2.0) + cap = Capture("nvim", cols, rows) + tmux("kill-session", "-t", "nvim", check=False) + return cap + + +# ----------------------------------------------------------------------------- SVG composition + + +FONT = termsvg.FONT_FAMILY +ACCENT = "#89b4fa" +INACTIVE_BORDER = ("#595959", 0.67) # Omarchy's rgba(595959aa) +CURSOR = "#f5e0dc" # catppuccin cursor colour + + +def esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<") + + +def stage_file(sb: Sandbox, name: str, svg: str) -> Path: + path = sb.root / f"{name}.svg" + path.write_text(svg) + return path + + +def backdrop(w: float, h: float, wall: Path, dim: float) -> str: + return ( + f'' + f'' + ) + + +def terminal(x: float, y: float, w: float, h: float, cap: Capture, *, border: tuple[str, float], radius: float = 0, show_cursor: bool = False, balance: bool = True) -> str: + """A Ghostty window: 2 px border, #030304 body, grid centred in the padding.""" + bw = 2 + inner_w, inner_h = w - 2 * bw, h - 2 * bw + ox = bw + (round((inner_w - cap.cols * CELL_W) / 2 * 2) / 2 if balance else 10) + oy = bw + (round((inner_h - cap.rows * CELL_H) / 2 * 2) / 2 if balance else 10) + cx, cy, vis = cap.cursor + body = termsvg.grid_svg( + cap.grid, CELL_W, CELL_H, cursor=(cx, cy) if show_cursor and vis else None, cursor_color=CURSOR + ) + color, opacity = border + return ( + f'' + f'' + f'{body}' + ) + + +def shadow(x: float, y: float, w: float, h: float, radius: float, blur: float, dy: float, opacity: float, fid: str) -> str: + return ( + f'' + f'' + ) + + +def standalone_svg(caps: list[Capture]) -> list[tuple[str, int, int]]: + """One Ghostty window per capture on the wallpaper, all at the same scale (the tallest fits the 6% margin). + Canvas 1200x960 logical (2400x1920 @2x): both gallery images share a size so their table rows align.""" + w_img, h_img = 1200, 960 + sizes = [(c.cols * CELL_W + 2 * 10 + 4, c.rows * CELL_H + 2 * 10 + 4) for c in caps] + scale = min(min(0.88 * w_img / w, 0.88 * h_img / h) for w, h in sizes) + out = [] + for cap, (w, h) in zip(caps, sizes): + sw, sh = w * scale, h * scale + x, y = (w_img - sw) / 2, (h_img - sh) / 2 + svg = ( + f'' + + backdrop(w_img, h_img, WALLPAPER, 0.28) + + shadow(x, y, sw, sh, 8, 16, 10, 0.6, "sh") + + f'' + + terminal(0, 0, w, h, cap, border=(ACCENT, 1.0), radius=8 / scale, show_cursor=True, balance=False) + + "" + ) + out.append((svg, w_img, h_img)) + return out + + +def hero_svg(dotfiles: Capture, lsd: Capture, nvim: Capture, fetch: Capture, layout: "HeroLayout") -> str: + L = layout + bar = ( + f'' + + "".join( + f'{i + 1}' for i in range(5) + ) + + "" + f'Thursday 10:24' + f'' + "\uf293 \uf1eb \uf028 \uf240" + ) + tiles = ( + terminal(L.xl, L.y0, L.wl, L.h1, dotfiles, border=(ACCENT, 1.0), show_cursor=True) + + terminal(L.xl, L.y0 + L.h1 + L.gap, L.wl, L.h2, lsd, border=INACTIVE_BORDER) + + terminal(L.xr, L.y0, L.wr, L.h1, nvim, border=INACTIVE_BORDER) + + terminal(L.xr, L.y0 + L.h1 + L.gap, L.wr, L.h2, fetch, border=INACTIVE_BORDER) + ) + return ( + f'' + + backdrop(1600, 900, WALLPAPER, 0.4) + + shadow(L.sx, L.sy, L.sw, L.sh, 16, 22, 14, 0.7, "screen-shadow") + + f'' + + f'' + + f'' + + f'{bar}' + + tiles + + f'' + "" + ) + + +class HeroLayout: + """Omarchy tiling inside a 16:9 'screen' (gaps_out 4, gaps_in 2 => 4 between windows, 2 px borders).""" + + bar_h, gap, outer = 22, 4, 4 + pad, border = 10, 2 + + def __init__(self) -> None: + self.sw = 1472 # 92% of the 1600 canvas + self.sh = self.sw * 9 / 16 + self.sx, self.sy = (1600 - self.sw) / 2, (900 - self.sh) / 2 + self.y0 = self.bar_h + self.outer + avail_h = self.sh - self.y0 - self.outer + self.xl = self.outer + self.cols_l = CLI_COLS + self.wl = self.cols_l * CELL_W + 2 * (self.pad + self.border) + self.xr = self.xl + self.wl + self.gap + self.wr = self.sw - self.outer - self.xr + self.cols_r = int((self.wr - 2 * (self.pad + self.border)) // CELL_W) + self.rows_top = 27 + self.h1 = self.rows_top * CELL_H + 2 * (self.pad + self.border) + self.h2 = avail_h - self.gap - self.h1 + self.rows_bottom = int((self.h2 - 2 * (self.pad + self.border)) // CELL_H) + + +def rasterize(svg: str, name: str, sb: Sandbox, out: Path, zoom: int = 2) -> Path: + shutil.copy2(WALLPAPER, sb.root / WALLPAPER.name) + src = stage_file(sb, name, svg) + run(["rsvg-convert", "-z", str(zoom), "-o", out, src]) + return out + + +# ----------------------------------------------------------------------------- main + + +def scene_fetch_config(sb: Sandbox) -> None: + """fastfetch: the stock defaults minus lines that depend on the terminal or reveal the network.""" + cfg = sb.home / ".config/fastfetch" + cfg.mkdir(parents=True) + (cfg / "config.jsonc").write_text( + json.dumps( + { + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "logo": {"type": "small", "color": {"1": "blue"}}, + "display": {"color": {"keys": "blue", "title": "blue", "separator": "bright_black"}}, + "modules": ["title", "separator", "os", "kernel", "shell", "packages", "cpu", "gpu", "memory", "disk"], + }, + indent=2, + ) + ) + + +def build_hero(sb: Sandbox, out: Path) -> Path: + L = HeroLayout() + scene_fetch_config(sb) + log("hero: dotfiles tile (a real run against the stub mise, ~25 s)") + dotfiles = cli_capture(sb, "hero-cli", "dotfiles", L.rows_top, behind=3, trim=False) + log("hero: lsd tile") + lsd = prompt_command(sb, "hero-lsd", L.cols_l, L.rows_bottom, "lsd modules", XDG_CONFIG_HOME=str(REAL_HOME / ".config")) + log("hero: fastfetch tile") + fetch = prompt_command(sb, "hero-fetch", L.cols_r, L.rows_bottom, "fastfetch", XDG_CONFIG_HOME=str(sb.home / ".config")) + log("hero: nvim tile (waits for the language server to go idle)") + nvim = nvim_capture(sb, L.cols_r, L.rows_top) + log("hero: composing") + return rasterize(hero_svg(dotfiles, lsd, nvim, fetch, L), "hero", sb, out) + + +def build_standalone(sb: Sandbox, outs: dict[str, Path]) -> list[Path]: + dry = cli_capture(sb, "dry", "dotfiles --dry-run", 46, behind=0).drop_wrapped_notice("dotfiles: previewing local modules") + fail = cli_capture(sb, "fail", "dotfiles", 46, behind=3, fail_stage="tools") + paths = [] + for (svg, _, _), key in zip(standalone_svg([dry, fail]), ("dry-run", "failure")): + paths.append(rasterize(svg, key, sb, outs[key])) + return paths + + +def main(argv: list[str]) -> int: + wanted = set(argv) or {"header", "hero", "dry-run", "failure"} + unknown = wanted - {"header", "hero", "dry-run", "failure"} + if unknown: + print(f"usage: render.py [header] [hero] [dry-run] [failure] (unknown: {' '.join(sorted(unknown))})", file=sys.stderr) + return 2 + started = time.time() + written: list[Path] = [] + if "header" in wanted: + import header # header.py: the wordmark banners + + written += header.render(ASSETS_DIR) + if wanted & {"hero", "dry-run", "failure"}: + sb = Sandbox() + try: + tmux_start() + if "hero" in wanted: + written.append(build_hero(sb, ASSETS_DIR / "hero.png")) + if wanted & {"dry-run", "failure"}: + outs = {"dry-run": ASSETS_DIR / "dry-run.png", "failure": ASSETS_DIR / "failure.png"} + written += build_standalone(sb, outs) + finally: + tmux_stop() + sb.cleanup() + for p in written: + print(f"wrote {p.relative_to(REPO)} ({p.stat().st_size / 1024:.0f} KiB)") + print(f"done in {time.time() - started:.0f}s") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/assets/render/stub-mise b/.github/assets/render/stub-mise new file mode 100755 index 000000000..71ee70221 --- /dev/null +++ b/.github/assets/render/stub-mise @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Stand-in for mise, used only by render.py so that the real bin/dotfiles UI can run +# unattended and deterministically. It answers exactly the calls bin/dotfiles makes and +# replays recorded output from $STUB_FIXTURES (see capture-fixtures.sh). Nothing is installed. +# +# STUB_FAIL_STAGE= that stage prints fixtures/fail-.txt and exits 1 +# STUB_SPEED= scales the per-stage sleeps (default 1) +# +# Stage sleeps mimic a real run so the elapsed times in the screen look natural. +set -u +fx=${STUB_FIXTURES:?STUB_FIXTURES not set} +speed=${STUB_SPEED:-1} + +nap() { sleep "$(awk -v s="$1" -v k="$speed" 'BEGIN { printf "%.2f", s * k }')"; } + +case ${1:-} in + --version) + echo "2026.10.12 linux-x64 (2026-10-16)" + exit 0 + ;; +esac + +case "${1:-} ${2:-}" in + "tasks ls") cat "$fx/tasks"; exit 0 ;; + "ls --missing") nap 0.3; cat "$fx/missing"; exit 0 ;; + "bootstrap plan") nap 0.4; cat "$fx/plan.json"; exit 0 ;; + "bootstrap user") echo "/usr/bin/zsh /usr/bin/zsh set"; exit 0 ;; + "dot status") + case " $* " in *" --json "*) nap 0.5; cat "$fx/dot.json" ;; esac + exit 0 + ;; + "dot apply") exit 0 ;; +esac + +# mise bootstrap --yes --only [--dry-run ...] +only="" prev="" dry=0 +for a in "$@"; do + [ "$prev" != --only ] || only=$a + [ "$a" != --dry-run ] || dry=1 + prev=$a +done +[ -n "$only" ] || exit 0 + +case $only in + accounts,plugins,packages) stage=packages secs=2.6 dry_secs=0.7 ;; + files,services,firewall,compose) stage=system secs=0.9 dry_secs=0.5 ;; + repos,dotfiles) stage=dotfiles secs=2.1 dry_secs=1.3 ;; + mise-shell-activate,*) stage=shell secs=0.6 dry_secs=0.4 ;; + tools) stage=tools secs=7.8 dry_secs=1.6 ;; + task,final-hook) stage=modules secs=9.4 dry_secs=0.6 ;; + *) echo "stub mise: unknown parts $only" >&2; exit 2 ;; +esac + +if [ "$dry" = 1 ]; then + nap "$dry_secs" + cat "$fx/dry-$stage.txt" + exit 0 +fi + +if [ "${STUB_FAIL_STAGE:-}" = "$stage" ]; then + nap 5.2 + cat "$fx/fail-$stage.txt" + exit 1 +fi +cat "$fx/apply-$stage.txt" +nap "$secs" +exit 0 diff --git a/.github/assets/render/stub-sudo b/.github/assets/render/stub-sudo new file mode 100755 index 000000000..06e4e4a24 --- /dev/null +++ b/.github/assets/render/stub-sudo @@ -0,0 +1,3 @@ +#!/bin/sh +# Stand-in for sudo, used only by render.py: reports a valid ticket and never runs anything. +exit 0 diff --git a/.github/assets/render/termsvg.py b/.github/assets/render/termsvg.py new file mode 100644 index 000000000..0ba69cc71 --- /dev/null +++ b/.github/assets/render/termsvg.py @@ -0,0 +1,433 @@ +"""Terminal screen capture (tmux capture-pane -e) -> SVG fragment. + +Stdlib only. The screen is parsed into a cell grid (SGR 16/256/truecolor, bold, italic, +dim, reverse, underline, strike); the SVG places every glyph on the exact column grid +with a per-character `x` list, so proportional fallback glyphs and Nerd Font icons can +never shift a column. Box drawing, block elements and Powerline separators are drawn as +shapes (like Ghostty's builtin renderer) so lines meet across cells without gaps. +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from xml.sax.saxutils import escape + +FONT_FAMILY = "BerkeleyMono Nerd Font, DejaVu Sans Mono, monospace" +ADVANCE_EM = 0.6 # BerkeleyMono advance, measured with rsvg-convert +ASCENT_EM = 0.956 # hhea ascent; ascent + |descent| = 1.2 em = the cell height + +DEFAULT_BG = "#030304" +DEFAULT_FG = "#cdd6f4" + +# Ghostty's catppuccin-mocha palette 0-15. +ANSI16 = [ + "#45475a", "#f38ba8", "#a6e3a1", "#f9e2af", "#89b4fa", "#f5c2e7", "#94e2d5", "#bac2de", + "#585b70", "#f38ba8", "#a6e3a1", "#f9e2af", "#89b4fa", "#f5c2e7", "#94e2d5", "#a6adc8", +] + + +def _xterm256(n: int) -> str: + if n < 16: + return ANSI16[n] + if n < 232: + n -= 16 + steps = (0, 95, 135, 175, 215, 255) + return "#%02x%02x%02x" % (steps[n // 36], steps[n // 6 % 6], steps[n % 6]) + v = 8 + (n - 232) * 10 + return "#%02x%02x%02x" % (v, v, v) + + +@dataclass(frozen=True) +class Style: + fg: str | None = None # "#rrggbb", or None for the default + bg: str | None = None + fg_idx: int | None = None # ANSI index 0-7, kept for bold-is-bright + bold: bool = False + italic: bool = False + dim: bool = False + reverse: bool = False + underline: bool = False + strike: bool = False + hidden: bool = False + + +@dataclass +class Cell: + ch: str = " " + style: Style = Style() + wide_tail: bool = False # right half of a double-width character + + +Grid = list[list[Cell]] + +_SGR = re.compile(r"\x1b\[([0-9;:]*)m") +_ESC_OTHER = re.compile(r"\x1b\[[0-9;:?]*[A-HJKSTfhlnsu]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>]") + + +def _apply_sgr(style: Style, params: str) -> Style: + codes = [c for c in re.split(r"[;:]", params)] if params else ["0"] + nums = [int(c) if c else 0 for c in codes] + s = style + i = 0 + while i < len(nums): + n = nums[i] + if n == 0: + s = Style() + elif n == 1: + s = _r(s, bold=True) + elif n == 2: + s = _r(s, dim=True) + elif n == 3: + s = _r(s, italic=True) + elif n in (4, 21): + s = _r(s, underline=True) + elif n == 7: + s = _r(s, reverse=True) + elif n == 8: + s = _r(s, hidden=True) + elif n == 9: + s = _r(s, strike=True) + elif n == 22: + s = _r(s, bold=False, dim=False) + elif n == 23: + s = _r(s, italic=False) + elif n == 24: + s = _r(s, underline=False) + elif n == 27: + s = _r(s, reverse=False) + elif n == 28: + s = _r(s, hidden=False) + elif n == 29: + s = _r(s, strike=False) + elif 30 <= n <= 37: + s = _r(s, fg=ANSI16[n - 30], fg_idx=n - 30) + elif n == 39: + s = _r(s, fg=None, fg_idx=None) + elif 40 <= n <= 47: + s = _r(s, bg=ANSI16[n - 40]) + elif n == 49: + s = _r(s, bg=None) + elif 90 <= n <= 97: + s = _r(s, fg=ANSI16[n - 90 + 8], fg_idx=None) + elif 100 <= n <= 107: + s = _r(s, bg=ANSI16[n - 100 + 8]) + elif n in (38, 48): + target = "fg" if n == 38 else "bg" + mode = nums[i + 1] if i + 1 < len(nums) else None + color = None + if mode == 5 and i + 2 < len(nums): + color = _xterm256(nums[i + 2]) + idx = nums[i + 2] if nums[i + 2] < 8 else None + i += 2 + elif mode == 2: + # 38;2;r;g;b, or the colon form 38:2::r:g:b (an empty colourspace slot) + skip = 1 if len(codes) > i + 2 and codes[i + 2] == "" and len(nums) >= i + 6 else 0 + rgb = nums[i + 2 + skip : i + 5 + skip] + if len(rgb) == 3: + color = "#%02x%02x%02x" % tuple(rgb) + idx = None + i += 4 + skip + if color is not None: + if target == "fg": + s = _r(s, fg=color, fg_idx=idx) + else: + s = _r(s, bg=color) + i += 1 + return s + + +def _r(style: Style, **kw) -> Style: + return Style(**{**style.__dict__, **kw}) + + +def _width(ch: str) -> int: + if unicodedata.combining(ch): + return 0 + return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 + + +def parse_capture(text: str, cols: int, rows: int) -> Grid: + """Parse `tmux capture-pane -p -e [-J]` output into a rows x cols grid.""" + grid: Grid = [[Cell() for _ in range(cols)] for _ in range(rows)] + for y, line in enumerate(text.split("\n")[:rows]): + line = _ESC_OTHER.sub("", line) + style = Style() + x = 0 + pos = 0 + for m in _SGR.finditer(line): + x, style = _put(grid[y], x, line[pos : m.start()], style) + style = _apply_sgr(style, m.group(1)) + pos = m.end() + _put(grid[y], x, line[pos:], style) + return grid + + +def _put(row: list[Cell], x: int, chunk: str, style: Style) -> tuple[int, Style]: + for ch in chunk: + w = _width(ch) + if w == 0: + continue # combining marks are not drawn separately + if x + w > len(row): + break + row[x] = Cell(ch, style) + if w == 2: + row[x + 1] = Cell(" ", style, wide_tail=True) + x += w + return x, style + + +def blank_rows_at_end(grid: Grid) -> int: + n = 0 + for row in reversed(grid): + if any(c.ch != " " or c.style.bg or c.style.reverse for c in row): + break + n += 1 + return n + + +# --------------------------------------------------------------------------------------- +# colour helpers + + +def _rgb(h: str) -> tuple[int, int, int]: + return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16) + + +def mix(a: str, b: str, t: float) -> str: + """a*(1-t) + b*t""" + ra, rb = _rgb(a), _rgb(b) + return "#%02x%02x%02x" % tuple(round(x * (1 - t) + y * t) for x, y in zip(ra, rb)) + + +def resolve(style: Style, default_fg: str, default_bg: str, bold_is_bright: bool = True) -> tuple[str, str | None]: + """Final (fg, bg) of a cell; bg None means 'the terminal background'.""" + fg = style.fg + if fg is None: + fg = default_fg + elif bold_is_bright and style.bold and style.fg_idx is not None: + fg = ANSI16[style.fg_idx + 8] + bg = style.bg + if style.reverse: + fg, bg = (bg or default_bg), fg + if style.dim: + fg = mix(fg, bg or default_bg, 0.5) + return fg, bg + + +# --------------------------------------------------------------------------------------- +# drawn glyphs + +# (left, right, up, down) weights: 0 none, 1 light, 2 heavy +_LINES = { + "─": (1, 1, 0, 0), "━": (2, 2, 0, 0), "│": (0, 0, 1, 1), "┃": (0, 0, 2, 2), + "┌": (0, 1, 0, 1), "┏": (0, 2, 0, 2), "┐": (1, 0, 0, 1), "┓": (2, 0, 0, 2), + "└": (0, 1, 1, 0), "┗": (0, 2, 2, 0), "┘": (1, 0, 1, 0), "┛": (2, 0, 2, 0), + "├": (0, 1, 1, 1), "┣": (0, 2, 2, 2), "┤": (1, 0, 1, 1), "┫": (2, 0, 2, 2), + "┬": (1, 1, 0, 1), "┳": (2, 2, 0, 2), "┴": (1, 1, 1, 0), "┻": (2, 2, 2, 0), + "┼": (1, 1, 1, 1), "╋": (2, 2, 2, 2), + "╴": (1, 0, 0, 0), "╵": (0, 0, 1, 0), "╶": (0, 1, 0, 0), "╷": (0, 0, 0, 1), + "┄": (1, 1, 0, 0), "┈": (1, 1, 0, 0), "╌": (1, 1, 0, 0), "┆": (0, 0, 1, 1), + "┊": (0, 0, 1, 1), "╎": (0, 0, 1, 1), +} +_ARCS = {"╭": "dr", "╮": "dl", "╯": "ul", "╰": "ur"} # which two sides the arc joins + +# block elements as fractions (x0, y0, x1, y1) of the cell, filled with the foreground +_BLOCKS: dict[str, list[tuple[float, float, float, float]]] = { + "▀": [(0, 0, 1, .5)], "▄": [(0, .5, 1, 1)], "█": [(0, 0, 1, 1)], "▌": [(0, 0, .5, 1)], "▐": [(.5, 0, 1, 1)], + "▔": [(0, 0, 1, 1 / 8)], "▕": [(7 / 8, 0, 1, 1)], + "▖": [(0, .5, .5, 1)], "▗": [(.5, .5, 1, 1)], "▘": [(0, 0, .5, .5)], "▝": [(.5, 0, 1, .5)], + "▙": [(0, 0, .5, 1), (.5, .5, 1, 1)], "▚": [(0, 0, .5, .5), (.5, .5, 1, 1)], + "▛": [(0, 0, 1, .5), (0, .5, .5, 1)], "▜": [(0, 0, 1, .5), (.5, .5, 1, 1)], + "▞": [(.5, 0, 1, .5), (0, .5, .5, 1)], "▟": [(.5, 0, 1, 1), (0, .5, 1, 1)], +} +for _i, _c in enumerate("▁▂▃▄▅▆▇"): + _BLOCKS[_c] = [(0, 1 - (_i + 1) / 8, 1, 1)] +for _i, _c in enumerate("▉▊▋▌▍▎▏"): + _BLOCKS[_c] = [(0, 0, (7 - _i) / 8, 1)] +_SHADES = {"░": 0.25, "▒": 0.5, "▓": 0.75} + + +def _fmt(v: float) -> str: + s = f"{v:.2f}".rstrip("0").rstrip(".") + return s or "0" + + +def _glyph_shapes(ch: str, x: float, y: float, w: float, h: float, fg: str) -> str | None: + """SVG for a drawn glyph, or None when `ch` is rendered as text.""" + if ch in _LINES: + l, r, u, d = _LINES[ch] + cx, cy = x + w / 2, y + h / 2 + out = [] + for weight, side in ((l, "l"), (r, "r"), (u, "u"), (d, "d")): + if not weight: + continue + t = 1.0 if weight == 1 else 2.0 + # every arm reaches half a stroke past the centre so corners and joins are filled + if side == "l": + rx, ry, rw, rh = x, cy - t / 2, cx + t / 2 - x, t + elif side == "r": + rx, ry, rw, rh = cx - t / 2, cy - t / 2, x + w - cx + t / 2, t + elif side == "u": + rx, ry, rw, rh = cx - t / 2, y, t, cy + t / 2 - y + else: + rx, ry, rw, rh = cx - t / 2, cy - t / 2, t, y + h - cy + t / 2 + out.append(f'') + return f'{"".join(out)}' + if ch in _ARCS: + cx, cy = x + w / 2, y + h / 2 + r = w / 2 + a = _ARCS[ch] + if a == "dr": # ╭: from the bottom edge up, round, out to the right edge + d = f"M{_fmt(cx)} {_fmt(y + h)}V{_fmt(cy + r)}A{_fmt(r)} {_fmt(r)} 0 0 1 {_fmt(cx + r)} {_fmt(cy)}H{_fmt(x + w)}" + elif a == "dl": # ╮ + d = f"M{_fmt(cx)} {_fmt(y + h)}V{_fmt(cy + r)}A{_fmt(r)} {_fmt(r)} 0 0 0 {_fmt(cx - r)} {_fmt(cy)}H{_fmt(x)}" + elif a == "ur": # ╰ + d = f"M{_fmt(cx)} {_fmt(y)}V{_fmt(cy - r)}A{_fmt(r)} {_fmt(r)} 0 0 0 {_fmt(cx + r)} {_fmt(cy)}H{_fmt(x + w)}" + else: # ╯ + d = f"M{_fmt(cx)} {_fmt(y)}V{_fmt(cy - r)}A{_fmt(r)} {_fmt(r)} 0 0 1 {_fmt(cx - r)} {_fmt(cy)}H{_fmt(x)}" + return f'' + if ch in _BLOCKS: + rects = "".join( + f'' + for a, b, c, d in _BLOCKS[ch] + ) + return f'{rects}' + if ch in _SHADES: + return f'' + # Powerline separators (Nerd Font E0B0-E0B7) + if ch == "\ue0b0": + return f'' + if ch == "\ue0b2": + return f'' + if ch == "\ue0b1": + return f'' + if ch == "\ue0b3": + return f'' + if ch == "\ue0b4": # half circle bulging right + return f'' + if ch == "\ue0b6": # half circle bulging left + return f'' + if ch == "\ue0b5": + return f'' + if ch == "\ue0b7": + return f'' + return None + + +# --------------------------------------------------------------------------------------- + + +def _is_icon(ch: str) -> bool: + o = ord(ch) + return 0xE000 <= o <= 0xF8FF or o >= 0xF0000 + + +def _text(x: float, y: float, s: str, fg: str, bold: bool, italic: bool, *, size: float | None = None, anchor: str = "") -> str: + attrs = ' font-weight="bold"' if bold else "" + attrs += ' font-style="italic"' if italic else "" + attrs += f' font-size="{_fmt(size)}"' if size is not None else "" + attrs += f' text-anchor="{anchor}"' if anchor else "" + return f'{escape(s)}' + + +def grid_svg( + grid: Grid, + cell_w: float, + cell_h: float, + *, + default_fg: str = DEFAULT_FG, + default_bg: str = DEFAULT_BG, + cursor: tuple[int, int] | None = None, + cursor_color: str = DEFAULT_FG, + bold_is_bright: bool = True, + pad_rect: bool = False, +) -> str: + """Return a in the grid's own coordinates (origin at the grid's top-left).""" + fs = cell_h / 1.2 + baseline = ASCENT_EM * fs + rows, cols = len(grid), len(grid[0]) if grid else 0 + parts: list[str] = [] + if pad_rect: + parts.append(f'') + + # 1. backgrounds, merged per row + bgs: list[str] = [] + for y, row in enumerate(grid): + x = 0 + while x < cols: + _, bg = resolve(row[x].style, default_fg, default_bg, bold_is_bright) + if bg is None or bg == default_bg: + x += 1 + continue + x2 = x + 1 + while x2 < cols and resolve(row[x2].style, default_fg, default_bg, bold_is_bright)[1] == bg: + x2 += 1 + # half a device pixel of overlap hides seams between rows + bgs.append( + f'' + ) + x = x2 + parts.extend(bgs) + + # 2. cursor block under the glyph + if cursor is not None: + cx, cy = cursor + if 0 <= cy < rows and 0 <= cx < cols: + parts.append(f'') + + # 3. glyphs: drawn shapes; ASCII words as one ; every other glyph on its own cell + for y, row in enumerate(grid): + top = y * cell_h + run: list | None = None # [key, x0_col, last_col, chars] + + def flush() -> None: + nonlocal run + if run is not None: + key, x0, _, chars = run + parts.append(_text(x0 * cell_w, top + baseline, "".join(chars), *key)) + run = None + + for x, c in enumerate(row): + if c.ch == " " or c.wide_tail or c.style.hidden: + flush() + continue + fg, bg = resolve(c.style, default_fg, default_bg, bold_is_bright) + if cursor == (x, y): + fg = default_bg + shape = _glyph_shapes(c.ch, x * cell_w, top, cell_w, cell_h, fg) + if shape is not None: + flush() + parts.append(shape) + elif _is_icon(c.ch): + flush() + # Like Ghostty: an icon may spill into a following blank cell, else it is fitted to one cell. + nxt = row[x + 1] if x + 1 < cols else None + span = 2 if nxt is None or (nxt.ch == " " and not nxt.wide_tail) else 1 + f2 = fs * min(1.0, span * cell_w / fs) + y2 = top + baseline - 0.33 * (fs - f2) + parts.append( + _text(x * cell_w + span * cell_w / 2, y2, c.ch, fg, c.style.bold, c.style.italic, size=f2, anchor="middle") + ) + else: + key = (fg, c.style.bold, c.style.italic) + if c.ch.isascii() and run is not None and run[0] == key and run[2] == x - 1: + run[2] = x + run[3].append(c.ch) + else: + flush() + if c.ch.isascii(): + run = [key, x, x, [c.ch]] + else: + parts.append(_text(x * cell_w, top + baseline, c.ch, *key)) + if c.style.underline: + parts.append(f'') + if c.style.strike: + parts.append(f'') + flush() + + return ( + f'' + "".join(parts) + "" + ) diff --git a/.github/workflows/ansible-lint.yml b/.github/workflows/ansible-lint.yml deleted file mode 100644 index bf8d7c90d..000000000 --- a/.github/workflows/ansible-lint.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Ansible CI -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Cache Ansible Galaxy modules - uses: actions/cache@v3 - with: - path: ~/.ansible/collections - key: ${{ runner.os }}-ansible-${{ hashFiles('requirements/common.yml') }} - restore-keys: | - ${{ runner.os }}-ansible- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install ansible-lint ansible-core - - - name: Install Galaxy Modules - run: ansible-galaxy install -r requirements/common.yml - - - name: Run ansible-lint - run: ansible-lint main.yml diff --git a/.github/workflows/markdown-link-config.json b/.github/workflows/markdown-link-config.json deleted file mode 100644 index 617556306..000000000 --- a/.github/workflows/markdown-link-config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "ignorePatterns": [ - { - "pattern": "^http://localhost" - } - ], - "replacementPatterns": [ - { - "pattern": "^/", - "replacement": "{{BASEURL}}/" - } - ], - "httpHeaders": [ - { - "urls": ["https://github.com"], - "headers": { - "Accept-Encoding": "zstd, br, gzip, deflate" - } - } - ], - "timeout": "20s", - "retryOn429": true, - "retryCount": 3, - "fallbackHttpStatus": [400, 401, 403, 404, 500, 502, 503, 504] -} \ No newline at end of file diff --git a/.github/workflows/markdown-links.yml b/.github/workflows/markdown-links.yml deleted file mode 100644 index 8b569ca31..000000000 --- a/.github/workflows/markdown-links.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Validate Markdown Links - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - paths: ['**/*.md'] - -jobs: - markdown-link-check: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Check markdown links - uses: gaurav-nelson/github-action-markdown-link-check@v1 - with: - use-quiet-mode: 'yes' - use-verbose-mode: 'yes' - config-file: '.github/workflows/markdown-link-config.json' - folder-path: '.' - file-path: './README.md' - - - name: Check docs folder - uses: gaurav-nelson/github-action-markdown-link-check@v1 - with: - use-quiet-mode: 'yes' - use-verbose-mode: 'yes' - config-file: '.github/workflows/markdown-link-config.json' - folder-path: './docs' \ No newline at end of file diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index 6859f3fa7..8d6aa2855 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '18' diff --git a/.github/workflows/mise.yml b/.github/workflows/mise.yml new file mode 100644 index 000000000..2dbb7c61f --- /dev/null +++ b/.github/workflows/mise.yml @@ -0,0 +1,161 @@ +name: mise + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + # Version lookups for github:/aqua tools hit the GitHub API. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # `mise run` would otherwise install every [tools] entry before running tests. + MISE_TASK_RUN_AUTO_INSTALL: "false" + +jobs: + arch: + runs-on: ubuntu-latest + container: archlinux:latest + steps: + - name: Install prerequisites + run: pacman -Syu --noconfirm mise git zsh lua bun jq python python-yaml + + - name: Checkout code + uses: actions/checkout@v7 + + - name: Link modules as mise conf.d + run: mkdir -p ~/.config/mise && ln -s "$GITHUB_WORKSPACE/modules" ~/.config/mise/conf.d + + # mise older than conf.d folder support loads no module and `mise bootstrap` exits 0 + # having done nothing; fail loudly instead. Run from ~ so no project config can shadow `check`. + - name: Verify modules loaded + run: | + cd ~ + mise tasks info check >/dev/null || { echo "modules did not load (mise $(mise --version))"; exit 1; } + + - name: Dry-run omarchy + run: MISE_AUTO_ENV=true MISE_ENV=omarchy mise bootstrap --dry-run --yes + + - name: Dry-run cachyos + run: MISE_AUTO_ENV=true MISE_ENV=cachyos mise bootstrap --dry-run --yes + + - name: Tests (omarchy) + run: MISE_AUTO_ENV=true MISE_ENV=omarchy mise run --jobs 1 'test:*' + + - name: Tests (cachyos) + run: MISE_AUTO_ENV=true MISE_ENV=cachyos mise run --jobs 1 'test:*' + + # Scoped real apply: only the dotfiles and tools steps. Packages, services, + # system files and the bootstrap task need systemd, an AUR helper, multilib + # and Omarchy's repo, none of which a stock container has; the dry-runs in + # `arch` cover those. This job catches what dry-runs cannot: tool installs + # that fail (npm trust policy, go builds) and dotfile entries that do not converge. + apply: + runs-on: ubuntu-latest + container: archlinux:latest + timeout-minutes: 25 + defaults: + run: + shell: bash + env: + # cachyos declares node = "lts", which the npm: tools need; omarchy relies + # on Omarchy pinning node globally. + MISE_AUTO_ENV: "true" + MISE_ENV: cachyos + # Registry lookups (proxy.golang.org, npm) time out at the 20s default. + MISE_FETCH_REMOTE_VERSIONS_TIMEOUT: 120s + MISE_HTTP_TIMEOUT: 120s + steps: + # gcc: go: tools with cgo (helm-ls). On a real machine base-devel comes from the packages step. + - name: Install prerequisites + run: pacman -Syu --noconfirm mise git zsh go gcc + + - name: Checkout code + uses: actions/checkout@v7 + + - name: Link modules as mise conf.d + run: mkdir -p ~/.config/mise && ln -s "$GITHUB_WORKSPACE/modules" ~/.config/mise/conf.d + + - name: Verify modules loaded + run: | + cd ~ + mise tasks info check >/dev/null || { echo "modules did not load (mise $(mise --version))"; exit 1; } + + - name: Apply dotfiles and tools + run: mise bootstrap --only dotfiles,tools --yes + + # Permission-only entries (~/.omp/agent/mcp.json, ~/.cursor/cli-config.json) cannot + # apply until a merge has created the file, so a fresh machine needs two runs. + - name: Apply again (settle first-run leftovers) + run: mise bootstrap --only dotfiles,tools --yes + + - name: Apply a third time (must change nothing) + run: | + mise bootstrap --only dotfiles,tools --yes 2>&1 | tee apply3.log + grep -F 'all files are applied' apply3.log + grep -F 'all edits are applied' apply3.log + grep -F 'all tools are installed' apply3.log + mise dot status --missing + # `mise ls --missing` prints one row per missing tool and exits 0 either way. + missing=$(mise ls --missing) + if [ -n "$missing" ]; then echo "missing tools:"; echo "$missing"; exit 1; fi + + - name: Smoke test shells + run: | + # zshenv puts the mise shims on PATH for every shell. + shims="$HOME/.local/share/mise/shims" + for tool in node gopls task; do + [ "$(zsh -c "command -v $tool")" = "$shims/$tool" ] || { echo "non-interactive zsh: $tool not at $shims/$tool"; exit 1; } + done + # The first interactive start clones zinit and its plugins; that output and the + # terminal control codes around it make the last line unusable, so tag ours. + out=$(TERM=xterm zsh -ic 'print -r -- NODE=$(command -v node)' 2>/dev/null /dev/null || { echo "modules did not load (mise $(mise --version))"; exit 1; } + + - name: Dry-run macos + run: | + cd ~ + MISE_AUTO_ENV=true mise bootstrap --dry-run --yes + + - name: Tests (all loaded macos modules) + run: | + cd ~ + # Enable the native source-relative fixture, not just its stubbed cases. + MISE_AUTO_ENV=true DOTFILES_TEST_REAL_MISE="$(command -v mise)" mise run --jobs 1 'test:*' diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index ac4412b05..cb818baff 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -5,32 +5,14 @@ on: branches: [ main ] pull_request: branches: [ main ] - paths: ['**/*.sh', 'bin/*'] + paths: ['bin/**', 'modules/**/*.sh'] jobs: shellcheck: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master - with: - scandir: './bin' - severity: error - format: gcc - disable_matcher: true - - - name: Run ShellCheck on shell scripts - uses: ludeeus/action-shellcheck@master - with: - scandir: '.' - severity: error - format: gcc - disable_matcher: true - ignore_paths: | - .git - .github - roles - docs \ No newline at end of file + run: shellcheck -x bin/dotfiles modules/*/bootstrap.sh modules/plasma/settings.sh modules/omarchy/theme.sh modules/omarchy/hypr-instance.sh diff --git a/.github/workflows/yaml-lint.yml b/.github/workflows/yaml-lint.yml deleted file mode 100644 index 22c4689a6..000000000 --- a/.github/workflows/yaml-lint.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: YAML Lint - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - paths: ['**/*.yml', '**/*.yaml'] - -jobs: - yaml-lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install yamllint - run: | - python -m pip install --upgrade pip - pip install yamllint - - - name: Create yamllint config - run: | - cat > .yamllint.yml << EOF - extends: default - rules: - line-length: disable - comments: disable - indentation: - spaces: 2 - truthy: - allowed-values: ['true', 'false', 'yes', 'no', 'on', 'off'] - document-start: disable - new-line-at-end-of-file: disable - trailing-spaces: disable - empty-lines: disable - brackets: disable - colons: disable - braces: disable - EOF - - - name: Run yamllint - run: yamllint -f github . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8c4d667c0..5fe31d7b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ -vault-password.txt - # Python cache directories __pycache__/ *.pyc *.pyo -roles/neovim/files/lazy-lock.json +modules/neovim/nvim/lazy-lock.json # Defensive only: refresh rejects Git worktree destinations, but never stage a # local plaintext cache or its temporary files if XDG_STATE_HOME is misdirected. @@ -17,15 +15,15 @@ secrets.zsh *.DS_Store # Company-specific Claude commands (not for public repo) -roles/claude/files/commands/raft/ -roles/claude/files/skills/rafty -roles/claude/files/commands/raft-*.md -roles/claude/files/agents/raft-*.md -roles/claude/files/hooks/peon-ping/ -roles/claude/files/skills/peon-ping-*/ +modules/claude/commands/raft/ +modules/claude/skills/rafty +modules/claude/commands/raft-*.md +modules/claude/agents/raft-*.md +modules/claude/hooks/peon-ping/ +modules/claude/skills/peon-ping-*/ # Herdr-managed Claude integration artifacts -roles/claude/files/hooks/herdr-agent-state.sh +modules/claude/hooks/herdr-agent-state.sh # Crush directory .crush/ @@ -33,16 +31,13 @@ roles/claude/files/hooks/herdr-agent-state.sh # Claude session snapshots (personal work state) .claude/snapshots/ -# Ansible cache/runtime directories -.ansible/ - # OMP runtime state is ignored; project skills are repository-managed. .omp/* !.omp/skills/ !.omp/skills/** -# OMP advisory config locks follow managed symlinks into this role. -roles/omp/files/**/config.yml.lock +# OMP advisory config locks follow managed symlinks into this module. +modules/omp/**/config.yml.lock # Logs logs @@ -63,7 +58,6 @@ node_modules/ *.njsproj *.sln *.sw? -# OS specific # Task files tasks.json diff --git a/.omp/skills/omp-release-config-audit/SKILL.md b/.omp/skills/omp-release-config-audit/SKILL.md index 9d8beb073..b40090066 100644 --- a/.omp/skills/omp-release-config-audit/SKILL.md +++ b/.omp/skills/omp-release-config-audit/SKILL.md @@ -6,7 +6,7 @@ condition: Use when reviewing OMP upgrades or release changes for compatibility # OMP release and configuration audit -Use this project-local, ancestor-discovered skill to decide whether a bounded set of OMP releases changes the safety, compatibility, or intent of repository-managed OMP behavior. It belongs in `.omp/skills/` and is not a role-managed or global OMP deployment. It is an evidence-gathering audit, not an upgrade or configuration-edit workflow. +Use this project-local, ancestor-discovered skill to decide whether a bounded set of OMP releases changes the safety, compatibility, or intent of repository-managed OMP behavior. It belongs in `.omp/skills/` and is not a module-managed or global OMP deployment. It is an evidence-gathering audit, not an upgrade or configuration-edit workflow. ## Non-negotiable evidence rules diff --git a/AGENTS.md b/AGENTS.md index ad8ea9163..c199c0ec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,39 @@ # AGENTS.md -This file is intentionally minimal. Add lines only for repo-specific landmines that are not obvious from the codebase. +Personal dotfiles applied with mise: `mise bootstrap`, `[dotfiles]`, `[tools]`, and tasks. Omarchy and macOS are primary; CachyOS remains supported. -- Secrets are referenced via 1Password `op://...`; never add plaintext secrets to the repo. -- Optional host/integration work should fail soft instead of breaking the whole play, especially around sudo and 1Password. -- Secret-bearing tasks should use `no_log: true`. -- Uninstall flows must not remove critical system dependencies such as `git` or system `python`. -- Prefer fixing confusing structure, docs, or automation over growing this file. If guidance is still needed, keep it short and scoped to the affected directory. +This file holds the rules, not an inventory. The config is the source of truth for what is managed, and README.md for commands and user-facing behavior; read them instead of copying details here. + +## Shape +- `bin/dotfiles` is the curl entrypoint and daily wrapper: it prepares git, mise and the checkout, links `modules/` as mise `conf.d`, records the machine's profile and purpose, then runs `mise bootstrap`. +- The wrapper evaluates from HOME unless given an explicit context, and an explicit context applies to every step; never mix the two. +- `modules//` is one conf.d folder: env files, config sources, an optional `bootstrap.sh`, and `tests/`. An app with config or steps gets its own module; pure installs and dev runtimes go in the shared package and devtools modules. +- Env files layer shared → platform → OS/session, and the most specific wins. A profile describes the OS and session, never the hardware. +- Purpose (work or personal) is independent of profile and lives in a local, untracked marker. Changing it only updates the marker and discovery settings; it never provisions. +- README images are generated by `.github/assets/render/render.py`; re-render when the wrapper's UI changes. + +## Rules +- Who writes a file decides how it deploys: link what only the repo writes; merge or edit just the owned keys of files the app also writes; copy only when a link cannot work, with the reason beside the entry. +- Always give an explicit relative `source`. +- Changing a directory's link mode: remove the old link on each machine first, or mise writes through it into the repo. +- Declare packages with the platform's native manager, in the most specific env file that needs them. Purpose restrictions use mise's native package `env` option; no parallel policy engine. +- One failed package install stops the run, so anything optional that can fail belongs in a fail-soft task instead. +- Imperative steps are idempotent `bootstrap:` tasks that fail soft: say what was skipped and exit 0. Only steps whose failure must stop the run, such as privilege policy or compositor config validation, exit non-zero. +- Files the wrapper generates belong to the wrapper; never hand-edit them. +- No secrets in the repo or in linked files: the repo is public. 1Password is reached only through the secrets module. +- Privilege changes are validated before install and target the real invoking user. Never weaken that to get a step through. +- Shared user configs such as Git: own one bounded block, publish the new owner before removing the old one, and preserve everything else. +- Retiring something: drop the declaration and document manual cleanup. Unconditional removal (`mode = "absent"`) only when intended and approved. +- Use apps' standard config locations; don't add a second deployer to fake what mise does not support. +- Tests execute shipped code and live beside it. No tests that grep config text, and no per-module READMEs. +- mise bootstrap is new: read and instead of guessing syntax. + +## Platform boundaries +- Omarchy owns its system tree, its Hyprland entry point, its desktop and gaming stack, and the configs and tools its installers manage. Hook in through its extension points, set only the keys we own in configs it ships, and don't redeclare tools it already installs. +- Keep CachyOS support profile-scoped; never port it to Omarchy automatically. + +## Verify before reporting done +- Use the README's preview and validation commands, run from HOME. Experiment in isolated HOME/XDG/mise directories; a preview must not change live state. +- Fix test fixtures, not production safety guards. +- A dry run or a macOS-hosted check is not proof for native Linux; check the affected OS directly. +- Apply live only when authorized, then require a clean status and a no-change repeat. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index aca373c3d..000000000 --- a/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -FROM ubuntu:22.04 - -LABEL maintainer="TechDufus " - -ARG USER=techdufus -ARG group=techdufus -ARG uid=1000 -ARG DEBIAN_FRONTEND=noninteractive - -ENV TZ="America/Chicago" - -USER ${USER} -USER root - -RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y \ - sudo \ - curl \ - git-core \ - gnupg \ - locales \ - tzdata \ - wget && \ - apt-get autoremove -y - -RUN locale-gen en_US.UTF-8 - -RUN adduser --quiet --disabled-password \ - --shell /bin/bash --home /home/${USER} \ - --gecos "User" ${USER} - -RUN mkdir -p /etc/sudoers.d && \ - touch /etc/sudoers.d/${USER} && \ - echo "%${group} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/${USER} && \ - groupadd docker && \ - usermod -aG docker ${USER} - -RUN chown -R ${USER}:${group} /home/${USER} -USER ${USER} - -COPY --chown=${USER}:${group} bin/dotfiles /home/${USER}/dotfiles - -RUN \ - mkdir -p /home/${USER}/.ansible-vault && \ - touch /home/${USER}/.ansible-vault/vault.secret && \ - echo '$vault_secret' > /home/${USER}/.ansible-vault/vault.secret - -# RUN bash -c "$(curl -fsSL https://raw.githubusercontent.com/TechDufus/dotfiles/main/bin/dotfiles)" -RUN git clone --quiet https://github.com/TechDufus/dotfiles.git /home/${USER}/.dotfiles -COPY --chown=${USER}:${group} ansible.cfg /home/${USER}/.dotfiles/ansible.cfg -RUN bash -c "/home/${USER}/dotfiles" - -RUN rm ~/.ansible-vault/vault.secret - -# CMD [] -# -# ENTRYPOINT ["/bin/bash"] diff --git a/README.md b/README.md index c3965bd0f..d226272b8 100644 --- a/README.md +++ b/README.md @@ -1,246 +1,355 @@ +
+ + + dotfiles + -![dotfiles-logo](https://github.com/TechDufus/dotfiles/assets/46715299/6c1d626d-28d2-41e3-bde5-981d9bf93462) -

- - - - - commit frequency -

+**One command to converge a whole machine: Omarchy, macOS, or CachyOS.** ---- -Fully automated development environment for [TechDufus](https://www.twitch.tv/TechDufus) on Twitch. +CI status +Stars +Last commit +License -You can watch a quick 'tour' (pre-1Password integration) here on YouTube: +Omarchy +macOS +CachyOS +Applied with mise - - Automating your Dotfiles with Ansible: A Showcase - +
Bootstrap
  +
Daily use
  +
Keybindings
  +
Gallery
  +
Validation
  +
Layout
  -This repo is heavily influenced by [ALT-F4-LLC](https://github.com/ALT-F4-LLC/dotfiles)'s repo. Go check it out! +
-## 📋 Table of Contents +Omarchy desktop with four tiled terminals: the dotfiles CLI converging the machine, lsd listing modules/, Neovim on cells.lua, and fastfetch -- [📋 Prerequisites](#-prerequisites) -- [🚀 Quick Start](#-quick-start) -- [🎯 Goals](#goals) -- [⚙️ Requirements](#requirements) -- [🔧 Setup](#setup) -- [📖 Usage](#usage) -- [📚 Documentation](#documentation) -- [⭐ Star History](#-star-history) +The real bin/dotfiles UI converging Omarchy against a stubbed mise, next to this repo's Neovim and prompt, rendered by render.py. -## 📋 Prerequisites +
-### macOS Users -Before starting, install [Homebrew](https://brew.sh/) (macOS package manager): +# dotfiles -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` +My personal dotfiles, applied with [mise](https://mise.jdx.dev/bootstrap.html). **Omarchy and macOS are the primary profiles; CachyOS remains supported.** + +| Profile | OS / session | +| --- | --- | +| `omarchy` | Omarchy (Hyprland), on laptops or desktops | +| `macos` | macOS (Apple Silicon) | +| `cachyos` | CachyOS (KDE Plasma) | -### Other Operating Systems -No prerequisites needed - the bootstrap script handles everything automatically. +Profiles describe the operating system and session, not the chassis. The wrapper and Taskfile recognize Omarchy by OS ID or an installation at `~/.local/share/omarchy` or `/usr/share/omarchy`; an explicit `DOTFILES_PROFILE` takes precedence. On Omarchy, its native tooling owns the desktop and gaming stack; CachyOS-specific gaming, KDE, and GPU policy is not automatically migrated. + +Other systems stop with `unsupported machine`. The earlier Ansible provisioning version, which also covered Ubuntu, Fedora and WSL, is tagged [`ansible-final`](https://github.com/TechDufus/dotfiles/tree/ansible-final). + +> [!NOTE] +> Public for reference, not as a framework. Read before you borrow. -## 🚀 Quick Start +## Highlights -**New to dotfiles?** → [Complete Beginner Guide](docs/QUICKSTART.md) +- One command bootstraps a machine: `bin/dotfiles` installs git and mise, clones or pulls `~/.dotfiles`, links `conf.d` to `modules/`, records the profile and purpose, then runs mise bootstrap. +- In a terminal, the run goes one stage at a time (packages, system, dotfiles, shell, tools, modules) with a live summary, and mise's full output is kept in `~/.local/state/dotfiles/last-run.log`. +- Nothing in the way of a link gets overwritten: a conflicting target is preserved in a unique `.pre-dotfiles../original` directory. +- Three profiles, `omarchy`, `macos` and `cachyos`, describe the OS and session; the work or personal purpose is independent of them. +- 43 modules, each one mise `conf.d` folder under `modules/`. +- macOS and Omarchy share the same CapsLock summon leader: **CapsLock → t** for Tern and **CapsLock → Shift+T** for Ghostty. +- Module test suites (`test:*`) run in CI on Arch (Omarchy and CachyOS environments) and on macOS. -**Want it fast?** Run this one command: +## Bootstrap ```bash bash -c "$(curl -fsSL https://raw.githubusercontent.com/TechDufus/dotfiles/main/bin/dotfiles)" ``` -**What happens:** -1. **Prerequisites** - Installs Ansible and bootstrap dependencies for your OS -2. **Bootstrap** - Clones or updates this repo at `~/.dotfiles` -3. **Configure** - Uses `~/.dotfiles/group_vars/all.yml` for your local role and secret references -4. **Apply** - Runs `ansible-playbook` with your selected roles +```mermaid +flowchart TB + subgraph prep["bin/dotfiles"] + direction LR + git["install git, mise"] --> repo["clone or pull ~/.dotfiles"] --> link["link conf.d to modules/, record profile + purpose"] + end + subgraph boot["mise bootstrap"] + direction LR + pkgs["packages"] --> sys["/etc files, services"] --> dots["links, merges, blocks"] --> shell["login shell"] --> tools["tools"] --> tasks["module tasks"] + end + prep --> boot +``` -**Next steps:** -- Copy `~/.dotfiles/group_vars/all.yml.example` to `~/.dotfiles/group_vars/all.yml` if a local config does not exist yet -- Set up [1Password CLI integration](#1password-integration) if you use secret-backed roles or values -- Customize your setup by editing `~/.dotfiles/group_vars/all.yml` -- Run `dotfiles` anytime to pull repo updates and apply your environment +Nothing in the way of a link gets overwritten. Before replacing `~/.config/mise/conf.d` that isn't already a link to `~/.dotfiles/modules`, or a real file/directory that blocks a module's link (on a fresh Omarchy: its own ghostty, kitty and nvim configs, and omp's), the wrapper preserves it in a unique `.pre-dotfiles../original` directory. Unexpected preview errors stop the run before conflict moves. These backups cover only conflicting link targets and `conf.d`, not a complete rollback: copies overwrite matching files without a backup, merges replace managed keys, and `absent` entries remove their targets. Review and preserve local differences before applying; save live edits to copied files first with `mise dot add`. ---- +In a terminal, `dotfiles` runs those steps one stage at a time with a live summary, applies the dotfiles a second time when the first pass created files that other entries need, and keeps mise's full output in `~/.local/state/dotfiles/last-run.log`. -## 🎯 Goals +> [!TIP] +> Piped, or with `--plain`, `dotfiles` prints mise's own output and skips that second pass: on a fresh machine, run it twice. -Provide fully automated multiple-OS development environment that is easy to set up and maintain. +## Daily use -### Why Ansible? +| Command | Does | +| --- | --- | +| `dotfiles` | Pull the repo, then converge this machine | +| `dotfiles --dry-run` | Preview local modules without setup, pulling, or conflict moves; requires compatible mise already installed | +| `dotfiles --plain` | mise's own output instead of the summary (also when piped) | +| `dotfiles --purpose` | Print the effective work/personal purpose without setup | +| `dotfiles --set-purpose work` | Record work purpose and update mise discovery settings only; no provisioning | +| `dotfiles --set-purpose personal` | Switch back to personal without provisioning | +| `mise dot status` | Show the state of every managed file | +| `MISE_TASK_RUN_AUTO_INSTALL=false mise run check` | Dry run plus module tests; use the full validation command below from `~` | +| `update` | Profile-specific system upgrade: native `omarchy-update`, CachyOS package tasks, or Homebrew | -Ansible replicates what we would do to set up a development environment pretty well. There are many automation solutions out there - I happen to enjoy using Ansible. +
+Context, dry-run, and maintenance notes -## ⚙️ Requirements +The wrapper evaluates mise configuration and tasks from **HOME by default**, even when invoked inside another project. `DOTFILES_DIR` selects the source checkout, not the task context. An explicit `-C` / `--cd` deliberately selects another context for preview, bootstrap, and status, preserves paths relative to the caller's original directory, and selects plain mode. That project's tools, dotfiles, and tasks can then participate; use this override only intentionally. -### Operating System +Dry-run retains the configured global context when this checkout, profile, and purpose are already configured. First-run previews use scratch configuration; retained global configuration that cannot be represented faithfully causes an explicit refusal. If an older generated mise selection lacks purpose, run the config-only `dotfiles --set-purpose work` (or `personal`) first, then retry the preview. Preview never updates the live purpose or discovery settings. Custom `MISE_GLOBAL_CONFIG_FILE`/`MISE_CONFIG_FILE` selections are unsupported by the wrapper. Mise can still evaluate trusted templates and update metadata/state/caches: use isolated HOME/XDG/mise directories and filesystem confinement for experiments. -This Ansible playbook only supports multiple OS's on a per-role basis. This gives a high level of flexibility to each role. +Taskfile maintenance follows the OS profile: Omarchy uses its native updater. CachyOS retains its package/AUR maintenance, cache cleanup, and Plasma/Steam desktop-health checks; those CachyOS-only consumers are not installed or run on Omarchy. -This means that you can run a role, and it will only run if your current OS is configured for that role. +
-This is accomplished with this `template` `main.yml` task in each role: -```yaml ---- -- name: "{{ role_name }} | Checking for Distribution Config: {{ ansible_facts['distribution'] }}" - ansible.builtin.stat: - path: "{{ role_path }}/tasks/{{ ansible_facts['distribution'] }}.yml" - register: distribution_config +### Machine purpose -- name: "{{ role_name }} | Run Tasks: {{ ansible_facts['distribution'] }}" - ansible.builtin.include_tasks: "{{ ansible_facts['distribution'] }}.yml" - when: distribution_config.stat.exists -``` -The first task checks for the existence of a `roles//tasks/.yml` file. If that file exists (example `current_distro:MacOSX` and a `MacOSX.yml` file exists) it will be run automatically. This keeps roles from breaking if you run a role that isn't yet supported or configured for the system you are running `dotfiles` on. +
+Purpose marker, generated mise settings, and package restrictions -Currently configured 'bootstrap-able' OS's: -- Ubuntu -- Fedora -- Archlinux (btw) -- MacOSX (darwin) +Purpose is independent of the OS profile: any supported profile can be `work` or `personal`. An unmarked machine defaults to `personal`, preserving the usual setup on other machines. The local, untracked marker is `${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/purpose`; invalid or empty stored values stop the wrapper before provisioning rather than silently selecting personal. -`bootstrap-able` means the pre-dotfiles setup is configured and performed automatically by this project. For example, before we can run this ansible project, we must first install ansible on each OS type. +`dotfiles --set-purpose work` and `dotfiles --set-purpose personal` only save that marker and regenerate mise discovery settings. They do not install prerequisites, sync the repo, replace `conf.d`, move conflicts, run tasks, or apply packages/dotfiles. Run the setter on its own, not with bootstrap flags such as `--dry-run`. `dotfiles --purpose` is a read-only query of the effective purpose. -To see details, see the `__task "Loading Setup for detected OS: $ID"` section of the `bin/dotfiles` script to see how each OS type is being handled. +The wrapper owns generated `${XDG_CONFIG_HOME:-$HOME/.config}/mise/miserc.local.toml`; do not hand-edit purpose there. The setter and normal setup keep `auto_env = true` and persist `env = ["work"]` (or `["personal"]`) on macOS, and `env = ["omarchy", "work"]` / `["cachyos", "work"]` on Linux (substitute `personal` when selected). Subsequent raw mise commands use this persisted selection; a marker alone does not configure raw mise before the setter or normal setup has generated it. -### System Upgrade +Package restrictions use mise's native [`[bootstrap.packages]` `env` option](https://mise.jdx.dev/bootstrap/packages/#by-config-environment): `env = "work"` is work-only, `env = "personal"` is personal-only, and omitting `env` is shared, still subject to the existing platform/profile declarations. Discord is personal-only on macOS and Linux; all other app declarations and shared shortcuts are unchanged. Selecting work does **not uninstall** an existing Discord installation or disable its shortcuts. -Verify your `supported OS` installation has all latest packages installed before running the playbook. +This is an installation preference, not an allowlist or organizational/security enforcement. It does not restrict unrelated apps installed by bootstrap tasks or external installers; any future restriction on those apps must also cover their installer/configuration owner. Explicit native `MISE_ENV` or `mise -E` selections can intentionally override generated environments; preserve the distro environment when doing so on Linux. -``` -# Ubuntu -sudo apt-get update && sudo apt-get upgrade -y -# Fedora -sudo dnf update && sudo dnf upgrade -y -# Arch -sudo pacman -Syu -# MacOSX (brew) -brew update && brew upgrade +Read-only inspection from HOME, with compatible mise and existing package-manager prerequisites: + +```bash +cd ~ +dotfiles --purpose +mise bootstrap packages status --json +# macOS: compare requested packages without changing the saved purpose. +MISE_AUTO_ENV=true mise -E work bootstrap packages status --json +MISE_AUTO_ENV=true mise -E personal bootstrap packages status --json +# Linux: retain the actual distro environment (use cachyos instead if applicable). +MISE_AUTO_ENV=true mise -E omarchy,work bootstrap packages status --json +MISE_AUTO_ENV=true mise -E omarchy,personal bootstrap packages status --json ``` -> [!NOTE] -> This may take some time... +[`status`](https://mise.jdx.dev/cli/bootstrap/packages/status.html) does not install packages or use sudo. Discord should be absent from the work request and present in the personal control; shared package requests should remain unchanged. A work status does not prove Discord is absent from the machine, and cross-platform selection on macOS is not native Linux runtime verification. -## 🔧 Setup +
-### Local configuration +## Keybindings -Your machine-specific configuration lives in `~/.dotfiles/group_vars/all.yml`. -Start from the checked-in example if you do not already have a local config: +### Mac bindings -```bash -cp ~/.dotfiles/group_vars/all.yml.example ~/.dotfiles/group_vars/all.yml -nvim ~/.dotfiles/group_vars/all.yml -``` +Karabiner maps CapsLock to the Hammerspoon app leader: **tap CapsLock, then the app key** within one second. Shifted letters are distinct bindings. Escape or Control+C cancels the leader. -The example file is the source of truth for role selection and common variables: +| App key | Application | +| --- | --- | +| `a` | Granola (Agenda) | +| `b` | Brave | +| `c` | Cursor | +| `Shift+C` | Signal | +| `d` | Discord | +| `e` | Outlook | +| `f` | Finder | +| `Shift+G` | GrokBot (launch only) | +| `h` | Screen Sharing | +| `s` | Spotify | +| `n` | Obsidian | +| `o` | 1Password | +| `m` | Teams (launch only) | +| `t` | Tern | +| **`Shift+T`** | **Ghostty** | +| **`w`** | **AWS WorkSpaces** (`com.amazon.workspaces`) | -- `default_roles`: roles that run when you execute `dotfiles` -- `git_user_name`: name used by git and other developer tooling -- `keyboard`: Linux/X11 keyboard model, layout, variant, and options -- role-specific variables such as `k8s.repo.version`, `helm.repos`, and `go.packages` +`Hyper` means Command+Option+Control+Shift; `lilHyper` omits Shift. -For the compact reference, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md). -For larger examples, see [docs/EXAMPLES.md](docs/EXAMPLES.md). -For tool-specific behavior, prefer the README and defaults inside each role directory. +| Shortcut | Window action | +| --- | --- | +| `Hyper+A` | Focus the frontmost application | +| `Hyper+P` | Pick a layout | +| `Hyper+;` / `Hyper+'` | Next layout variant / reset layout | +| `Command+U` | Bind the focused window to a cell | +| `Command+O` or `lilHyper+O` | Move the window to the next screen | +| `Command+Shift+O` or `Hyper+O` | Move the window to the previous screen | -### 1Password Integration +Tap CapsLock twice, or F16 once, for the macro leader: `s` takes a screenshot to the clipboard, `e` opens the emoji picker, and `a` selects the next window of the current app. `b`, `t`, and `g` send the configured Raycast bookmark, tab, and GIF-search chords; those integrations require matching Raycast actions. -1Password is recommended for secret-backed configuration, but the whole playbook no longer hard-fails just because 1Password is missing or locked. The bootstrap detects whether `op` is installed and authenticated; roles that need secrets should skip or warn when secrets are unavailable. +### Omarchy screens -The default 1Password account is `op_account` (`my.1password.com` when unset). Roles that need secrets skip or warn when the CLI is missing or locked. +Super stands in for Command. As on the Mac, each screen has a layout of cells, and each app has a cell in every layout. -#### Git identity and SSH signing +| Shortcut | Window action | +| --- | --- | +| `Super+O` or `Super+Control+Alt+O` | Move the window to the next screen, into its cell there | +| `Super+Shift+O` or `Super+Control+Alt+Shift+O` | Move the window to the previous screen | +| `Super+U` | Pick the app's cell on this screen; each screen remembers its own choice. The last cell of every cell layout is full screen | +| `Super+Control+Alt+Shift+P` / `;` / `'` | Pick, cycle, or reset this screen's layout | +| `Super+L` | Toggle the workspace between cells and full screen | +| `Alt+Tab` / `Alt+Shift+Tab` | OmaSwitch window switcher (most recent windows first, type to search); without the plugin, cycle windows on the workspace | -The git role can read your commit email and allowed signers file from 1Password: +Floating windows that cells does not place, such as dialogs, keep their position and size relative to the screen. Moving a window you tiled with `Super+T` puts it back in its cell. Fullscreen windows stay fullscreen, also across config reloads: Hyprland 0.56 forgets their fullscreen state there without telling the app, and Brave then hides its tabs again on every focus change. When they leave it, a window cells places goes back to its cell; one you tiled or popped out yourself stays where Hyprland restores it. `Super+O` and `Super+Shift+O` replace Omarchy's pop-out and Obsidian bindings. Use CapsLock → `n` to summon Obsidian. -```yaml -op: - git: - user: - email: "op://Personal/GitHub/email" - allowed_signers: "op://Personal/GitHub SSH/allowed_signers" -``` +The summon leader is CapsLock or F13, as on the Mac (Karabiner turned CapsLock into F13) and on CachyOS (keyd accepted both). A keyboard that sends F13 from its Caps Lock key needs no remap. -`op.git.allowed_signers` should point to a field whose value is one or more lines in git's SSH allowed signers format: +Every window is fully opaque, focused or not; Omarchy's default translucency is turned off so video never shows the wallpaper through. Apps that should be see-through set it themselves, such as Ghostty's `background-opacity`. -```text - namespaces="git" -``` +`modules/omarchy/hypr/monitors.lua` sets up the desk with the Acer VG281K 4K and the HP P224 1080p. The HP is on the left, the 4K is main, and the cursor starts there at login. Workspaces 1–3 (terminal, browser, Obsidian) live on the 4K, and workspaces 4–5 (chat, music) on the HP. The HP defaults to the Fullscreen layout. These rules match the panels by description, and the workspace bindings apply only while both panels are connected, so other machines are unaffected. -Example: +## Apps -```text -you@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... -``` +### Tern -#### SSH keys +Native mise merges and the bootstrap reset both use the standard Tern roots: `~/Library/Application Support/Tern` on macOS and `~/.config/tern` on Linux. `TERN_CONFIG_DIR` and a custom `XDG_CONFIG_HOME` are not supported by this module; the old role's alternate-root behavior is not carried forward. -The ssh role deploys every key listed under `op.ssh.github` groups: +Tern shares dark Catppuccin styling, Float layout, Glass material, vertical tabs, BerkeleyMono Nerd Font, copy-on-select, and the Console chat style (`surface_chat`). Bootstrap removes the legacy explicit opacity override so Tern's default applies. Use **Reload settings (`Control+Shift+F5`)** after settings cleanup; the macOS held-key-repeat preference in [macOS migration](#macos-migration) instead requires a full quit and reopen. -```yaml -op: - ssh: - github: - personal: - - name: id_ed25519 - vault_path: "op://Personal/GitHub SSH" - work: - - name: work_key - vault_path: "op://Work/GitHub SSH" -``` +macOS and Omarchy now share **CapsLock → t** for Tern and **CapsLock → Shift+T** for Ghostty. Linux-only pane keys are `Control+]` / `Control+[` for next/previous pane and `Control+Alt+]` / `Control+Alt+[` for next/previous tab across sessions. -Each vault item must expose `private_key` and `public_key` fields. +### Omarchy shell plugins -## 📖 Usage +`mise.omarchy.toml` clones three shell plugins into `~/.config/omarchy/plugins/`: [OMP Usage](https://github.com/iamanro/omarchy-omp-usage) (quota, resets and cost for every provider omp is logged in to), [GitHub](https://github.com/robzolkos/omarchy-github) (notifications, review requests, your PRs, Actions) and [OmaSwitch](https://github.com/piyush97/omaswitch) (Alt+Tab switcher with live previews). `bootstrap:omarchy-plugins` enables them, putting the two widgets in the bar, and takes the stock Agents widget out, through the running Omarchy shell, once per plugin list: later changes stay until a list changes. Outside a session it waits for the next run. -### Install +Alt+Tab opens OmaSwitch only while it is installed and enabled; on each press `cells.lua` checks the plugin folder and `shell.json`, so `omarchy plugin disable piyush.omaswitch` or `remove` hands Alt+Tab back to plain window cycling without a keybinding change or reload. To drop it for good, delete its `[bootstrap.repos]` entry and its id from `OMARCHY_PLUGINS`, then run `omarchy plugin remove piyush.omaswitch --yes`: mise never deletes a clone. -This playbook includes a custom shell script located at `bin/dotfiles`. This script is added to your $PATH after installation and can be run multiple times while making sure any Ansible dependencies are installed and updated. +The GitHub widget reads through `gh`: run `gh auth login`, then `gh auth refresh -h github.com -s notifications -s repo`. `omarchy update` does not update third-party plugins by itself, so a linked post-update hook runs `omarchy plugin update --yes`: every update brings all git-managed plugins to their latest commit, validated and rolled back on failure. `dotfiles --update` also fast-forwards them; a plain `dotfiles` run never fetches them. -This shell script is also used to initialize your environment after bootstrapping your `supported-OS` and performing a full system upgrade as mentioned above. +## Gallery -> [!NOTE] -> You must follow required steps before running this command or things may become unusable until fixed. + + + + + + + + + +
Ghostty window running dotfiles --dry-run, with every stage previewedGhostty window showing a failed stage and the last lines of its log
dotfiles --dry-run previews local modules without setup, pulling, or conflict moves.A failed stage stops the run and shows the last lines of its log.
+ +## Validation + +Run the gate from HOME so an unrelated project's config or `check` task cannot shadow the dotfiles: ```bash -bash -c "$(curl -fsSL https://raw.githubusercontent.com/TechDufus/dotfiles/main/bin/dotfiles)" +cd ~ && + MISE_TASK_RUN_AUTO_INSTALL=false MISE_AUTO_ENV=true \ + DOTFILES_TEST_REAL_MISE="$(command -v mise)" mise run check ``` -If you want to run only specific roles, pass Ansible tags through the launcher: +Set `MISE_TASK_RUN_AUTO_INSTALL=false` **before** the outer `mise run` starts; setting it inside a task is too late to prevent tool installation. This invocation does not disable normal auto-install globally. `DOTFILES_TEST_REAL_MISE` must name a trusted, compatible installed mise executable; it enables the native source-relative preview fixture without installing or applying dotfiles. + +Install the gate's prerequisites separately before running it: Git, Bash, Zsh, Lua, Bun, jq, Python 3, and the platform's standard utilities. macOS CI runs every loaded `test:*` module suite from HOME with the same native fixture enabled, not just the Hammerspoon suite. + +A dry run or a macOS-hosted Linux profile-selection check is not native Linux runtime verification. Check the relevant Omarchy or CachyOS services, desktop/session behavior, and optional task outcomes on that OS; do not treat macOS CI as proof that all Linux paths ran. + +
+Linux sudoers regression gate + +The separate Linux sudoers regression gate is not part of the wildcard module suites: + ```bash -dotfiles -t comma,separated,tags +cd ~ && + MISE_TASK_RUN_AUTO_INSTALL=false MISE_AUTO_ENV=true mise run check:system-sudoers ``` -Common examples: +It requires a non-root Linux account, installed `visudo`, bubblewrap, permitted unprivileged user/PID namespaces, and an existing `/etc/sudoers.d`. It maps a disposable policy directory and does not invoke real sudo or write a live grant. Run it on a suitable Linux executor; namespace restrictions are a reported failure, not proof that the policy passed. + +The workflow defines a dedicated `system-sudoers` Ubuntu job that installs packaged sudo/bubblewrap and runs the same test script directly as the non-root runner account, outside the wildcard suites. It does not weaken host namespace restrictions. This route covers isolated sudoers safety, not full Omarchy/CachyOS provisioning or desktop runtime; consult the workflow results for execution evidence. + +
+ +## Migration + +### macOS migration + +
+macOS migration notes + +- Existing stable Raycast is preserved, including hand-installed copies; the stable Homebrew cask is installed only when the app is absent. The migration neither installs Beta nor deletes stable. +- Existing sudo policy is left untouched. Privileged changes can still require authentication. +- The 1Password SSH agent selects only `Personal` on macOS; other profiles retain their shared selections. +- Claude uses a Mac-specific, symlinked settings source containing only theme and TUI. It does not enable the shared permission/plugin/hook profile. The Herdr-generated session hook is neither carried forward nor registered. +- Hammerspoon uses lowercase `t` for Tern and uppercase `T` for Ghostty. Spoon updates validate both releases before replacement, restore prior installations on failure/interruption, and leave unchanged installs untouched. If restoration fails, the reported transaction directory retains the originals. +- Tern's macOS profile disables the press-and-hold accent menu for `so.stencil.tern` so held letters and numbers can repeat. Global repeat timing and other apps are unchanged. Fully quit and reopen Tern after applying this preference; reloading its settings alone is insufficient. +- On macOS, mise shims take precedence over local CLI installations, including app-owned launchers such as Tern's `omp`. Login Zsh restores this order after Apple's `path_helper`. Linux keeps its intentional local-wrapper-first order; optional CLI paths remain available on both platforms. pnpm uses native upstream releases rather than npm lifecycle scripts. +- Vimscript language-server installation is no longer managed. An existing Bun-global `vim-language-server` must be removed during an approved cutover if OMP's built-in discovery should stop finding it; Vim syntax parsers remain available. +- Yazi's Mac profile owns its config and theme; its current built-in keymap replaces the empty legacy keymap. Obsolete Plasma, rofimoji, and OpenCode companion links are retired only on macOS. +- Neovim generates a new ignored `lazy-lock.json` from its configured plugins instead of carrying forward the old role's lock. macOS declares Ruby for Mason's Solargraph and selects it inside Neovim without changing the shell's Ruby. Go-tool builds report installation failures rather than completing before their installers. +- Neovim's shared theme explicitly selects Catppuccin Mocha, with transparent editing areas and ordinary floating windows rather than near-black overrides. It inherits the terminal background; window opacity remains a terminal setting, and selections/statuslines retain their own contrast. +- The global TypeScript compiler remains on `latest`. TypeScript language-server 6 carries a private compatible SDK 6 through its mise postinstall hook, without adding competing compiler executables. + +
+ +### Migration ownership and rollback + +
+Ownership changes, sudoers validation, and Ansible cutover + +- Ansible is retained as a development-tool dependency for the Ansible language server, not as the provisioning engine. macOS owns standalone Ansible through Homebrew; Linux owns `ansible-core` and `ansible-lint` through pacman. The existing macOS Neovim profile also owns `ansible-lint`. +- `bootstrap:git` places one owned include block at the end of `~/.gitconfig`, after historical globals. After successful migration, defaults and the generated `~/.config/git/identity.gitconfig` are loaded once; macOS also includes its platform defaults. Explicit repository-local settings still win. Cleanup removes only exact obsolete `dotfiles` / `dotfiles-macos` blocks from `~/.config/git/config` (or an active XDG config root within HOME), preserving unrelated contents and both global files. This transfers ownership without requiring a change to an already-correct identity. +- Helm bootstrap requires the canonical `traefik` name to point at its required repository URL. It repairs a missing or incorrect alias without a broad repository update; optional Helm failures still warn and skip. +- Retired Ollama helpers are not deleted automatically. If `~/.config/ollama/Modelfile.qwen-omp` or `~/.local/bin/ollama-qwen`, `ollama-qwen-start`, or `ollama-qwen-stop` remain, review their contents and consumers before manually removing obsolete copies. Customized files or replacement symlinks must not be treated as known historical files. +- The retired `sesh` popup (`prefix+o`) is removed from the managed tmux config instead of reinstalling its provider. Omarchy's native tmux configuration remains outside this module's ownership. +- Linux passwordless sudo policy is derived from the actual invoking non-root UID, not `$USER` or a root fallback. `bootstrap:system` protects and validates a root-owned `0440` candidate with real `visudo` before atomic installation, rejects symlink/non-regular destinations, and fails before later udev/keyboard steps if validation or installation fails. Other policy files and macOS sudo policy are left alone. + +Git include placement is task-owned: `mise dot apply` / `status` cover the linked source files but do not perform or verify the include migration. After applying those sources, use full bootstrap or the targeted task: ```bash -dotfiles # Pull latest repo changes and run default_roles -dotfiles -t tmux -vvv # Run one role with Ansible verbosity -dotfiles --check # Dry run -dotfiles --list-tags # List available role tags -dotfiles --uninstall neovim # Run a role uninstall script, if present -dotfiles --delete old_role # Uninstall, remove from all.yml, and delete the role directory +cd ~ && MISE_TASK_RUN_AUTO_INSTALL=false mise run bootstrap:git ``` -`--uninstall` and `--delete` prompt before making destructive changes. +Python 3 is required. The task warns and skips changed/damaged owned blocks, unsafe/shared files or parents, lock files, or an explicit `GIT_CONFIG_GLOBAL` redirect; inspect its outcome rather than assuming a green dotfile status proves Git ownership. -## 📚 Documentation +The new validated include owner is published **before** obsolete includes are removed, so interruption does not remove the signing policy. If cleanup fails after publication, temporary duplicate includes can remain. Review the warning and rerun `bootstrap:git` to complete cleanup and restore single loading; do not delete a global config file to recover. -- [📖 Complete Beginner Guide](docs/QUICKSTART.md) - Step-by-step setup for new users -- [🔧 Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Common issues and solutions -- [📋 Configuration Examples](docs/EXAMPLES.md) - Sample setups for different use cases +After an authorized apply on **native Linux**, validate the installed sudoers include graph and file metadata before considering the system step complete: -## 🌟 Star History +```bash +sudo visudo -cf /etc/sudoers +sudo stat -c '%U:%G:%a' /etc/sudoers.d/techdufus +sudo -l +``` + +Expect `root:root:440` for the managed file and review the invoking account's effective policy. Run bootstrap as the normal user, not root. These post-apply checks complement, rather than replace, the automatic pre-install candidate validation. + +For an Ansible cutover, use a plain terminal and a fixed reviewed revision. Preserve unique local commits, copied/merged configurations, and any retired-role configuration you intend to keep before switching branches. Retiring the old `roles/` source tree is not an instruction to delete its former live configuration wholesale: review each local file and its current owner. Remove redundant public CLI installations only after checking consumers; app-owned runtimes such as Tern's OMP and a Bun binary used by a LaunchAgent may still be required. + +Relink configurations before broader provisioning, run again to settle permissions on merge-created files, then require clean `mise dot status --missing` and a no-change repeat. Verify signing, service health, actual application behavior, and any fail-soft task outcomes separately. A Git checkout reversal does not undo installed tools, services, shell registration, copies, merges, or removals; preserve a verified private rollback snapshot of the affected live configuration and ownership rather than treating a branch switch as a transaction. - +
+ +## Layout + +```text +bin/dotfiles bootstrap + wrapper (curl entrypoint) +modules// one mise conf.d folder per module + mise.toml every supported profile + mise.linux.toml Omarchy + CachyOS + mise.macos.toml macOS + mise.omarchy.toml Omarchy / Hyprland + mise.cachyos.toml CachyOS / KDE Plasma + , bootstrap.sh, tests/ +.github/assets/ README images; render/render.py regenerates them +``` + +[AGENTS.md](AGENTS.md) has the rules for changing modules. + +## Star history + + - - - Star History Chart + + Star history + +## License + +Apache-2.0, see [LICENSE](LICENSE). diff --git a/Taskfile.yml b/Taskfile.yml deleted file mode 100644 index 806972236..000000000 --- a/Taskfile.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: 3 - -vars: - vault_secret: - sh: cat $HOME/.ansible-vault/vault.secret - -tasks: - docker-build: - desc: Build dotfiles Docker image - env: - vault_secret: "{{.vault_secret}}" - cmds: - - docker build -t devbox:latest . diff --git a/ansible.cfg b/ansible.cfg deleted file mode 100644 index 7a26798e4..000000000 --- a/ansible.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[defaults] -# Suppress warning about empty inventory -localhost_warning = False -inventory = /dev/null - -# Use the Beautiful Output callback plugin. -# stdout_callback = beautiful_output -# Use the stdout_callback when running ad-hoc commands. -# bin_ansible_callbacks = True -# display_skipped_hosts = False diff --git a/bin/dotfiles b/bin/dotfiles index 54aaecdd0..4389b4f4e 100755 --- a/bin/dotfiles +++ b/bin/dotfiles @@ -1,698 +1,1256 @@ -#!/bin/bash -# shellcheck shell=bash -# shellcheck disable=SC2034 # Unused variables are exported for child scripts - -# Catppuccin Mocha color codes -NC='\033[0m' -BOLD='\033[1m' - -# Catppuccin Mocha colors -CAT_ROSEWATER='\033[38;2;245;224;220m' -CAT_FLAMINGO='\033[38;2;242;205;205m' -CAT_PINK='\033[38;2;245;194;231m' -CAT_MAUVE='\033[38;2;203;166;247m' -CAT_RED='\033[38;2;243;139;168m' -CAT_MAROON='\033[38;2;235;160;172m' -CAT_PEACH='\033[38;2;250;179;135m' -CAT_YELLOW='\033[38;2;249;226;175m' -CAT_GREEN='\033[38;2;166;227;161m' -CAT_TEAL='\033[38;2;148;226;213m' -CAT_SKY='\033[38;2;137;220;235m' -CAT_SAPPHIRE='\033[38;2;116;199;236m' -CAT_BLUE='\033[38;2;137;180;250m' -CAT_LAVENDER='\033[38;2;180;190;254m' -CAT_TEXT='\033[38;2;205;214;244m' -CAT_SUBTEXT1='\033[38;2;186;194;222m' -CAT_SUBTEXT0='\033[38;2;166;173;200m' -CAT_OVERLAY2='\033[38;2;147;153;178m' -CAT_OVERLAY1='\033[38;2;127;132;156m' -CAT_OVERLAY0='\033[38;2;108;112;134m' -CAT_SURFACE2='\033[38;2;88;91;112m' -CAT_SURFACE1='\033[38;2;69;71;90m' -CAT_SURFACE0='\033[38;2;49;50;68m' -CAT_BASE='\033[38;2;30;30;46m' -CAT_MANTLE='\033[38;2;24;24;37m' -CAT_CRUST='\033[38;2;17;17;27m' - -# Legacy mappings for easier use -RED="$CAT_RED" -GREEN="$CAT_GREEN" -YELLOW="$CAT_YELLOW" -BLUE="$CAT_BLUE" -PURPLE="$CAT_MAUVE" -CYAN="$CAT_TEAL" -WHITE="$CAT_TEXT" -ORANGE="$CAT_PEACH" -LBLACK="$CAT_SURFACE0" -LRED="$CAT_RED" -LGREEN="$CAT_GREEN" -LYELLOW="$CAT_YELLOW" -LBLUE="$CAT_BLUE" -LPURPLE="$CAT_MAUVE" -LCYAN="$CAT_TEAL" - -# Special -SEA="$CAT_SAPPHIRE" -OVERWRITE='\e[1A\e[K' - -#emoji codes -CHECK_MARK="${GREEN}\xE2\x9C\x94${NC}" -X_MARK="${RED}\xE2\x9C\x96${NC}" -PIN="${RED}\xF0\x9F\x93\x8C${NC}" -CLOCK="${GREEN}\xE2\x8C\x9B${NC}" -ARROW="${SEA}\xE2\x96\xB6${NC}" -BOOK="${RED}\xF0\x9F\x93\x8B${NC}" -HOT="${ORANGE}\xF0\x9F\x94\xA5${NC}" -WARNING="${RED}\xF0\x9F\x9A\xA8${NC}" -RIGHT_ANGLE="${GREEN}\xE2\x88\x9F${NC}" - - - -set -e - -# Check for tput (required for cursor control) -if ! command -v tput &> /dev/null; then - echo -e "${CAT_RED}Error: 'tput' is required but not found.${NC}" - echo -e "${CAT_SUBTEXT1}Please install ncurses (Ubuntu: apt install ncurses-bin, Arch: pacman -S ncurses)${NC}" +#!/usr/bin/env bash +# Converge this machine with mise bootstrap. +# Fresh machine: bash -c "$(curl -fsSL https://raw.githubusercontent.com/TechDufus/dotfiles/main/bin/dotfiles)" +set -euo pipefail + +DOTFILES_DIR=${DOTFILES_DIR:-$HOME/.dotfiles} +DOTFILES_SYSROOT=${DOTFILES_SYSROOT:-} # prefix for /etc and /usr lookups; exists for tests +REPO_URL=https://github.com/TechDufus/dotfiles.git +MISE_DIR=${MISE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/mise} +PURPOSE_DIR=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles +PURPOSE_FILE=$PURPOSE_DIR/purpose +PURPOSE=personal +STATE_DIR=${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles +LOG=$STATE_DIR/last-run.log + +# Machine configuration is home-scoped, not the invoking project's mise config. +# Explicit -C/--cd keeps mise's own caller-relative directory selection semantics. +MISE_CWD=$HOME +mise_in_context() ( + cd "$MISE_CWD" || exit + exec mise "$@" +) + +# Bootstrap stages, in mise's own order (mise bootstrap --help). 'files' with +# phase = "pre-packages" would have to run before packages; no module uses it. +STAGES="packages system dotfiles shell tools modules" +KNOWN_PARTS=" accounts plugins packages files services firewall compose repos dotfiles mise-shell-activate macos-defaults macos-launchd-agents linux-systemd-units user tools task final-hook " +SUDO_PARTS=" accounts plugins packages files services firewall linux-systemd-units user " + +UI=0 PLAIN=0 ANIM=0 COLOR=0 DRY=0 ONLY="" SKIP="" PASS=() CONFIG_ARGS=() FORCE_PREVIEW=0 +CHILD="" SUDO_PID="" INFO="" PREVIEW_DIR="" LOG_OFF=0 RIGHT=78 NOW=0 T0=0 RUN_T0=0 HAVE_FRAC=0 +FIT="" ROW="" FMT="" DETAIL="" RESULT="" EXTRA=() WARNS=() TASK_TOTAL=0 COLS=80 HOST="" SELECTED="" +RST="" BOLD="" C_TEXT="" C_SUB="" C_OVR="" C_SURF="" C_MAUVE="" C_BLUE="" C_GREEN="" C_YELLOW="" C_RED="" C_PEACH="" +FRAMES=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) + +usage() { + cat < $DOTFILES_DIR/modules, then runs 'mise bootstrap --yes'. +A real file or folder where a module wants a link (a fresh Omarchy's own +configs) is kept in a unique .pre-dotfiles../original backup. + +Flags passed through to mise bootstrap: + --dry-run preview this checkout without installing or changing setup + --only run only these parts, e.g. --only dotfiles,tools + --skip run everything except these parts + --force-dotfiles overwrite conflicting files instead of moving them aside + -C, --cd explicitly select a context (relative to the caller's cwd) + +Other: + --plain print mise's own log instead of the progress screen + --profile print the detected machine profile and exit + --purpose print the saved machine purpose (default: personal) and exit + --set-purpose

save work or personal and update mise selection only; standalone + -h, --help show this help + +On a terminal, dotfiles runs bootstrap in six stages (packages, system, dotfiles, +shell, tools, modules) and draws a progress screen. mise's output goes to +$LOG +The screen is replaced by mise's own log with --plain, DOTFILES_UI=0, NO_COLOR, +a non-terminal stdout/stderr, or any other flag (-v, --raw, -q, --from, ...). +Configuration discovery, conflict checks and bootstrap default to HOME, even when +called inside another project. Explicit -C/--cd selects that project's context +for both the safety probe and bootstrap; its tools, links and tasks may run. + +Dry runs require an existing compatible mise and a local modules checkout. They +do not pull, install prerequisites, replace live mise config, or move conflicts. +They still evaluate trusted mise config templates and may update mise metadata, +state and caches; deployment, hook and bootstrap-task commands are only previewed. +Configured candidates preview in their original config context. First-run scratch +preview replaces conf.d as apply would; retained global files/tasks require an +isolated rehearsal or already-configured candidate. Custom MISE_GLOBAL_CONFIG_FILE +and MISE_CONFIG_FILE selections are unsupported for both setup and preflight. +Profiles: omarchy, cachyos, macos (Apple Silicon only); override with DOTFILES_PROFILE=. +Purpose is independent of the OS profile and stored at $PURPOSE_FILE. +Purpose queries/setters do not install, sync, or provision. Setters reject all other +flags, including --dry-run. Explicit mise -E/MISE_ENV remains an intentional override. +State: mise dot status, mise bootstrap status. Checks: cd ~ && mise run check +EOF +} + +die() { + echo "dotfiles: $*" >&2 exit 1 -fi +} -# Early check for help/version/debug flags (before setup) -for arg in "$@"; do - case $arg in - -h|--help) - SHOW_HELP=true - ;; - --version) - SHOW_VERSION=true - ;; - --detect-os) - SHOW_DETECTED_OS=true - ;; - esac -done +# ---- detection, repo and mise setup (shared by both modes) ---- -# Cleanup function for exit -cleanup() { - # Kill spinner if running - if [[ $SPINNER_PID != "" ]]; then - kill $SPINNER_PID 2>/dev/null || true - wait $SPINNER_PID 2>/dev/null || true - fi - # Show cursor - tput cnorm 2>/dev/null || true -} - -# Set trap to cleanup on exit -trap cleanup EXIT INT TERM - -# Paths -VAULT_SECRET_FILE="$HOME/.ansible-vault/vault.secret" -OP_INSTALLED=false -OP_AUTHENTICATED=false -DOTFILES_LOG="$HOME/.dotfiles.log" -DOTFILES_DIR="$HOME/.dotfiles" -IS_FIRST_RUN="$HOME/.dotfiles_run" -DOTFILES_VERSION="1.0.0" -# Spinner PID tracking -SPINNER_PID="" - -# Show usage/help information -show_help() { - echo -e "${CAT_SAPPHIRE}TechDufus Dotfiles${NC} - Ansible-based dotfiles management" - echo "" - echo -e "${CAT_BLUE}USAGE:${NC}" - echo " dotfiles [OPTIONS] [-- ANSIBLE_ARGS]" - echo "" - echo -e "${CAT_BLUE}OPTIONS:${NC}" - echo -e " ${CAT_GREEN}-h, --help${NC} Show this help message" - echo -e " ${CAT_GREEN}--version${NC} Show version information" - echo -e " ${CAT_GREEN}--detect-os${NC} Print normalized OS identifier and exit" - echo -e " ${CAT_GREEN}--uninstall ${NC} Uninstall a role (cleanup only)" - echo -e " ${CAT_GREEN}--delete ${NC} Uninstall and delete a role from dotfiles" - echo -e " ${CAT_GREEN}-v, -vv, -vvv${NC} Verbose mode (passed to ansible-playbook)" - echo "" - echo -e "${CAT_BLUE}EXAMPLES:${NC}" - echo -e " ${CAT_SUBTEXT1}# Run full dotfiles installation${NC}" - echo " dotfiles" - echo "" - echo -e " ${CAT_SUBTEXT1}# Install specific roles only${NC}" - echo " dotfiles -t neovim,git,tmux" - echo "" - echo -e " ${CAT_SUBTEXT1}# Dry run (test without applying)${NC}" - echo " dotfiles --check" - echo "" - echo -e " ${CAT_SUBTEXT1}# Uninstall a role${NC}" - echo " dotfiles --uninstall neovim" - echo "" - echo -e " ${CAT_SUBTEXT1}# Delete a role completely${NC}" - echo " dotfiles --delete old_role" - echo "" - echo -e "${CAT_BLUE}ANSIBLE ARGUMENTS:${NC}" - echo " All arguments not recognized by dotfiles are passed to ansible-playbook." - echo " Common options: -t , --check, -vvv, --list-tags" - echo "" - echo -e "${CAT_SUBTEXT0}For more information: https://github.com/TechDufus/dotfiles${NC}" -} - -# Show version information -show_version() { - local git_hash="" - if [[ -d "$DOTFILES_DIR/.git" ]]; then - git_hash=$(git -C "$DOTFILES_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") - fi - echo -e "${CAT_SAPPHIRE}TechDufus Dotfiles${NC} version ${CAT_GREEN}${DOTFILES_VERSION}${NC} (${CAT_SUBTEXT1}${git_hash}${NC})" -} - -# Spinner function that runs in background -_spinner() { - local task_text="$1" - local chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') - local delay=0.08 - - # Hide cursor - tput civis 2>/dev/null || true - - # Save cursor position - tput sc 2>/dev/null || true - - while true; do - for char in "${chars[@]}"; do - # Restore cursor position and clear line - tput rc 2>/dev/null || true - tput el 2>/dev/null || true - printf "${CAT_OVERLAY1} [${CAT_SAPPHIRE}${char}${CAT_OVERLAY1}] ${CAT_TEXT}${task_text}" >&2 - sleep $delay - done +detect_profile() { + if [ -n "${DOTFILES_PROFILE:-}" ]; then + case $DOTFILES_PROFILE in + omarchy | cachyos | macos) echo "$DOTFILES_PROFILE" ;; + *) die "unsupported machine ($DOTFILES_PROFILE)" ;; + esac + return + fi + if [ "$(uname -s)" = Darwin ]; then + # mise's brew manager and Homebrew's installer support only Apple Silicon. + [ "$(uname -m)" = arm64 ] || die "unsupported machine (Intel Mac)" + echo macos + return + fi + local id=unknown + if [ -r "$DOTFILES_SYSROOT/etc/os-release" ]; then + # shellcheck source=/dev/null + id=$(. "$DOTFILES_SYSROOT/etc/os-release" && echo "${ID:-unknown}") + fi + if [ "$id" = omarchy ] || [ -d "$DOTFILES_SYSROOT/usr/share/omarchy" ] || [ -d "$HOME/.local/share/omarchy" ]; then + echo omarchy + elif [ "$id" = cachyos ]; then + echo cachyos + else + die "unsupported machine ($id)" + fi +} + +validate_purpose_target() { + [ ! -L "$PURPOSE_DIR" ] && { [ ! -e "$PURPOSE_DIR" ] || [ -d "$PURPOSE_DIR" ]; } || + die "unsafe purpose directory $PURPOSE_DIR; use a real directory" + [ ! -L "$PURPOSE_FILE" ] && { [ ! -e "$PURPOSE_FILE" ] || [ -f "$PURPOSE_FILE" ]; } || + die "unsafe purpose target $PURPOSE_FILE; use a regular, non-symlink file" +} + +read_purpose() { + local value + validate_purpose_target + PURPOSE=personal + [ -e "$PURPOSE_FILE" ] || return 0 + for value in work personal; do + if printf '%s\n' "$value" | cmp -s - "$PURPOSE_FILE" || + printf '%s' "$value" | cmp -s - "$PURPOSE_FILE"; then + PURPOSE=$value + return 0 + fi done + die "invalid purpose at $PURPOSE_FILE; expected exactly work or personal (optionally followed by one newline); correct the file before retrying" } -# Start a new task with spinner -__task() { - # if a task is running, complete it first - if [[ $TASK != "" ]] && [[ $SPINNER_PID != "" ]]; then - _task_done +write_purpose() { + local tmp + validate_purpose_target + if [ -f "$PURPOSE_FILE" ] && + { printf '%s\n' "$PURPOSE" | cmp -s - "$PURPOSE_FILE" || + printf '%s' "$PURPOSE" | cmp -s - "$PURPOSE_FILE"; }; then + return 0 fi + mkdir -p "$PURPOSE_DIR" || die "cannot create purpose directory $PURPOSE_DIR" + tmp=$(mktemp "$PURPOSE_DIR/.purpose.XXXXXX") || die "cannot prepare purpose at $PURPOSE_FILE" + if ! printf '%s\n' "$PURPOSE" >"$tmp" || ! mv -f "$tmp" "$PURPOSE_FILE"; then + rm -f "$tmp" + die "cannot save purpose at $PURPOSE_FILE" + fi +} - # set new task - TASK=$1 - - # Start spinner in background - _spinner "$TASK" & - SPINNER_PID=$! +install_prerequisites() { + local pkg missing="" + if [ "$1" = macos ]; then + if ! command -v brew >/dev/null 2>&1 && [ ! -x /opt/homebrew/bin/brew ]; then + step run prereqs "installing Homebrew" + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + step ok prereqs "Homebrew installed" + fi + if [ -x /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + fi + for pkg in git mise; do + if ! command -v "$pkg" >/dev/null 2>&1; then + step run prereqs "installing $pkg" + brew install "$pkg" + step ok prereqs "$pkg installed" + fi + done + return 0 + fi + for pkg in git mise; do + command -v "$pkg" >/dev/null 2>&1 || missing="$missing${missing:+ }$pkg" + done + if [ -n "$missing" ]; then + step run prereqs "installing $missing" + # Never -Syu here: Omarchy blocks unmanaged system upgrades. + sudo pacman -S --needed --noconfirm git mise + step ok prereqs "installed $missing" + fi +} - # Disable job control messages - disown $SPINNER_PID 2>/dev/null || true +# warn_step