diff --git a/.beads/config.yaml b/.beads/config.yaml index f02a515..2724a34 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -6,7 +6,7 @@ # Issue prefix for this repository (used by bd init) # If not set, bd init will auto-detect from directory name # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" +issue-prefix: "x" # Use no-db mode: load from JSONL, no SQLite, write back after each command # When true, bd will use .beads/issues.jsonl as the source of truth diff --git a/flake.lock b/flake.lock index 1c08685..43473d2 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ ] }, "locked": { - "lastModified": 1769020852, - "narHash": "sha256-MR6evuoa8w6mjYTesTAa3bsRH+c3IB7EOEDTCjsiAp8=", + "lastModified": 1769405733, + "narHash": "sha256-WpROnW0dRi5ub0SlpKrMBs3pYlSBY4xw22hnTNvBMgI=", "owner": "steveyegge", "repo": "beads", - "rev": "cb46db603d34c0190605eecb8724a6c581119f09", + "rev": "6e82d1e2eea121ce5dc0964d554879f8b0c08563", "type": "github" }, "original": { @@ -24,11 +24,11 @@ "doomemacs": { "flake": false, "locked": { - "lastModified": 1767773143, - "narHash": "sha256-QL/t9v2kFNxBDyNJb/s411o3mxujan+QX5IZglTdpTk=", + "lastModified": 1768984347, + "narHash": "sha256-VvC4rgAAaFnYLCdcUoz7dTE3kuBNuHIc+GlXOrPCxpg=", "owner": "doomemacs", "repo": "doomemacs", - "rev": "3e15fb36d7f94f0a218bda977be4d3f5da983a71", + "rev": "57818a6da90fbef39ff80d62fab2cd319496c3b9", "type": "github" }, "original": { @@ -47,11 +47,11 @@ ] }, "locked": { - "lastModified": 1768011937, - "narHash": "sha256-SnU2XTo34vwVaijs+4VwcXTNwMWO4nwzzs08N39UagA=", + "lastModified": 1769329593, + "narHash": "sha256-u5PSA+8TUYF/13ziBcnoE67nkDwpjAdecKh3srcJJm0=", "owner": "nix-community", "repo": "emacs-overlay", - "rev": "79abf71d9897cf3b5189f7175cda1b1102abc65c", + "rev": "776dc33d735af583a14cc56b406ea658398964a7", "type": "github" }, "original": { @@ -81,17 +81,17 @@ "gastown": { "flake": false, "locked": { - "lastModified": 1769031452, - "narHash": "sha256-tTvtLvTr38okqbpNnr5exfurI6VkVKNLcnM+A6O7DGY=", - "ref": "refs/heads/main", - "rev": "93e22595cd59802a24253b100dcfae98a6849428", - "revCount": 2938, - "type": "git", - "url": "ssh://git@git.johnogle.info:2222/johno/gastown.git" + "lastModified": 1769538736, + "narHash": "sha256-A33gyS/ERUCFcaFG9PJdIHfIOafguqkRe+DuIZteH5s=", + "owner": "steveyegge", + "repo": "gastown", + "rev": "177094a2335786d1d450fd9e14b935877291c004", + "type": "github" }, "original": { - "type": "git", - "url": "ssh://git@git.johnogle.info:2222/johno/gastown.git" + "owner": "steveyegge", + "repo": "gastown", + "type": "github" } }, "google-cookie-retrieval": { @@ -101,11 +101,11 @@ ] }, "locked": { - "lastModified": 1761423376, - "narHash": "sha256-pMy3cnUFfue4vz/y0jx71BfcPGxZf+hk/DtnzWvfU0c=", + "lastModified": 1768846578, + "narHash": "sha256-82f/+e8HAwmBukiLlr7I3HYvM/2GCd5SOc+BC+qzsOQ=", "ref": "refs/heads/main", - "rev": "a1f695665771841a988afc965526cbf99160cd77", - "revCount": 11, + "rev": "c11ff9d3c67372a843a0fa6bf23132e986bd6955", + "revCount": 14, "type": "git", "url": "https://git.johnogle.info/johno/google-cookie-retrieval.git" }, @@ -121,11 +121,11 @@ ] }, "locked": { - "lastModified": 1767514898, - "narHash": "sha256-ONYqnKrPzfKEEPChoJ9qPcfvBqW9ZgieDKD7UezWPg4=", + "lastModified": 1768949235, + "narHash": "sha256-TtjKgXyg1lMfh374w5uxutd6Vx2P/hU81aEhTxrO2cg=", "owner": "nix-community", "repo": "home-manager", - "rev": "7a06e8a2f844e128d3b210a000a62716b6040b7f", + "rev": "75ed713570ca17427119e7e204ab3590cc3bf2a5", "type": "github" }, "original": { @@ -142,11 +142,11 @@ ] }, "locked": { - "lastModified": 1767556355, - "narHash": "sha256-RDTUBDQBi9D4eD9iJQWtUDN/13MDLX+KmE+TwwNUp2s=", + "lastModified": 1769397130, + "narHash": "sha256-TTM4KV9IHwa181X7afBRbhLJIrgynpDjAXJFMUOWfyU=", "owner": "nix-community", "repo": "home-manager", - "rev": "f894bc4ffde179d178d8deb374fcf9855d1a82b7", + "rev": "c37679d37bdbecf11bbe3c5eb238d89ca4f60641", "type": "github" }, "original": { @@ -164,11 +164,11 @@ ] }, "locked": { - "lastModified": 1767082077, - "narHash": "sha256-2tL1mRb9uFJThUNfuDm/ehrnPvImL/QDtCxfn71IEz4=", + "lastModified": 1769273817, + "narHash": "sha256-+iyLihi/ynJokMgJZMRXuMuI6DPGUQRajz5ztNCHgnI=", "owner": "Jovian-Experiments", "repo": "Jovian-NixOS", - "rev": "efd4b22e6fdc6d7fb4e186ae333a4b74e03da440", + "rev": "98f988ad46e31f9956c5f6874dfb3580a7ff3969", "type": "github" }, "original": { @@ -184,11 +184,11 @@ ] }, "locked": { - "lastModified": 1765066094, - "narHash": "sha256-0YSU35gfRFJzx/lTGgOt6ubP8K6LeW0vaywzNNqxkl4=", + "lastModified": 1767634391, + "narHash": "sha256-owcSz2ICqTSvhBbhPP+1eWzi88e54rRZtfCNE5E/wwg=", "owner": "nix-darwin", "repo": "nix-darwin", - "rev": "688427b1aab9afb478ca07989dc754fa543e03d5", + "rev": "08585aacc3d6d6c280a02da195fdbd4b9cf083c2", "type": "github" }, "original": { @@ -206,11 +206,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1768034604, - "narHash": "sha256-62pIZMvGHhYJmMiiBsxHqZt/dFyENPcFHlJq5NJF3Sw=", + "lastModified": 1769330679, + "narHash": "sha256-X7rw5ouiAYKmbbKLtkEc/Kqcg6DxKgOtgaftzuchy/M=", "owner": "marienz", "repo": "nix-doom-emacs-unstraightened", - "rev": "9b3b8044fe4ccdcbb2d6f733d7dbe4d5feea18bc", + "rev": "0c2d527055f448c8856129c6d063535e06aeff4d", "type": "github" }, "original": { @@ -243,11 +243,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1767480499, - "narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=", + "lastModified": 1769089682, + "narHash": "sha256-9yA/LIuAVQq0lXelrZPjLuLVuZdm03p8tfmHhnDIkms=", "owner": "nixos", "repo": "nixpkgs", - "rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92", + "rev": "078d69f03934859a181e81ba987c2bb033eebfc5", "type": "github" }, "original": { @@ -259,11 +259,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1767379071, - "narHash": "sha256-EgE0pxsrW9jp9YFMkHL9JMXxcqi/OoumPJYwf+Okucw=", + "lastModified": 1769170682, + "narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fb7944c166a3b630f177938e478f0378e64ce108", + "rev": "c5296fdd05cfa2c187990dd909864da9658df755", "type": "github" }, "original": { @@ -273,6 +273,22 @@ "type": "github" } }, + "perles": { + "flake": false, + "locked": { + "lastModified": 1769460725, + "narHash": "sha256-zM2jw+emxe8+mNyR1ebMWkQiEx8uSmhoqqI0IxXLDgs=", + "owner": "zjrosen", + "repo": "perles", + "rev": "57b20413eea461452b59e13f5a4a367953b1f768", + "type": "github" + }, + "original": { + "owner": "zjrosen", + "repo": "perles", + "type": "github" + } + }, "plasma-manager": { "inputs": { "home-manager": [ @@ -283,11 +299,11 @@ ] }, "locked": { - "lastModified": 1763909441, - "narHash": "sha256-56LwV51TX/FhgX+5LCG6akQ5KrOWuKgcJa+eUsRMxsc=", + "lastModified": 1767662275, + "narHash": "sha256-d5Q1GmQ+sW1Bt8cgDE0vOihzLaswsm8cSdg8124EqXE=", "owner": "nix-community", "repo": "plasma-manager", - "rev": "b24ed4b272256dfc1cc2291f89a9821d5f9e14b4", + "rev": "51816be33a1ff0d4b22427de83222d5bfa96d30e", "type": "github" }, "original": { @@ -306,11 +322,11 @@ ] }, "locked": { - "lastModified": 1763909441, - "narHash": "sha256-56LwV51TX/FhgX+5LCG6akQ5KrOWuKgcJa+eUsRMxsc=", + "lastModified": 1767662275, + "narHash": "sha256-d5Q1GmQ+sW1Bt8cgDE0vOihzLaswsm8cSdg8124EqXE=", "owner": "nix-community", "repo": "plasma-manager", - "rev": "b24ed4b272256dfc1cc2291f89a9821d5f9e14b4", + "rev": "51816be33a1ff0d4b22427de83222d5bfa96d30e", "type": "github" }, "original": { @@ -331,6 +347,7 @@ "nix-doom-emacs-unstraightened": "nix-doom-emacs-unstraightened", "nixpkgs": "nixpkgs", "nixpkgs-unstable": "nixpkgs-unstable", + "perles": "perles", "plasma-manager": "plasma-manager", "plasma-manager-unstable": "plasma-manager-unstable" } diff --git a/flake.nix b/flake.nix index 90dfdf1..449e640 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,12 @@ }; gastown = { - url = "git+ssh://git@git.johnogle.info:2222/johno/gastown.git"; + url = "github:steveyegge/gastown"; + flake = false; # No flake.nix upstream yet + }; + + perles = { + url = "github:zjrosen/perles"; flake = false; # No flake.nix upstream yet }; @@ -210,7 +215,7 @@ }; # Darwin/macOS configurations - darwinConfigurations."blkfv4yf49kt7" = inputs.nix-darwin.lib.darwinSystem rec { + darwinConfigurations."BLKFV4YF49KT7" = inputs.nix-darwin.lib.darwinSystem rec { system = "aarch64-darwin"; modules = darwinModules ++ [ ./machines/johno-macbookpro/configuration.nix diff --git a/home/home-darwin-work.nix b/home/home-darwin-work.nix index 8d0e379..1cd3610 100644 --- a/home/home-darwin-work.nix +++ b/home/home-darwin-work.nix @@ -107,7 +107,7 @@ aerospace = { enable = true; leader = "cmd"; - ctrlShortcuts.enable = true; + ctrlShortcuts.enable = false; sketchybar.enable = true; # Optional: Add per-machine userSettings overrides # userSettings = { diff --git a/home/roles/aerospace/default.nix b/home/roles/aerospace/default.nix index 486158d..f7253ba 100644 --- a/home/roles/aerospace/default.nix +++ b/home/roles/aerospace/default.nix @@ -632,7 +632,9 @@ in text = '' #!/bin/bash - DISK_USAGE=$(df -H / | grep -v Filesystem | awk '{print $5}') + # Monitor /System/Volumes/Data which contains user data on APFS + # The root / is a read-only snapshot with minimal usage + DISK_USAGE=$(df -H /System/Volumes/Data | grep -v Filesystem | awk '{print $5}') ${pkgs.sketchybar}/bin/sketchybar --set $NAME label="$DISK_USAGE" ''; diff --git a/home/roles/base/default.nix b/home/roles/base/default.nix index 5ec69b9..051f71e 100644 --- a/home/roles/base/default.nix +++ b/home/roles/base/default.nix @@ -22,6 +22,7 @@ in shellcheck tmux tree + watch ]; # Automatic garbage collection for user profile (home-manager generations). diff --git a/home/roles/development/beads-search-query-optimization.patch b/home/roles/development/beads-search-query-optimization.patch new file mode 100644 index 0000000..c5d88e5 --- /dev/null +++ b/home/roles/development/beads-search-query-optimization.patch @@ -0,0 +1,44 @@ +diff --git a/internal/storage/dolt/queries.go b/internal/storage/dolt/queries.go +index 7d8214ee..8acdaae2 100644 +--- a/internal/storage/dolt/queries.go ++++ b/internal/storage/dolt/queries.go +@@ -212,8 +212,21 @@ func (s *DoltStore) SearchIssues(ctx context.Context, query string, filter types + } + + // nolint:gosec // G201: whereSQL contains column comparisons with ?, limitSQL is a safe integer ++ // Performance fix: SELECT all columns directly instead of id-only + WHERE IN (all_ids) ++ // See: hq-ihwsj - bd list uses inefficient WHERE IN (all_ids) query pattern + querySQL := fmt.Sprintf(` +- SELECT id FROM issues ++ SELECT id, content_hash, title, description, design, acceptance_criteria, notes, ++ status, priority, issue_type, assignee, estimated_minutes, ++ created_at, created_by, owner, updated_at, closed_at, external_ref, ++ compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, ++ deleted_at, deleted_by, delete_reason, original_type, ++ sender, ephemeral, pinned, is_template, crystallizes, ++ await_type, await_id, timeout_ns, waiters, ++ hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type, ++ event_kind, actor, target, payload, ++ due_at, defer_until, ++ quality_score, work_type, source_system ++ FROM issues + %s + ORDER BY priority ASC, created_at DESC + %s +@@ -225,7 +238,15 @@ func (s *DoltStore) SearchIssues(ctx context.Context, query string, filter types + } + defer rows.Close() + +- return s.scanIssueIDs(ctx, rows) ++ var issues []*types.Issue ++ for rows.Next() { ++ issue, err := scanIssueRow(rows) ++ if err != nil { ++ return nil, err ++ } ++ issues = append(issues, issue) ++ } ++ return issues, rows.Err() + } + + // GetReadyWork returns issues that are ready to work on (not blocked) diff --git a/home/roles/development/default.nix b/home/roles/development/default.nix index 53780b6..14ff066 100644 --- a/home/roles/development/default.nix +++ b/home/roles/development/default.nix @@ -9,17 +9,57 @@ let # Remove after upstream fix: https://github.com/steveyegge/beads/issues/XXX beadsPackage = globalInputs.beads.packages.${system}.default.overrideAttrs (old: { vendorHash = "sha256-YU+bRLVlWtHzJ1QPzcKJ70f+ynp8lMoIeFlm+29BNPE="; + + # Performance fix: avoid WHERE IN (8000+ IDs) query pattern that hammers Dolt CPU + # See: hq-ihwsj - bd list uses inefficient WHERE IN (all_ids) query pattern + # The fix changes SearchIssues to SELECT all columns directly instead of: + # 1. SELECT id FROM issues WHERE ... -> collect IDs + # 2. SELECT * FROM issues WHERE id IN (all_ids) -> 8000+ placeholder IN clause + patches = (old.patches or []) ++ [ + ./beads-search-query-optimization.patch + ]; }); # Gastown - multi-agent workspace manager (no upstream flake.nix yet) # Source is tracked via flake input for renovate updates + gastownRev = builtins.substring 0 8 (globalInputs.gastown.rev or "unknown"); gastownPackage = pkgs.buildGoModule { pname = "gastown"; - version = "unstable-${builtins.substring 0 8 globalInputs.gastown.rev or "unknown"}"; + version = "unstable-${gastownRev}"; src = globalInputs.gastown; vendorHash = "sha256-ripY9vrYgVW8bngAyMLh0LkU/Xx1UUaLgmAA7/EmWQU="; subPackages = [ "cmd/gt" ]; doCheck = false; + + # Must match ldflags from gastown Makefile - BuiltProperly=1 is required + # or gt will error with "This binary was built with 'go build' directly" + ldflags = [ + "-X github.com/steveyegge/gastown/internal/cmd.Version=${gastownRev}" + "-X github.com/steveyegge/gastown/internal/cmd.Commit=${gastownRev}" + "-X github.com/steveyegge/gastown/internal/cmd.BuildTime=nix-build" + "-X github.com/steveyegge/gastown/internal/cmd.BuiltProperly=1" + ]; + + # Bug fixes not yet merged upstream + # Each patch is stored in a separate file for clarity and maintainability + patches = [ + # Fix validateRecipient bug: normalize addresses before comparison + ./gastown-fix-validate-recipient.patch + # Fix agentBeadToAddress to use title field for hq- prefixed beads + ./gastown-fix-agent-bead-address-title.patch + # Fix agentBeadToAddress to handle rig-specific prefixes (j-, sc-, etc.) + ./gastown-fix-agent-bead-rig-prefix.patch + # Fix crew/polecat home paths: remove incorrect /rig suffix + ./gastown-fix-role-home-paths.patch + # Fix town root detection: don't map to Mayor (causes spurious mismatch warnings) + ./gastown-fix-town-root-detection.patch + # Fix copyDir to handle symlinks (broken symlinks cause "no such file" errors) + ./gastown-fix-copydir-symlinks.patch + # Statusline optimization: skip expensive beads queries for detached sessions + # Reduces Dolt CPU from ~70% to ~20% by caching and early-exit + ./gastown-statusline-optimization.patch + ]; + meta = with lib; { description = "Gas Town - multi-agent workspace manager by Steve Yegge"; homepage = "https://github.com/steveyegge/gastown"; @@ -28,6 +68,28 @@ let }; }; + # Perles - TUI for beads issue tracking (no upstream flake.nix yet) + # Source is tracked via flake input for renovate updates + perlesRev = builtins.substring 0 8 (globalInputs.perles.rev or "unknown"); + perlesPackage = pkgs.buildGoModule { + pname = "perles"; + version = "unstable-${perlesRev}"; + src = globalInputs.perles; + vendorHash = "sha256-JHERJDzbiqgjWXwRhXVjgDEiDQ3AUXRIONotfPF21B0="; + doCheck = false; + + ldflags = [ + "-X main.version=${perlesRev}" + ]; + + meta = with lib; { + description = "Perles - Terminal UI for beads issue tracking"; + homepage = "https://github.com/zjrosen/perles"; + license = licenses.mit; + mainProgram = "perles"; + }; + }; + # Fetch the claude-plugins repository (for humanlayer commands/agents) # Update the rev to get newer versions of the commands claudePluginsRepo = builtins.fetchGit { @@ -62,9 +124,11 @@ in home.packages = [ beadsPackage gastownPackage + perlesPackage pkgs.unstable.claude-code pkgs.unstable.claude-code-router pkgs.unstable.codex + pkgs.dolt pkgs.sqlite # Custom packages @@ -86,12 +150,14 @@ in if [ -f "$file" ]; then filename=$(basename "$file" .md) dest="$HOME/.claude/commands/humanlayer:''${filename}.md" + rm -f "$dest" 2>/dev/null || true # Copy file and conditionally remove the "model:" line from frontmatter ${if cfg.allowArbitraryClaudeCodeModelSelection then "cp \"$file\" \"$dest\"" else "${pkgs.gnused}/bin/sed '/^model:/d' \"$file\" > \"$dest\"" } + chmod u+w "$dest" 2>/dev/null || true fi done @@ -100,12 +166,14 @@ in if [ -f "$file" ]; then filename=$(basename "$file" .md) dest="$HOME/.claude/agents/humanlayer:''${filename}.md" + rm -f "$dest" 2>/dev/null || true # Copy file and conditionally remove the "model:" line from frontmatter ${if cfg.allowArbitraryClaudeCodeModelSelection then "cp \"$file\" \"$dest\"" else "${pkgs.gnused}/bin/sed '/^model:/d' \"$file\" > \"$dest\"" } + chmod u+w "$dest" 2>/dev/null || true fi done @@ -120,6 +188,7 @@ in sleep 0.5 cp "$file" "$dest" || echo "Warning: Failed to copy $filename.md to commands" fi + chmod u+w "$dest" 2>/dev/null || true fi done @@ -134,13 +203,17 @@ in sleep 0.5 cp "$file" "$dest" || echo "Warning: Failed to copy $filename.md to skills" fi + chmod u+w "$dest" 2>/dev/null || true fi done # Copy micro-skills (compact reusable knowledge referenced by formulas) for file in ${./skills/micro}/*.md; do if [ -f "$file" ]; then - cp "$file" "$HOME/.claude/commands/skills/$(basename "$file")" + dest="$HOME/.claude/commands/skills/$(basename "$file")" + rm -f "$dest" 2>/dev/null || true + cp "$file" "$dest" + chmod u+w "$dest" 2>/dev/null || true fi done @@ -148,7 +221,10 @@ in mkdir -p ~/.beads/formulas for file in ${./formulas}/*.formula.toml; do if [ -f "$file" ]; then - cp "$file" "$HOME/.beads/formulas/$(basename "$file")" + dest="$HOME/.beads/formulas/$(basename "$file")" + rm -f "$dest" 2>/dev/null || true + cp "$file" "$dest" + chmod u+w "$dest" 2>/dev/null || true fi done diff --git a/home/roles/development/gastown-fix-agent-bead-address-title.patch b/home/roles/development/gastown-fix-agent-bead-address-title.patch new file mode 100644 index 0000000..84975b2 --- /dev/null +++ b/home/roles/development/gastown-fix-agent-bead-address-title.patch @@ -0,0 +1,15 @@ +diff --git a/internal/mail/router.go b/internal/mail/router.go +--- a/internal/mail/router.go ++++ b/internal/mail/router.go +@@ -326,7 +326,10 @@ func agentBeadToAddress(bead *agentBead) string { + } + + // Fall back to parsing description for role_type and rig +- return parseAgentAddressFromDescription(bead.Description) ++ if bead.Title != "" && strings.Contains(bead.Title, "/") { ++ return bead.Title ++ } ++ return parseAgentAddressFromDescription(bead.Description) + } + + // Handle gt- prefixed IDs (legacy format) diff --git a/home/roles/development/gastown-fix-agent-bead-rig-prefix.patch b/home/roles/development/gastown-fix-agent-bead-rig-prefix.patch new file mode 100644 index 0000000..b5a4b20 --- /dev/null +++ b/home/roles/development/gastown-fix-agent-bead-rig-prefix.patch @@ -0,0 +1,35 @@ +diff --git a/internal/mail/router.go b/internal/mail/router.go +--- a/internal/mail/router.go ++++ b/internal/mail/router.go +@@ -330,8 +330,29 @@ func agentBeadToAddress(bead *agentBead) string { + } + + // Handle gt- prefixed IDs (legacy format) +- if !strings.HasPrefix(id, "gt-") { +- return "" // Not a valid agent bead ID ++ // Handle rig-specific prefixes: --- ++ // Examples: j-java-crew-americano -> java/crew/americano ++ idParts := strings.Split(id, "-") ++ if len(idParts) >= 3 { ++ for i, part := range idParts { ++ if part == "crew" || part == "polecat" || part == "polecats" { ++ if i >= 1 && i < len(idParts)-1 { ++ rig := idParts[i-1] ++ name := strings.Join(idParts[i+1:], "-") ++ return rig + "/" + part + "/" + name ++ } ++ } ++ if part == "witness" || part == "refinery" { ++ if i >= 1 { ++ return idParts[i-1] + "/" + part ++ } ++ } ++ } ++ } ++ ++ // Handle gt- prefixed IDs (legacy format) ++ if !strings.HasPrefix(id, "gt-") { ++ return "" // Not a valid agent bead ID + } + + // Strip prefix diff --git a/home/roles/development/gastown-fix-copydir-symlinks.patch b/home/roles/development/gastown-fix-copydir-symlinks.patch new file mode 100644 index 0000000..fd2a9b6 --- /dev/null +++ b/home/roles/development/gastown-fix-copydir-symlinks.patch @@ -0,0 +1,25 @@ +diff --git a/internal/git/git.go b/internal/git/git.go +--- a/internal/git/git.go ++++ b/internal/git/git.go +@@ -73,7 +73,19 @@ func copyDir(src, dest string) error { + srcPath := filepath.Join(src, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + +- if entry.IsDir() { ++ // Handle symlinks (recreate them, do not follow) ++ if entry.Type()&os.ModeSymlink != 0 { ++ linkTarget, err := os.Readlink(srcPath) ++ if err != nil { ++ return err ++ } ++ if err := os.Symlink(linkTarget, destPath); err != nil { ++ return err ++ } ++ continue ++ } ++ ++ if entry.IsDir() { + if err := copyDir(srcPath, destPath); err != nil { + return err + } + diff --git a/home/roles/development/gastown-fix-role-home-paths.patch b/home/roles/development/gastown-fix-role-home-paths.patch new file mode 100644 index 0000000..20ba5d3 --- /dev/null +++ b/home/roles/development/gastown-fix-role-home-paths.patch @@ -0,0 +1,18 @@ +diff --git a/internal/cmd/role.go b/internal/cmd/role.go +--- a/internal/cmd/role.go ++++ b/internal/cmd/role.go +@@ -326,11 +326,11 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string { + if rig == "" || polecat == "" { + return "" + } +- return filepath.Join(townRoot, rig, "polecats", polecat, "rig") ++ return filepath.Join(townRoot, rig, "polecats", polecat) + case RoleCrew: + if rig == "" || polecat == "" { + return "" + } +- return filepath.Join(townRoot, rig, "crew", polecat, "rig") ++ return filepath.Join(townRoot, rig, "crew", polecat) + default: + return "" + } diff --git a/home/roles/development/gastown-fix-town-root-detection.patch b/home/roles/development/gastown-fix-town-root-detection.patch new file mode 100644 index 0000000..8782030 --- /dev/null +++ b/home/roles/development/gastown-fix-town-root-detection.patch @@ -0,0 +1,18 @@ +diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go +--- a/internal/cmd/prime.go ++++ b/internal/cmd/prime.go +@@ -276,11 +276,12 @@ func detectRole(cwd, townRoot string) RoleInfo { + + // Check for mayor role + // At town root, or in mayor/ or mayor/rig/ + if relPath == "." || relPath == "" { +- ctx.Role = RoleMayor +- return ctx ++ return ctx // RoleUnknown - town root is shared space + } ++ ++ // Check for mayor role: mayor/ or mayor/rig/ + if len(parts) >= 1 && parts[0] == "mayor" { + ctx.Role = RoleMayor + return ctx + } diff --git a/home/roles/development/gastown-fix-validate-recipient.patch b/home/roles/development/gastown-fix-validate-recipient.patch new file mode 100644 index 0000000..0d1258f --- /dev/null +++ b/home/roles/development/gastown-fix-validate-recipient.patch @@ -0,0 +1,13 @@ +diff --git a/internal/mail/router.go b/internal/mail/router.go +index b864c069..4b6a045b 100644 +--- a/internal/mail/router.go ++++ b/internal/mail/router.go +@@ -646,7 +646,7 @@ func (r *Router) validateRecipient(identity string) error { + } + + for _, agent := range agents { +- if agentBeadToAddress(agent) == identity { ++ if AddressToIdentity(agentBeadToAddress(agent)) == AddressToIdentity(identity) { + return nil // Found matching agent + } + } diff --git a/home/roles/development/gastown-statusline-optimization.patch b/home/roles/development/gastown-statusline-optimization.patch new file mode 100644 index 0000000..c981f71 --- /dev/null +++ b/home/roles/development/gastown-statusline-optimization.patch @@ -0,0 +1,135 @@ +diff --git a/internal/cmd/statusline.go b/internal/cmd/statusline.go +index 2edf1be8..00253eea 100644 +--- a/internal/cmd/statusline.go ++++ b/internal/cmd/statusline.go +@@ -6,6 +6,7 @@ import ( + "path/filepath" + "sort" + "strings" ++ "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" +@@ -14,6 +15,37 @@ import ( + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/workspace" + ) ++// statusLineCacheTTL is how long cached status output remains valid. ++const statusLineCacheTTL = 10 * time.Second ++ ++// statusLineCachePath returns the cache file path for a session. ++func statusLineCachePath(session string) string { ++ return filepath.Join(os.TempDir(), fmt.Sprintf("gt-status-%s", session)) ++} ++ ++// getStatusLineCache returns cached status if fresh, empty string otherwise. ++func getStatusLineCache(session string) string { ++ path := statusLineCachePath(session) ++ info, err := os.Stat(path) ++ if err != nil { ++ return "" ++ } ++ if time.Since(info.ModTime()) > statusLineCacheTTL { ++ return "" ++ } ++ data, err := os.ReadFile(path) ++ if err != nil { ++ return "" ++ } ++ return string(data) ++} ++ ++// setStatusLineCache writes status to cache file. ++func setStatusLineCache(session, status string) { ++ path := statusLineCachePath(session) ++ _ = os.WriteFile(path, []byte(status), 0644) ++} ++ + + var ( + statusLineSession string +@@ -34,6 +66,19 @@ func init() { + func runStatusLine(cmd *cobra.Command, args []string) error { + t := tmux.NewTmux() + ++ // Optimization: skip expensive beads queries for detached sessions ++ if statusLineSession != "" { ++ if !t.IsSessionAttached(statusLineSession) { ++ fmt.Print("○ |") ++ return nil ++ } ++ // Check cache for attached sessions too ++ if cached := getStatusLineCache(statusLineSession); cached != "" { ++ fmt.Print(cached) ++ return nil ++ } ++ } ++ + // Get session environment + var rigName, polecat, crew, issue, role string + +@@ -150,7 +195,11 @@ func runWorkerStatusLine(t *tmux.Tmux, session, rigName, polecat, crew, issue st + + // Output + if len(parts) > 0 { +- fmt.Print(strings.Join(parts, " | ") + " |") ++ output := strings.Join(parts, " | ") + " |" ++ if statusLineSession != "" { ++ setStatusLineCache(statusLineSession, output) ++ } ++ fmt.Print(output) + } + + return nil +@@ -389,7 +438,11 @@ func runMayorStatusLine(t *tmux.Tmux) error { + } + } + +- fmt.Print(strings.Join(parts, " | ") + " |") ++ output := strings.Join(parts, " | ") + " |" ++ if statusLineSession != "" { ++ setStatusLineCache(statusLineSession, output) ++ } ++ fmt.Print(output) + return nil + } + +@@ -458,7 +511,11 @@ func runDeaconStatusLine(t *tmux.Tmux) error { + } + } + +- fmt.Print(strings.Join(parts, " | ") + " |") ++ output := strings.Join(parts, " | ") + " |" ++ if statusLineSession != "" { ++ setStatusLineCache(statusLineSession, output) ++ } ++ fmt.Print(output) + return nil + } + +@@ -526,7 +583,11 @@ func runWitnessStatusLine(t *tmux.Tmux, rigName string) error { + } + } + +- fmt.Print(strings.Join(parts, " | ") + " |") ++ output := strings.Join(parts, " | ") + " |" ++ if statusLineSession != "" { ++ setStatusLineCache(statusLineSession, output) ++ } ++ fmt.Print(output) + return nil + } + +@@ -617,7 +678,11 @@ func runRefineryStatusLine(t *tmux.Tmux, rigName string) error { + } + } + +- fmt.Print(strings.Join(parts, " | ") + " |") ++ output := strings.Join(parts, " | ") + " |" ++ if statusLineSession != "" { ++ setStatusLineCache(statusLineSession, output) ++ } ++ fmt.Print(output) + return nil + } + diff --git a/home/roles/emacs/doom/config.el b/home/roles/emacs/doom/config.el index 537391f..4a1fcb8 100644 --- a/home/roles/emacs/doom/config.el +++ b/home/roles/emacs/doom/config.el @@ -53,6 +53,22 @@ ;; change `org-directory'. It must be set before org loads! (setq org-directory "~/org/") (after! org + ;; Skip recurring events past their CALDAV_UNTIL date + ;; org-caldav ignores UNTIL from RRULE, so we store it as a property + ;; and filter here in the agenda + (defun my/skip-if-past-until () + "Return non-nil if entry has CALDAV_UNTIL and current date is past it." + (let ((until-str (org-entry-get nil "CALDAV_UNTIL"))) + (when (and until-str + (string-match "^\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" until-str)) + (let* ((until-year (string-to-number (match-string 1 until-str))) + (until-month (string-to-number (match-string 2 until-str))) + (until-day (string-to-number (match-string 3 until-str))) + (until-time (encode-time 0 0 0 until-day until-month until-year)) + (today (current-time))) + (when (time-less-p until-time today) + (org-end-of-subtree t)))))) + (setq org-agenda-span 'week org-agenda-start-with-log-mode t my-agenda-dirs '("projects" "roam") @@ -61,6 +77,7 @@ "\.org$")) my-agenda-dirs)) org-log-done 'time + org-agenda-skip-function-global #'my/skip-if-past-until org-agenda-custom-commands '(("n" "Agenda" ((agenda "") (tags-todo "-someday-recurring"))) @@ -83,25 +100,135 @@ "d" #'org-agenda-day-view "w" #'org-agenda-week-view)) -;; (use-package! org-caldav -;; :defer t -;; :config -;; (setq org-caldav-url "https://nextcloud.johnogle.info/remote.php/dav/calendars/johno" -;; org-caldav-calendar-id "personal" -;; org-icalendar-timezone "America/Los_Angeles" -;; org-caldav-inbox "~/org/calendar.org" -;; org-caldav-files nil -;; org-caldav-sync-direction 'cal->org)) +;; org-caldav: Sync Org entries with Nextcloud CalDAV +;; Setup requirements: +;; 1. Create Nextcloud app password: Settings -> Security -> Devices & sessions +;; 2. Store in rbw: rbw add nextcloud-caldav (put app password as the secret) +;; 3. Run: doom sync +;; 4. Test: M-x my/org-caldav-sync-with-rbw (or SPC o a s) +;; +;; Note: Conflict resolution is "Org always wins" - treat Org as source of truth +;; for entries that originated in Org. -(defun my/get-rbw-password (alias) - "Return the password for ALIAS via rbw, unlocking the vault only if needed." - (let* ((cmd (format "rbw get %s 2>&1" alias)) - (output (shell-command-to-string cmd))) - (string-trim output))) +;; Define sync wrapper before use-package (so keybinding works) +(defun my/org-caldav-sync-with-rbw () + "Run org-caldav-sync with credentials from rbw embedded in URL." + (interactive) + (require 'org) + (require 'org-caldav) + (let* ((password (my/get-rbw-password "nextcloud-caldav")) + ;; Embed credentials in URL (url-encode password in case of special chars) + (encoded-pass (url-hexify-string password))) + (setq org-caldav-url + (format "https://johno:%s@nextcloud.johnogle.info/remote.php/dav/calendars/johno" + encoded-pass)) + (org-caldav-sync))) + +(use-package! org-caldav + :after org + :commands (org-caldav-sync my/org-caldav-sync-with-rbw) + :init + (map! :leader + (:prefix ("o" . "open") + (:prefix ("a" . "agenda/calendar") + :desc "Sync CalDAV" "s" #'my/org-caldav-sync-with-rbw))) + :config + ;; Nextcloud CalDAV base URL (credentials added dynamically by sync wrapper) + (setq org-caldav-url "https://nextcloud.johnogle.info/remote.php/dav/calendars/johno") + + ;; Timezone for iCalendar export + (setq org-icalendar-timezone "America/Los_Angeles") + + ;; Sync state storage (in org directory for multi-machine sync) + (setq org-caldav-save-directory (expand-file-name ".org-caldav/" org-directory)) + + ;; Backup file for entries before modification + (setq org-caldav-backup-file (expand-file-name ".org-caldav/backup.org" org-directory)) + + ;; Limit past events to 30 days (avoids uploading years of scheduled tasks) + (setq org-caldav-days-in-past 30) + + ;; Sync behavior: bidirectional by default + (setq org-caldav-sync-direction 'twoway) + + ;; What changes from calendar sync back to Org (conservative: title and timestamp only) + (setq org-caldav-sync-changes-to-org 'title-and-timestamp) + + ;; Deletion handling: never auto-delete to prevent accidental mass deletion + (setq org-caldav-delete-calendar-entries 'never) + (setq org-caldav-delete-org-entries 'never) + + ;; Enable TODO/VTODO sync + (setq org-icalendar-include-todo 'all) + (setq org-caldav-sync-todo t) + + ;; Map VTODO percent-complete to org-todo-keywords + ;; Format: (PERCENT "KEYWORD") - percent thresholds map to states + (setq org-caldav-todo-percent-states + '((0 "TODO") + (25 "WAIT") + (50 "IN-PROGRESS") + (100 "DONE") + (100 "KILL"))) + + ;; Allow export with broken links (mu4e links can't be resolved during export) + (setq org-export-with-broken-links 'mark) + + ;; Calendar-specific configuration + (setq org-caldav-calendars + '(;; Personal calendar: two-way sync with family-shared Nextcloud calendar + (:calendar-id "personal" + :inbox "~/org/personal-calendar.org" + :files ("~/org/personal-calendar.org")) + + ;; Tasks calendar: one-way sync (org → calendar only) + ;; SCHEDULED/DEADLINE items from todo.org push to private Tasks calendar. + ;; No inbox = no download from calendar (effectively one-way). + ;; Note: Create 'tasks' calendar in Nextcloud first, keep it private. + (:calendar-id "tasks" + :files ("~/org/todo.org")))) + + ;; Handle UNTIL in recurring events + ;; org-caldav ignores UNTIL from RRULE - events repeat forever. + ;; This advice extracts UNTIL and stores it as a property for agenda filtering. + (defun my/org-caldav-add-until-property (orig-fun eventdata-alist) + "Advice to store CALDAV_UNTIL property for recurring events." + (let ((result (funcall orig-fun eventdata-alist))) + (let* ((rrule-props (alist-get 'rrule-props eventdata-alist)) + (until-str (cadr (assoc 'UNTIL rrule-props))) + (summary (alist-get 'summary eventdata-alist))) + ;; Debug: log what we're seeing + (message "CALDAV-DEBUG: %s | rrule-props: %S | until: %s" + (or summary "?") rrule-props until-str) + (when until-str + (save-excursion + (org-back-to-heading t) + (org-entry-put nil "CALDAV_UNTIL" until-str)))) + result)) + + (advice-add 'org-caldav-insert-org-event-or-todo + :around #'my/org-caldav-add-until-property) + ) + +(defun my/get-rbw-password (alias &optional no-error) + "Return the password for ALIAS via rbw, unlocking the vault only if needed. +If NO-ERROR is non-nil, return nil instead of signaling an error when +rbw is unavailable or the entry is not found." + (if (not (executable-find "rbw")) + (if no-error + nil + (user-error "rbw: not installed or not in PATH")) + (let* ((cmd (format "rbw get %s 2>/dev/null" (shell-quote-argument alias))) + (output (string-trim (shell-command-to-string cmd)))) + (if (string-empty-p output) + (if no-error + nil + (user-error "rbw: no entry found for '%s' - run: rbw add %s" alias alias)) + output)))) (after! gptel :config - (setq! gptel-api-key (my/get-rbw-password "openai-api-key-chatgpt-el") + (setq! gptel-api-key (my/get-rbw-password "openai-api-key-chatgpt-el" t) gptel-default-mode 'org-mode gptel-use-tools t gptel-confirm-tool-calls 'always diff --git a/home/roles/emacs/doom/packages.el b/home/roles/emacs/doom/packages.el index b1630c5..bdc1ef0 100644 --- a/home/roles/emacs/doom/packages.el +++ b/home/roles/emacs/doom/packages.el @@ -49,7 +49,7 @@ ;; ...Or *all* packages (NOT RECOMMENDED; will likely break things) ;; (unpin! t) -;; (package! org-caldav) +(package! org-caldav) ;; Note: Packages with custom recipes must be pinned for nix-doom-emacs-unstraightened ;; to build deterministically. Update pins when upgrading packages. diff --git a/machines/john-endesktop/configuration.nix b/machines/john-endesktop/configuration.nix index d124bfa..21d7690 100644 --- a/machines/john-endesktop/configuration.nix +++ b/machines/john-endesktop/configuration.nix @@ -90,6 +90,8 @@ with lib; htop tmux zfs + rclone + custom.rclone-torbox-setup # Helper script to set up TorBox credentials via rbw ]; # Enable SSH @@ -126,6 +128,26 @@ with lib; roles.virtualisation.enable = true; + # TorBox WebDAV mount for rdt-client and Jellyfin + roles.rclone-mount = { + enable = true; + mounts.torbox = { + webdavUrl = "https://webdav.torbox.app"; + username = "john@ogle.fyi"; # TorBox account email + mountPoint = "/media/media/torbox-rclone"; + environmentFile = "/etc/rclone/torbox.env"; + vfsCacheMode = "full"; # Best for streaming media + dirCacheTime = "5m"; + extraArgs = [ + "--buffer-size=64M" + "--vfs-read-chunk-size=32M" + "--vfs-read-chunk-size-limit=off" + ]; + # Wait for ZFS media pool to be mounted before starting + requiresMountsFor = [ "/media" ]; + }; + }; + # Time zone time.timeZone = "America/Los_Angeles"; # Adjust as needed diff --git a/machines/nix-book/configuration.nix b/machines/nix-book/configuration.nix index ccf7cd8..4e46ff7 100644 --- a/machines/nix-book/configuration.nix +++ b/machines/nix-book/configuration.nix @@ -23,12 +23,12 @@ printing.enable = true; remote-build.builders = [ { - hostName = "zix790prors"; + hostName = "zix790prors.oglehome"; maxJobs = 16; speedFactor = 3; } { - hostName = "john-endesktop"; + hostName = "john-endesktop.oglehome"; maxJobs = 1; speedFactor = 1; } diff --git a/machines/nix-deck/configuration.nix b/machines/nix-deck/configuration.nix index 011cf27..238e10f 100644 --- a/machines/nix-deck/configuration.nix +++ b/machines/nix-deck/configuration.nix @@ -19,11 +19,18 @@ desktopSession = "plasma"; }; }; - remote-build.builders = [{ - hostName = "zix790prors"; - maxJobs = 16; - speedFactor = 4; # Prefer remote heavily on Steam Deck - }]; + remote-build.builders = [ + { + hostName = "zix790prors.oglehome"; + maxJobs = 16; + speedFactor = 4; + } + { + hostName = "john-endesktop.oglehome"; + maxJobs = 1; + speedFactor = 2; + } + ]; users = { enable = true; extraGroups = [ "video" ]; diff --git a/packages/claude-code/default.nix b/packages/claude-code/default.nix index 24437c8..91085ad 100644 --- a/packages/claude-code/default.nix +++ b/packages/claude-code/default.nix @@ -6,24 +6,24 @@ }: let - version = "2.1.12"; + version = "2.1.19"; srcs = { aarch64-darwin = { url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/${version}/darwin-arm64/claude"; - sha256 = "40be59519a84bd35eb1111aa46f72aa6b3443866d3f6336252a198fdcaefbbe5"; + sha256 = "d386ac8f6d1479f85d31f369421c824135c10249c32087017d05a5f428852c41"; }; x86_64-darwin = { url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/${version}/darwin-x64/claude"; - sha256 = "0eee4b46c91749480bf856f88e49b15a3e944faa9d346679c5f0c0d7fa6f2f54"; + sha256 = "be266b3a952f483d8358ad141e2afe661170386506f479ead992319e4fdc38ac"; }; x86_64-linux = { url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/${version}/linux-x64/claude"; - sha256 = "3fe979215489dc1b31463fadf95ed2d2d5473a9969447bb7a46431f4578847d4"; + sha256 = "4e2a1c73871ecf3b133376b57ded03333a7a6387f2d2a3a6279bb90a07f7a944"; }; aarch64-linux = { url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/${version}/linux-arm64/claude"; - sha256 = "e214b1d3b5afd4cd2de9177359001d41a3eb98cb1e3665fe97edc592f5aa132f"; + sha256 = "8c4b61b24ca760d6f7aa2f19727163d122e9fd0c3ce91f106a21b6918a7b1bbb"; }; }; diff --git a/packages/default.nix b/packages/default.nix index 89b08ed..2ef8623 100644 --- a/packages/default.nix +++ b/packages/default.nix @@ -4,4 +4,5 @@ app-launcher-server = pkgs.callPackage ./app-launcher-server {}; claude-code = pkgs.callPackage ./claude-code {}; mcrcon-rbw = pkgs.callPackage ./mcrcon-rbw {}; + rclone-torbox-setup = pkgs.callPackage ./rclone-torbox-setup {}; } diff --git a/packages/rclone-torbox-setup/default.nix b/packages/rclone-torbox-setup/default.nix new file mode 100644 index 0000000..04d17cc --- /dev/null +++ b/packages/rclone-torbox-setup/default.nix @@ -0,0 +1,98 @@ +{ pkgs, ... }: + +pkgs.writeShellScriptBin "rclone-torbox-setup" '' + set -euo pipefail + + # Default values + RBW_ENTRY="''${1:-torbox}" + ENV_FILE="''${2:-/etc/rclone/torbox.env}" + + usage() { + echo "Usage: rclone-torbox-setup [rbw-entry] [env-file]" + echo "" + echo "Sets up rclone credentials for TorBox WebDAV mount." + echo "Retrieves password from rbw (Bitwarden), obscures it for rclone," + echo "and writes it to the environment file for the systemd service." + echo "" + echo "Arguments:" + echo " rbw-entry Name of the Bitwarden entry containing the password (default: torbox)" + echo " env-file Path to write the environment file (default: /etc/rclone/torbox.env)" + echo "" + echo "The Bitwarden entry should contain your TorBox password as the password field." + echo "" + echo "Example:" + echo " rclone-torbox-setup torbox-password /etc/rclone/torbox.env" + exit 1 + } + + if [[ "''${1:-}" == "-h" ]] || [[ "''${1:-}" == "--help" ]]; then + usage + fi + + echo "rclone TorBox credential setup" + echo "==============================" + echo "" + + # Check if rbw is available + if ! command -v rbw &> /dev/null; then + echo "Error: rbw is not available. Please ensure rbw is installed and configured." + exit 1 + fi + + # Check if rclone is available + if ! command -v rclone &> /dev/null; then + echo "Error: rclone is not available. Please ensure rclone is installed." + exit 1 + fi + + echo "Retrieving password from rbw entry: $RBW_ENTRY" + + # Retrieve password from Bitwarden + if ! TORBOX_PASS=$(rbw get "$RBW_ENTRY" 2>/dev/null); then + echo "" + echo "Error: Failed to retrieve password from rbw entry '$RBW_ENTRY'" + echo "" + echo "Please ensure:" + echo " 1. The entry '$RBW_ENTRY' exists in Bitwarden" + echo " 2. rbw is unlocked: rbw unlock" + echo " 3. rbw is synced: rbw sync" + echo "" + echo "To create the entry in Bitwarden:" + echo " - Name: $RBW_ENTRY" + echo " - Password: Your TorBox password" + exit 1 + fi + + echo "Password retrieved successfully" + + # Obscure the password for rclone + echo "Obscuring password for rclone..." + if ! OBSCURED_PASS=$(echo -n "$TORBOX_PASS" | rclone obscure -); then + echo "Error: Failed to obscure password with rclone" + exit 1 + fi + + # Create the directory if needed (requires sudo) + ENV_DIR=$(dirname "$ENV_FILE") + if [[ ! -d "$ENV_DIR" ]]; then + echo "Creating directory $ENV_DIR (requires sudo)..." + sudo mkdir -p "$ENV_DIR" + fi + + # Write the environment file + echo "Writing environment file to $ENV_FILE (requires sudo)..." + echo "RCLONE_WEBDAV_PASS=$OBSCURED_PASS" | sudo tee "$ENV_FILE" > /dev/null + sudo chmod 600 "$ENV_FILE" + + echo "" + echo "Setup complete!" + echo "" + echo "The environment file has been created at: $ENV_FILE" + echo "The rclone-mount-torbox systemd service will use this file." + echo "" + echo "To activate the mount after NixOS rebuild:" + echo " sudo systemctl start rclone-mount-torbox" + echo "" + echo "To check status:" + echo " sudo systemctl status rclone-mount-torbox" +'' diff --git a/roles/default.nix b/roles/default.nix index a56fd02..2a02e65 100644 --- a/roles/default.nix +++ b/roles/default.nix @@ -14,6 +14,7 @@ with lib; ./nfs-mounts ./nvidia ./printing + ./rclone-mount ./remote-build ./spotifyd ./users diff --git a/roles/rclone-mount/default.nix b/roles/rclone-mount/default.nix new file mode 100644 index 0000000..3d44261 --- /dev/null +++ b/roles/rclone-mount/default.nix @@ -0,0 +1,149 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.roles.rclone-mount; + + # Generate systemd service for a single mount + mkMountService = name: mountCfg: { + description = "rclone mount for ${name}"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + # Wait for parent mount points (e.g., ZFS pools) to be available + unitConfig = mkIf (mountCfg.requiresMountsFor != []) { + RequiresMountsFor = mountCfg.requiresMountsFor; + }; + + serviceConfig = { + Type = "notify"; + ExecStartPre = "${pkgs.coreutils}/bin/mkdir -p ${mountCfg.mountPoint}"; + ExecStart = concatStringsSep " " ([ + "${pkgs.rclone}/bin/rclone mount" + ":webdav:${mountCfg.remotePath}" + "${mountCfg.mountPoint}" + "--webdav-url=${mountCfg.webdavUrl}" + "--webdav-vendor=${mountCfg.webdavVendor}" + "--webdav-user=${mountCfg.username}" + "--allow-other" + "--vfs-cache-mode=${mountCfg.vfsCacheMode}" + "--dir-cache-time=${mountCfg.dirCacheTime}" + "--poll-interval=${mountCfg.pollInterval}" + "--log-level=${mountCfg.logLevel}" + ] ++ mountCfg.extraArgs); + ExecStop = "${pkgs.fuse}/bin/fusermount -uz ${mountCfg.mountPoint}"; + Restart = "on-failure"; + RestartSec = "10s"; + EnvironmentFile = mountCfg.environmentFile; + }; + }; +in +{ + options.roles.rclone-mount = { + enable = mkEnableOption "Enable rclone WebDAV mounts"; + + mounts = mkOption { + type = types.attrsOf (types.submodule { + options = { + webdavUrl = mkOption { + type = types.str; + description = "WebDAV server URL (e.g., https://webdav.torbox.app)"; + }; + + webdavVendor = mkOption { + type = types.enum [ "other" "nextcloud" "owncloud" "sharepoint" "sharepoint-ntlm" "fastmail" ]; + default = "other"; + description = "WebDAV server vendor for optimizations"; + }; + + username = mkOption { + type = types.str; + description = "WebDAV username (often email address)"; + }; + + environmentFile = mkOption { + type = types.path; + description = '' + Path to environment file containing RCLONE_WEBDAV_PASS. + The password should be obscured using: rclone obscure + File format: RCLONE_WEBDAV_PASS= + ''; + }; + + mountPoint = mkOption { + type = types.str; + description = "Local mount point path"; + }; + + remotePath = mkOption { + type = types.str; + default = "/"; + description = "Remote path on WebDAV server to mount"; + }; + + vfsCacheMode = mkOption { + type = types.enum [ "off" "minimal" "writes" "full" ]; + default = "full"; + description = '' + VFS cache mode. For streaming media, 'full' is recommended. + - off: No caching (direct reads/writes) + - minimal: Cache open files only + - writes: Cache writes and open files + - full: Full caching of all files + ''; + }; + + dirCacheTime = mkOption { + type = types.str; + default = "5m"; + description = "Time to cache directory entries"; + }; + + pollInterval = mkOption { + type = types.str; + default = "1m"; + description = "Poll interval for remote changes"; + }; + + logLevel = mkOption { + type = types.enum [ "DEBUG" "INFO" "NOTICE" "ERROR" ]; + default = "INFO"; + description = "rclone log level"; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = []; + description = "Extra arguments to pass to rclone mount"; + }; + + requiresMountsFor = mkOption { + type = types.listOf types.str; + default = []; + description = '' + List of mount points that must be available before this service starts. + Use this when the mount point's parent is on a ZFS pool or other filesystem + that may not be mounted at boot time. + Example: [ "/media" ] to wait for the media ZFS pool to mount. + ''; + }; + }; + }); + default = {}; + description = "Attribute set of rclone WebDAV mounts to configure"; + }; + }; + + config = mkIf cfg.enable { + # Ensure FUSE is available + environment.systemPackages = [ pkgs.rclone pkgs.fuse ]; + programs.fuse.userAllowOther = true; + + # Create systemd services for each mount + systemd.services = mapAttrs' (name: mountCfg: + nameValuePair "rclone-mount-${name}" (mkMountService name mountCfg) + ) cfg.mounts; + }; +} diff --git a/roles/remote-build/default.nix b/roles/remote-build/default.nix index d18ebac..181869b 100644 --- a/roles/remote-build/default.nix +++ b/roles/remote-build/default.nix @@ -35,12 +35,12 @@ # a) Configure builders in configuration.nix: # roles.remote-build.builders = [ # { -# hostName = "zix790prors"; +# hostName = "zix790prors.oglehome"; # maxJobs = 16; # Number of parallel build jobs # speedFactor = 3; # Higher = prefer this builder # } # { -# hostName = "john-endesktop"; +# hostName = "john-endesktop.oglehome"; # maxJobs = 1; # Conservative for busy machines # speedFactor = 1; # }