Add an install path: LaunchAgent, repos config, and documented defaults !10
6 files changed, +287 −12
README.md +61
| @@ -224,6 +224,67 @@ and — for the differential test — `zpty` modules ship with zsh). | ||
| 224 | 224 | - **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse. |
| 225 | 225 | *Out of scope.* |
| 226 | 226 | |
| 227 | ## Install | |
| 228 | ||
| 229 | ```sh | |
| 230 | brew install krz/tap/ambient-companions | |
| 231 | brew services start ambient-companions | |
| 232 | ``` | |
| 233 | ||
| 234 | Name the repositories to watch — signald takes none by default, and a login | |
| 235 | agent has no useful working directory: | |
| 236 | ||
| 237 | ```sh | |
| 238 | mkdir -p ~/.config/signald | |
| 239 | cat > ~/.config/signald/repos <<'EOF' | |
| 240 | # one repository path per line; # comments and blank lines are ignored | |
| 241 | ~/git/some-repo | |
| 242 | EOF | |
| 243 | brew services restart ambient-companions | |
| 244 | ``` | |
| 245 | ||
| 246 | Then add one line to `.zshrc` for the terminal collector: | |
| 247 | ||
| 248 | ```sh | |
| 249 | source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh" | |
| 250 | ``` | |
| 251 | ||
| 252 | Open a new shell and watch the garden: | |
| 253 | ||
| 254 | ```sh | |
| 255 | terminal-garden | |
| 256 | ``` | |
| 257 | ||
| 258 | `brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand | |
| 259 | instead, `packaging/net.krz.signald.plist` is a launchd template; its header | |
| 260 | comment carries the `sed` line that fills in the paths and the `launchctl load` | |
| 261 | that starts it. | |
| 262 | ||
| 263 | ### Paths | |
| 264 | ||
| 265 | Defaults, resolved the same way by `signald`, `terminal-garden`, and | |
| 266 | `shell-hooks/signald-hooks.zsh`. This table is the single place they are | |
| 267 | written down; unit tests in both binaries pin the code to it. | |
| 268 | ||
| 269 | | | `$XDG_RUNTIME_DIR` set | otherwise | | |
| 270 | |---|---|---| | |
| 271 | | socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` | | |
| 272 | | history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` | | |
| 273 | | terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` | | |
| 274 | ||
| 275 | The db and the spool are derived from the socket's directory, so `--socket` | |
| 276 | moves all three together. Override individually with `--db` and `--spool`, and | |
| 277 | the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever | |
| 278 | the daemon uses, or the terminal metrics stay silently empty. launchd sets no | |
| 279 | `XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`. | |
| 280 | ||
| 281 | The repository list is `$XDG_CONFIG_HOME/signald/repos`, else | |
| 282 | `~/.config/signald/repos`. Positional arguments to `signald` override it; with | |
| 283 | neither, the working directory is watched. | |
| 284 | ||
| 285 | Unix socket paths are limited to about 104 bytes. A deep scratch directory | |
| 286 | will hit `SUN_LEN`; use `mktemp -d` when testing by hand. | |
| 287 | ||
| 227 | 288 | ## Build & test |
| 228 | 289 | |
| 229 | 290 | ```sh |
crates/signald/Cargo.toml +1 −1
| @@ -11,7 +11,7 @@ path = "src/main.rs" | ||
| 11 | 11 | |
| 12 | 12 | [dependencies] |
| 13 | 13 | signal-schema = { path = "../signal-schema" } |
| 14 | # History store (spec §1.3): SQLite in WAL mode. `bundled` compiles SQLite in- | |
| 14 | # History store: SQLite in WAL mode. `bundled` compiles SQLite in- | |
| 15 | 15 | # tree so there is no system-library dependency. This is the one new dependency |
| 16 | 16 | # added in v0.2; signal-schema stays dependency-free by design. |
| 17 | 17 | rusqlite = { version = "0.40", features = ["bundled"] } |
crates/signald/src/main.rs +125 −6
| @@ -26,7 +26,9 @@ | ||
| 26 | 26 | //! [--collector <path>] [--interval-ms <n>] [--retention-days <n>] |
| 27 | 27 | //! [<repo-path> ...] |
| 28 | 28 | //! ``` |
| 29 | //! With no repo paths, the current directory is watched. The socket defaults to | |
| 29 | //! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else | |
| 30 | //! `~/.config/signald/repos`) is read — one path per line, `#` comments | |
| 31 | //! ignored — and failing that the current directory is watched. The socket defaults to | |
| 30 | 32 | //! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the |
| 31 | 33 | //! history db and terminal spool default alongside it. `--collector` names the |
| 32 | 34 | //! `macos-collector` binary; by default it is looked up on `PATH` and skipped, |
| @@ -140,6 +142,9 @@ fn parse_args() -> Config { | ||
| 140 | 142 | } |
| 141 | 143 | } |
| 142 | 144 | |
| 145 | if repos.is_empty() { | |
| 146 | repos = repos_from_config_file(); | |
| 147 | } | |
| 143 | 148 | if repos.is_empty() { |
| 144 | 149 | repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); |
| 145 | 150 | } |
| @@ -156,13 +161,65 @@ fn parse_args() -> Config { | ||
| 156 | 161 | } |
| 157 | 162 | } |
| 158 | 163 | |
| 159 | /// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`. | |
| 160 | fn default_socket_path() -> PathBuf { | |
| 161 | if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { | |
| 164 | /// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`. | |
| 165 | /// | |
| 166 | /// Split from the environment so it can be tested without mutating it. The | |
| 167 | /// same resolution is duplicated in the other binary and in | |
| 168 | /// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one | |
| 169 | /// place they are written down, and the tests below pin them to it. | |
| 170 | fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf { | |
| 171 | if let Some(dir) = xdg_runtime_dir { | |
| 162 | 172 | return PathBuf::from(dir).join("signald.sock"); |
| 163 | 173 | } |
| 164 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | |
| 165 | PathBuf::from(home).join(".local/state/signald/sock") | |
| 174 | PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock") | |
| 175 | } | |
| 176 | ||
| 177 | fn default_socket_path() -> PathBuf { | |
| 178 | socket_path_from( | |
| 179 | std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), | |
| 180 | std::env::var("HOME").ok().as_deref(), | |
| 181 | ) | |
| 182 | } | |
| 183 | ||
| 184 | /// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`. | |
| 185 | fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf { | |
| 186 | match xdg_config_home { | |
| 187 | Some(dir) => PathBuf::from(dir).join("signald/repos"), | |
| 188 | None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"), | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | /// One repository path per line. Blank lines and `#` comments are ignored. | |
| 193 | fn parse_repos_file(contents: &str) -> Vec<PathBuf> { | |
| 194 | contents | |
| 195 | .lines() | |
| 196 | .map(|l| l.trim()) | |
| 197 | .filter(|l| !l.is_empty() && !l.starts_with('#')) | |
| 198 | .map(PathBuf::from) | |
| 199 | .collect() | |
| 200 | } | |
| 201 | ||
| 202 | /// Repositories to watch when none were given on the command line. | |
| 203 | /// | |
| 204 | /// A login agent has no useful working directory — launchd starts it in `/` — | |
| 205 | /// so falling straight through to the cwd would leave `brew services start | |
| 206 | /// signald` collecting no git signals at all. A missing file is not an error; | |
| 207 | /// the cwd fallback still applies. | |
| 208 | fn repos_from_config_file() -> Vec<PathBuf> { | |
| 209 | let path = repos_config_path( | |
| 210 | std::env::var("XDG_CONFIG_HOME").ok().as_deref(), | |
| 211 | std::env::var("HOME").ok().as_deref(), | |
| 212 | ); | |
| 213 | match std::fs::read_to_string(&path) { | |
| 214 | Ok(contents) => { | |
| 215 | let repos = parse_repos_file(&contents); | |
| 216 | if !repos.is_empty() { | |
| 217 | eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display()); | |
| 218 | } | |
| 219 | repos | |
| 220 | } | |
| 221 | Err(_) => Vec::new(), | |
| 222 | } | |
| 166 | 223 | } |
| 167 | 224 | |
| 168 | 225 | /// Log enabled collectors and assert none holds an input-tap capability. A real |
| @@ -188,3 +245,65 @@ fn print_self_attestation(cfg: &Config) { | ||
| 188 | 245 | eprintln!(" collector {source:?}: input-tap capability = NONE — {state}"); |
| 189 | 246 | } |
| 190 | 247 | } |
| 248 | ||
| 249 | #[cfg(test)] | |
| 250 | mod path_tests { | |
| 251 | use super::*; | |
| 252 | ||
| 253 | /// Pinned to the README "Paths" table. If this changes, the table and | |
| 254 | /// `shell-hooks/signald-hooks.zsh` change with it. | |
| 255 | #[test] | |
| 256 | fn socket_default_follows_xdg_then_home() { | |
| 257 | assert_eq!( | |
| 258 | socket_path_from(Some("/run/user/501"), Some("/Users/x")), | |
| 259 | PathBuf::from("/run/user/501/signald.sock") | |
| 260 | ); | |
| 261 | assert_eq!( | |
| 262 | socket_path_from(None, Some("/Users/x")), | |
| 263 | PathBuf::from("/Users/x/.local/state/signald/sock") | |
| 264 | ); | |
| 265 | } | |
| 266 | ||
| 267 | #[test] | |
| 268 | fn repos_file_skips_blanks_and_comments() { | |
| 269 | let repos = parse_repos_file( | |
| 270 | "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n", | |
| 271 | ); | |
| 272 | assert_eq!( | |
| 273 | repos, | |
| 274 | vec![ | |
| 275 | PathBuf::from("/Users/x/git/one"), | |
| 276 | PathBuf::from("/Users/x/git/two") | |
| 277 | ] | |
| 278 | ); | |
| 279 | } | |
| 280 | ||
| 281 | #[test] | |
| 282 | fn repos_config_follows_xdg_then_home() { | |
| 283 | assert_eq!( | |
| 284 | repos_config_path(Some("/Users/x/.config"), Some("/Users/x")), | |
| 285 | PathBuf::from("/Users/x/.config/signald/repos") | |
| 286 | ); | |
| 287 | assert_eq!( | |
| 288 | repos_config_path(None, Some("/Users/x")), | |
| 289 | PathBuf::from("/Users/x/.config/signald/repos") | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | /// The db and the spool are derived from the socket's directory, so all | |
| 294 | /// three move together when --socket is given. | |
| 295 | #[test] | |
| 296 | fn db_and_spool_sit_beside_the_socket() { | |
| 297 | let socket = socket_path_from(None, Some("/Users/x")); | |
| 298 | let base = socket.parent().unwrap(); | |
| 299 | assert_eq!( | |
| 300 | base.join("signald.sqlite"), | |
| 301 | PathBuf::from("/Users/x/.local/state/signald/signald.sqlite") | |
| 302 | ); | |
| 303 | assert_eq!( | |
| 304 | base.join("terminal.spool"), | |
| 305 | PathBuf::from("/Users/x/.local/state/signald/terminal.spool"), | |
| 306 | "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh" | |
| 307 | ); | |
| 308 | } | |
| 309 | } | |
crates/terminal-garden/Cargo.toml +1 −1
| @@ -3,7 +3,7 @@ name = "terminal-garden" | ||
| 3 | 3 | version.workspace = true |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | license.workspace = true |
| 6 | description = "First renderer (spec build order §3): a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor." | |
| 6 | description = "First renderer: a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor." | |
| 7 | 7 | |
| 8 | 8 | [[bin]] |
| 9 | 9 | name = "terminal-garden" |
crates/terminal-garden/src/main.rs +35 −4
| @@ -88,10 +88,41 @@ fn parse_socket() -> PathBuf { | ||
| 88 | 88 | default_socket_path() |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | fn default_socket_path() -> PathBuf { | |
| 92 | if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { | |
| 91 | /// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`. | |
| 92 | /// | |
| 93 | /// Split from the environment so it can be tested without mutating it. The | |
| 94 | /// same resolution is duplicated in the other binary and in | |
| 95 | /// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one | |
| 96 | /// place they are written down, and the tests below pin them to it. | |
| 97 | fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf { | |
| 98 | if let Some(dir) = xdg_runtime_dir { | |
| 93 | 99 | return PathBuf::from(dir).join("signald.sock"); |
| 94 | 100 | } |
| 95 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | |
| 96 | PathBuf::from(home).join(".local/state/signald/sock") | |
| 101 | PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock") | |
| 102 | } | |
| 103 | ||
| 104 | fn default_socket_path() -> PathBuf { | |
| 105 | socket_path_from( | |
| 106 | std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), | |
| 107 | std::env::var("HOME").ok().as_deref(), | |
| 108 | ) | |
| 109 | } | |
| 110 | ||
| 111 | #[cfg(test)] | |
| 112 | mod path_tests { | |
| 113 | use super::*; | |
| 114 | ||
| 115 | /// Pinned to the README "Paths" table. If this changes, the table and | |
| 116 | /// `shell-hooks/signald-hooks.zsh` change with it. | |
| 117 | #[test] | |
| 118 | fn socket_default_follows_xdg_then_home() { | |
| 119 | assert_eq!( | |
| 120 | socket_path_from(Some("/run/user/501"), Some("/Users/x")), | |
| 121 | PathBuf::from("/run/user/501/signald.sock") | |
| 122 | ); | |
| 123 | assert_eq!( | |
| 124 | socket_path_from(None, Some("/Users/x")), | |
| 125 | PathBuf::from("/Users/x/.local/state/signald/sock") | |
| 126 | ); | |
| 127 | } | |
| 97 | 128 | } |
packaging/net.krz.signald.plist added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <!-- | |
| 3 | User LaunchAgent for signald. | |
| 4 | ||
| 5 | This is the template for a manual install. `brew services start signald` | |
| 6 | does not use it — the formula's own service block generates an equivalent | |
| 7 | plist under Homebrew's control. | |
| 8 | ||
| 9 | To install by hand: | |
| 10 | ||
| 11 | mkdir -p ~/Library/Logs/signald ~/Library/LaunchAgents | |
| 12 | sed -e "s|__HOME__|$HOME|g" -e "s|__PREFIX__|$(brew --prefix)|g" \ | |
| 13 | packaging/net.krz.signald.plist > ~/Library/LaunchAgents/net.krz.signald.plist | |
| 14 | launchctl load ~/Library/LaunchAgents/net.krz.signald.plist | |
| 15 | ||
| 16 | Edit ProgramArguments to name the repositories to watch: signald takes them | |
| 17 | as positional paths, and with none it watches the working directory, which | |
| 18 | is not useful for a login agent. Create the log directory first — launchd | |
| 19 | creates the log files but not the directory holding them. | |
| 20 | --> | |
| 21 | <plist version="1.0"> | |
| 22 | <dict> | |
| 23 | <key>Label</key> | |
| 24 | <string>net.krz.signald</string> | |
| 25 | ||
| 26 | <key>ProgramArguments</key> | |
| 27 | <array> | |
| 28 | <string>__PREFIX__/bin/signald</string> | |
| 29 | <!-- The hardware collector runs as a child of signald. Naming it here | |
| 30 | avoids depending on launchd's PATH, which is not the shell's. --> | |
| 31 | <string>--collector</string> | |
| 32 | <string>__PREFIX__/bin/macos-collector</string> | |
| 33 | <!-- Repositories to watch. Replace with your own; add one string per repo. --> | |
| 34 | <string>__HOME__/git</string> | |
| 35 | </array> | |
| 36 | ||
| 37 | <key>RunAtLoad</key> | |
| 38 | <true/> | |
| 39 | <key>KeepAlive</key> | |
| 40 | <dict> | |
| 41 | <key>SuccessfulExit</key> | |
| 42 | <false/> | |
| 43 | </dict> | |
| 44 | ||
| 45 | <key>StandardOutPath</key> | |
| 46 | <string>__HOME__/Library/Logs/signald/signald.log</string> | |
| 47 | <key>StandardErrorPath</key> | |
| 48 | <string>__HOME__/Library/Logs/signald/signald.err.log</string> | |
| 49 | ||
| 50 | <!-- signald resolves its socket, db and spool under $XDG_RUNTIME_DIR when | |
| 51 | set and ~/.local/state/signald otherwise. launchd sets neither, so the | |
| 52 | agent lands on the ~/.local/state/signald defaults the zsh hook also | |
| 53 | uses. Keep it that way: a spool the daemon and the hook disagree about | |
| 54 | is a silently empty terminal metric. --> | |
| 55 | <key>EnvironmentVariables</key> | |
| 56 | <dict> | |
| 57 | <key>HOME</key> | |
| 58 | <string>__HOME__</string> | |
| 59 | </dict> | |
| 60 | ||
| 61 | <key>ProcessType</key> | |
| 62 | <string>Background</string> | |
| 63 | </dict> | |
| 64 | </plist> | |