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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,36 @@ self-updates from GitHub Releases.
- **Distribution:** prebuilt binaries in `dist/` (for `install.sh`) and
GitHub Releases (for `gander --upgrade`).
- **Source layout:**
| File | Purpose |
| --------------------- | -------------------------------------------------------- |
| `main.go` | CLI parsing, flag dispatch, entrypoint glue |
| `config.go` | `~/.gander` JSON loader and defaults; honors `GANDER_CONFIG=<name>` to use `~/.gander.<name>` instead (full isolation from the prod profile, with path-traversal guard) |
| `render.go` | Markdown → HTML, page builder, CSS, TOC + reload JS |
| `watch.go` | HTTP server, SSE hub, fsnotify watcher, debounced reload |
| `share.go` | `gander share [--watch]` upload + push-to-gandermd loop |
| `api.go` | gandermd HTTP client (signup, share CRUD, manage intent) |
| File | Purpose |
| ----------------------------- | -------------------------------------------------------- |
| `main.go` | CLI parsing, flag dispatch, entrypoint glue |
| `config.go` | Profile dir `~/.gander` (or `~/.gander.<name>` via `GANDER_CONFIG`); JSON at `config.json`. Migrates a legacy file at that path into the directory. Path-traversal guard on profile names. |
| `render.go` | Markdown → HTML, page builder, CSS, TOC + reload JS |
| `watch.go` | Per-watch HTTP+SSE server and fsnotify loop (also reused by the runner via `serveWatchForever`) |
| `share.go` | `gander share [--watch]` upload + push-to-gandermd loop (also reused by the runner via `serveShareWatcher`) |
| `runner.go` | Daemon lifecycle (`gander _serve`): flock, UDS, PID, signal handling, hand-off from CLI |
| `runner_ipc.go` | UDS server + client + line-delimited JSON protocol |
| `runner_ipc_linux.go` | `SO_PEERCRED` peer-UID check |
| `runner_ipc_darwin.go` | macOS UDS peer check (file-mode only — see comment) |
| `runner_watch.go` | `watchManager`: register/stop/list, `watches.json` persistence (chmod 0600, atomic rename, refuses to load wider modes) |
| `runner_http.go` | Daemon's single HTTP server on `127.0.0.1:7821`; `/w/<id>?t=…` and `/healthz` token-gated via `crypto/subtle.ConstantTimeCompare` |
| `runner_install.go` | LaunchAgent (macOS) / systemd user unit (Linux) rendering + idempotent `launchctl load` / `systemctl enable --now` |
| `runner_cmd.go` | `gander runner {install\|uninstall}` CLI subcommand |
| `status.go` | `gander status` — list runner + watches via IPC |
| `stop.go` | `gander stop [<file>\|<id>] [--all]` |
| `logs.go` | `gander logs [<id>] [--follow\|--no-follow]` tail file |
| `api.go` | gandermd HTTP client (signup, share CRUD, manage intent) |
| `signup.go`/`auth.go`/`list.go`/`remove.go`/`manage.go` | gandermd account subcommands |
| `upgrade.go` | `--upgrade` self-update via GitHub Releases API |
| `completion.go` | `gander completion {bash\|zsh}` |
| `*_test.go` | Unit tests |
| `install.sh` | Installer: downloads the latest release, source fallback |
| `scripts/release.sh` | Release automation (see below) |
| `.github/workflows/` | CI (currently just `release.yml`) |
| `upgrade.go` | `--upgrade` self-update via GitHub Releases API; coordinates with the runner (shutdown over UDS, replace binary, supervisor restarts under the new code) |
| `completion.go` | `gander completion {bash\|zsh}` |
| `*_test.go` | Unit tests |
| `install.sh` | Installer: downloads the latest release, source fallback |
| `scripts/release.sh` | Release automation (see below) |
| `plans/` | Agent-authored plans (untracked; not part of releases) |
| `.github/workflows/` | CI (currently just `release.yml`) |

- **Runner architecture (Phases 0–6 of `plans/2026-08-26-persistent-runner-process-for-gander.md`):**
`gander --watch <file>` hands the file off to a long-lived daemon (`gander _serve`) and exits. The daemon owns the HTTP server on `127.0.0.1:7821`, the fsnotify loop, and `~/.gander/watches.json` (chmod 0600, atomic temp-rename writes, refuses to load with broader modes). Watches survive the CLI's lifetime and reboots — the daemon is auto-launched at login by `gander runner install` (LaunchAgent on macOS, `systemctl --user` on Linux). The CLI surfaces are `gander status`, `gander stop`, `gander logs`, `gander runner {install|uninstall}`; the old blocking behavior is preserved behind `gander --watch --foreground`. Security defaults: UDS gated by `~/.gander/runner.sock` mode 0600 inside `~/.gander` mode 0700, plus `SO_PEERCRED` peer-UID on Linux; per-watch and daemon tokens (32 hex from `crypto/rand`) compared with `crypto/subtle.ConstantTimeCompare`; `watches.json` mode at most 0600 — the daemon refuses to start if it's wider. `gander --upgrade` reads `~/.gander/runner.pid`, verifies the recorded PID is still a live same-user process, sends `{"op":"shutdown"}` over UDS, replaces the binary, and lets the supervisor (or `ensureRunner` when unsupervised) bring the new binary back up — `watches.json` is reloaded by the new process so the upgrade is invisible to open viewers.

### Using gander from an agent

Expand Down
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,25 @@ gander -outfile readme.html README.md
gander --watch README.md
```

Especially handy when an agent is writing the file — open the preview once and watch it grow without leaving your browser. On every save the rendered HTML hot-swaps in place and the TOC rebuilds; scroll position is preserved. Press `Ctrl+C` to stop.
`gander --watch` no longer blocks your terminal: the CLI hands the watch off to a long-lived runner daemon (`gander _serve`) and exits. The daemon owns the HTTP server, the fsnotify loop, and the reload. On every save the rendered HTML hot-swaps in place and the TOC rebuilds; scroll position is preserved.

A local HTTP server is started on a random free port (printed in the output) so the browser can receive change notifications over Server-Sent Events.
Open the URL once; it stays valid across edits, restarts of the runner, and reboots (the daemon re-loads its watch list from `~/.gander/watches.json`).

```bash
gander status # list active watches + URLs
gander stop README.md # remove one watch
gander stop --all # remove everything
gander logs <watch-id> # tail reload events for one watch
gander logs # tail the runner's log
```

The first `--watch` installs a per-user supervisor unit (LaunchAgent on macOS, `systemctl --user` on Linux) so the daemon auto-starts at login. To opt out: `gander runner uninstall`. To re-install: `gander runner install`.

If you want the old blocking CLI back (Ctrl+C to stop): `gander --watch --foreground README.md`. The daemon is untouched; the foreground process owns a transient server instead.

For other local users on the same machine, the URL is gated by a per-watch unguessable token (`?t=…`). The token survives restarts because it's stored in `~/.gander/watches.json` (mode 0600). Same-user access only — the daemon's UDS is chmod 0600 inside a 0700 directory, and on Linux the daemon additionally checks `SO_PEERCRED` against the connecting UID.

A local HTTP server is started on `127.0.0.1:7821` so the browser can receive change notifications over Server-Sent Events; multiple watches share the port under `/w/<id>`.

> `--watch` and `-outfile` cannot be combined.

Expand Down Expand Up @@ -205,11 +221,11 @@ CLI flags always override the config. Pass `--watch=false` (or any explicit valu

#### Profiles (`GANDER_CONFIG`)

For pointing a single checkout at a different gandermd instance (local dev, staging, a self-hosted deployment) without disturbing your prod `~/.gander`, set `GANDER_CONFIG=<name>`. The CLI then reads and writes `~/.gander.<name>` instead — fully isolated from the default profile.
For pointing a single checkout at a different gandermd instance (local dev, staging, a self-hosted deployment) without disturbing your prod `~/.gander`, set `GANDER_CONFIG=<name>`. The CLI then uses `~/.gander.<name>/` (config at `config.json`, runner socket and watches alongside) — fully isolated from the default profile. A legacy JSON file at `~/.gander.<name>` is migrated into that directory on first write or `--watch`.

```sh
GANDER_CONFIG=dev gander signup --email dev@example.com # writes ~/.gander.dev
GANDER_CONFIG=staging gander list # writes ~/.gander.staging
GANDER_CONFIG=dev gander signup --email dev@example.com # writes ~/.gander.dev/config.json
GANDER_CONFIG=staging gander list # reads ~/.gander.staging/config.json
```

The legacy `~/.mdp` fallback only applies when `GANDER_CONFIG` is unset; named profiles never fall back to `.mdp`. Profile names must be a single path component (no `/`, `\`, `.`, or `..`).
Expand All @@ -220,9 +236,13 @@ The legacy `~/.mdp` fallback only applies when `GANDER_CONFIG` is unset; named p
-outfile string
Optional: write HTML output to a file instead of opening it in the browser
-watch
Watch the file for changes and live-reload the browser preview
Hand the file off to the long-lived runner and live-reload the browser preview.
The CLI exits; the daemon owns the watch. Use --foreground for the old
blocking behavior.
-upgrade
Download and install the latest release, then exit
Download and install the latest release, then exit. The runner is shut
down over UDS first, the binary is replaced, then the supervisor (or a
fresh spawn) brings the upgraded daemon back up with the same watches.
```

Subcommands:
Expand All @@ -231,6 +251,10 @@ Subcommands:
gander signup --email <addr> Open the signup form in your browser, save the API token
gander share [--watch] <file> Upload to gander.md and open the share link
gander watch <file> Live-share to gander.md and push every save (alias for share --watch)
gander status Show runner + active watches + URLs
gander stop [<file>|<id>] [--all] Stop a watch (by file, id, or --all)
gander logs [<id>] Tail the runner log (optionally filtered by watch id)
gander runner install|uninstall Auto-start the runner at login via LaunchAgent/systemd
gander remove [--all] [<file>] Delete a share from gander.md
gander list List shares currently on gander.md
gander manage Open the dashboard in your browser
Expand All @@ -239,7 +263,7 @@ gander --version Print the version and exit
gander completion {bash|zsh} Print a shell completion script
```

The subcommands appear in help only after a successful `gander signup` (except `completion`, which is always available).
The gandermd-bound subcommands appear in help only after a successful `gander signup` (except `completion`, which is always available). The runner-managed subcommands (`status`, `stop`, `logs`, `runner`) are always listed.

## Releasing

Expand Down Expand Up @@ -296,4 +320,4 @@ MIT License — see [LICENSE](LICENSE) for details.
- **HTML sanitization**: uses [bluemonday](https://github.com/microcosm-cc/bluemonday) for security
- **File watching**: uses [fsnotify](https://github.com/fsnotify/fsnotify) when running with `--watch`

Without `--watch`, the CLI is fire-and-forget: it renders once, opens the result in your browser, and exits. With `--watch`, it starts a tiny localhost HTTP server and pushes hot-swaps over Server-Sent Events on every save.
Without `--watch`, the CLI is fire-and-forget: it renders once, opens the result in your browser, and exits. With `--watch`, the CLI hands off to a long-lived runner (`gander _serve`, hidden subcommand) that owns the HTTP server on `127.0.0.1:7821` and pushes hot-swaps over Server-Sent Events on every save; the CLI exits cleanly while the daemon keeps the watch alive through reboots (a LaunchAgent on macOS / `systemctl --user` unit on Linux re-launches it on login). All state lives in `~/.gander/watches.json` (mode 0600): the daemon URL under `/w/<id>` carries an unguessable per-watch token, and the daemon's Unix-domain control socket is gated by file mode (and on Linux additionally by `SO_PEERCRED`).
4 changes: 2 additions & 2 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func TestShareWatchFlowEndToEnd(t *testing.T) {

done := make(chan error, 1)
go func() {
done <- runShareWithCtx(ctx, []string{"--watch", mdFile})
done <- runShareWithCtx(ctx, []string{"--watch", "--foreground", mdFile})
}()

time.Sleep(500 * time.Millisecond)
Expand Down Expand Up @@ -236,7 +236,7 @@ func TestWatchCmdEqualsShareWatch(t *testing.T) {

done := make(chan error, 1)
go func() {
done <- runWatchCmdWithCtx(ctx, []string{mdFile})
done <- runWatchCmdWithCtx(ctx, []string{"--foreground", mdFile})
}()

deadline := time.Now().Add(5 * time.Second)
Expand Down
25 changes: 25 additions & 0 deletions completions/_gander
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ _gander() {
'watch:Live-share to gander.md and push every save (alias for share --watch)'
'remove:Delete a share from gander.md'
'list:List shares currently on gander.md'
'status:Show the runner + active watches + URLs'
'stop:Stop a watch (by file, id, or --all)'
'logs:Tail the runner log (optionally filtered by watch id)'
'runner:Install or uninstall the OS-level auto-start unit'
'manage:Open the dashboard in your browser'
'auth:Install a new API token (validate against /api/shares first)'
'completion:Print a shell completion script'
Expand All @@ -36,6 +40,7 @@ _gander() {
;;
watch)
_arguments \
'--foreground[Run the foreground blocking watcher instead of handing off to the runner]' \
'*:markdown file:_files -g "*.md"'
;;
remove)
Expand All @@ -46,6 +51,26 @@ _gander() {
'--non-interactive[Disallow prompts; require --all or --pick]' \
'*:file or short_id:_files'
;;
status)
_arguments
;;
stop)
_arguments \
'--all[Stop every active watch]' \
'--yes[Skip the confirmation prompt]' \
'--non-interactive[Refuse prompts; require --all]' \
'1:file or short_id:_files'
;;
logs)
_arguments \
'--follow[Keep tailing for new log lines (default)]' \
'--no-follow[Print existing log lines and exit]' \
'1:watch id:_files'
;;
runner)
_arguments \
'1:action:(install uninstall)'
;;
list)
_arguments
;;
Expand Down
22 changes: 20 additions & 2 deletions completions/gander.bash
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ _gander_completions() {
prev="${COMP_WORDS[COMP_CWORD-1]}"

if [[ ${COMP_CWORD} -eq 1 ]]; then
cmds="signup share watch remove list manage auth completion --upgrade upgrade --help -h -help help"
cmds="signup share watch remove list status stop logs runner manage auth completion --upgrade upgrade --help -h -help help"
COMPREPLY=( $(compgen -W "${cmds}" -- "${cur}") )
return 0
fi
Expand All @@ -23,13 +23,31 @@ _gander_completions() {
return 0
;;
watch)
COMPREPLY=( $(compgen -f -- "${cur}") )
COMPREPLY=( $(compgen -W "--foreground" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
remove)
COMPREPLY=( $(compgen -W "--all --pick --yes --non-interactive" -- "${cur}") )
return 0
;;
status)
COMPREPLY=()
return 0
;;
stop)
COMPREPLY=( $(compgen -W "--all --yes --non-interactive" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
logs)
COMPREPLY=( $(compgen -W "--follow --no-follow" -- "${cur}") )
return 0
;;
runner)
COMPREPLY=( $(compgen -W "install uninstall" -- "${cur}") )
return 0
;;
completion)
COMPREPLY=( $(compgen -W "bash zsh" -- "${cur}") )
return 0
Expand Down
Loading