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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ self-updates from `https://release.gander.md` (GitHub Releases fallback).
| `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_dir.go` | Directory watches (`dir-share` / `dir-local`): glob/ignore, debounce, rate limit, CreateShare + child file register |
| `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 |
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ gander watch README.md # upload + live-update the remote viewer
gander watch README.md --silent # same watch, print URL, skip the browser
gander share README.md --watch # same as `watch`, spelled out
gander share --watch --silent README.md # same as `watch --silent`
gander watch ~/reports # adopt new .md under the folder; no extra browser tabs
gander watch ~/reports --existing # also onboard unmatched files already in the folder
gander --watch ~/notes --no-recursive # local previews for new top-level .md only
gander share README.md --comments anyone # public review comments (opt-in; default is private)
gander share README.md --no-comments # hide viewer threads (comment_access=disabled)
gander share README.md --private # only the author and invited team can read the doc
Expand Down Expand Up @@ -340,10 +343,21 @@ The legacy `~/.mdp` fallback only applies when `GANDER_CONFIG` is unset; named p
-watch
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.
blocking behavior. A directory path registers a directory watch: new
matching `.md` files are adopted automatically and the browser is not opened.
-silent
Do not open a browser after rendering, sharing, or starting a watch.
The URL is still printed. Does not change visibility or commenting.
Directory-adopted files never open a browser, with or without this flag.
-existing
With `--watch` on a directory, also onboard unmatched `.md` files already
in the folder. More than 50 files requires `--yes`.
-no-recursive
With `--watch` on a directory, watch only that folder, not subdirectories.
-glob string
With `--watch` on a directory, filename glob (default `**/*.md`).
-yes
With `--watch --existing` on a directory, confirm onboarding more than 50 files.
-upgrade
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
Expand All @@ -355,8 +369,8 @@ Subcommands:

```
gander signup --email <addr> Open the signup form in your browser, save the API token
gander share [--watch] [--silent] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file>
gander watch [--silent] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file>
gander share [--watch] [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file|dir>
gander watch [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file|dir>
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)
Expand Down
4 changes: 2 additions & 2 deletions completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestRunCompletionBashNonEmpty(t *testing.T) {
if out == "" {
t.Fatal("bash completion is empty")
}
for _, want := range []string{"complete -F", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent"} {
for _, want := range []string{"complete -F", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent", "--existing", "--no-recursive", "--glob"} {
if !strings.Contains(out, want) {
t.Errorf("bash completion missing %q\n%s", want, out)
}
Expand Down Expand Up @@ -72,7 +72,7 @@ func TestRunCompletionZshNonEmpty(t *testing.T) {
if out == "" {
t.Fatal("zsh completion is empty")
}
for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent"} {
for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent", "--existing", "--no-recursive", "--glob"} {
if !strings.Contains(out, want) {
t.Errorf("zsh completion missing %q\n%s", want, out)
}
Expand Down
20 changes: 16 additions & 4 deletions completions/_gander
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ _gander() {
commands=(
'signup:Open signup form in your browser, save the API token'
'share:Upload Markdown to gander.md and open the share link'
'watch:Live-share to gander.md and push every save (alias for share --watch)'
'watch:Live-share a file or adopt new markdown in a directory'
'remove:Delete a share from gander.md'
'list:List shares currently on gander.md'
'invite:Mint a team invite link'
Expand Down Expand Up @@ -46,21 +46,29 @@ _gander() {
'--watch[Live-update the shared page as the file changes]' \
'--foreground[Keep share --watch in-process instead of handing off to the runner]' \
'--silent[Create or refresh without opening a browser]' \
'--existing[With a directory, also onboard unmatched .md files already in the folder]' \
'--no-recursive[With a directory, do not watch subdirectories]' \
'--glob[With a directory, filename glob]:pattern:' \
'--yes[Confirm onboarding more than 50 existing files]' \
'--visibility[Who may see the markdown]:visibility:(anyone private hidden)' \
'--private[Make the document private (alias for --visibility private)]' \
'--comments[Who may comment]:comments:(anyone private disabled)' \
'--no-comments[Turn off commenting (alias for --comments disabled)]' \
'*:markdown file:_files -g "*.md"'
'*:markdown file or directory:_files'
;;
watch)
_arguments \
'--foreground[Run the foreground blocking watcher instead of handing off to the runner]' \
'--silent[Create or refresh without opening a browser]' \
'--existing[With a directory, also onboard unmatched .md files already in the folder]' \
'--no-recursive[With a directory, do not watch subdirectories]' \
'--glob[With a directory, filename glob]:pattern:' \
'--yes[Confirm onboarding more than 50 existing files]' \
'--visibility[Who may see the markdown]:visibility:(anyone private hidden)' \
'--private[Make the document private (alias for --visibility private)]' \
'--comments[Who may comment]:comments:(anyone private disabled)' \
'--no-comments[Turn off commenting (alias for --comments disabled)]' \
'*:markdown file:_files -g "*.md"'
'*:markdown file or directory:_files'
;;
remove)
_arguments \
Expand Down Expand Up @@ -131,9 +139,13 @@ _gander() {
_arguments \
'-watch[Live-reload the browser preview]' \
'--silent[Do not open a browser; still print the preview URL]' \
'--existing[With --watch on a directory, also onboard unmatched .md already in the folder]' \
'--no-recursive[With --watch on a directory, do not watch subdirectories]' \
'--glob[With --watch on a directory, filename glob]:pattern:' \
'--yes[With --watch --existing, confirm more than 50 files]' \
'-outfile[Write HTML to a file]:file:_files -g "*.html"' \
'--help[Show help]' \
'*:markdown file:_files -g "*.md"'
'*:markdown file or directory:_files'
;;
esac
}
Expand Down
6 changes: 3 additions & 3 deletions completions/gander.bash
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ _gander_completions() {
return 0
;;
esac
COMPREPLY=( $(compgen -W "--watch --foreground --silent --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY=( $(compgen -W "--watch --foreground --silent --existing --no-recursive --glob --yes --visibility --comments --private --no-comments" -- "${cur}") )
return 0
;;
watch)
Expand All @@ -43,7 +43,7 @@ _gander_completions() {
return 0
;;
esac
COMPREPLY=( $(compgen -W "--foreground --silent --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY=( $(compgen -W "--foreground --silent --existing --no-recursive --glob --yes --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
Expand Down Expand Up @@ -101,7 +101,7 @@ _gander_completions() {
return 0
;;
*)
COMPREPLY=( $(compgen -W "--watch --foreground --silent --outfile --help" -- "${cur}") )
COMPREPLY=( $(compgen -W "--watch --foreground --silent --existing --no-recursive --glob --yes --outfile --help" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
Expand Down
58 changes: 53 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ func main() {
watch := flag.Bool("watch", false, "Watch the file for changes and live-reload the browser preview")
foreground := flag.Bool("foreground", false, "With --watch, run the blocking watcher in-process (no runner handoff)")
silent := flag.Bool("silent", false, "Do not open a browser; still print the preview URL")
existing := flag.Bool("existing", false, "With --watch on a directory, also onboard unmatched .md files already in the folder")
noRecursive := flag.Bool("no-recursive", false, "With --watch on a directory, do not watch subdirectories")
glob := flag.String("glob", "", "With --watch on a directory, filename glob (default **/*.md)")
yes := flag.Bool("yes", false, "With --watch --existing on a directory, confirm more than 50 files")
upgrade := flag.Bool("upgrade", false, "Download and install the latest release, then exit")
flag.Parse()

Expand Down Expand Up @@ -190,6 +194,44 @@ func main() {
useWatch = cfg.Watch
}

dirFlags := *existing || *noRecursive || *glob != "" || *yes
if fi, statErr := os.Stat(absPath); statErr == nil && fi.IsDir() {
if *outFile != "" {
fmt.Fprintln(os.Stderr, "error: -outfile cannot be used with a directory")
os.Exit(1)
}
if !useWatch {
fmt.Fprintf(os.Stderr, "error: %s is a directory; pass --watch to adopt new markdown files\n", absPath)
os.Exit(1)
}
if *foreground {
fmt.Fprintln(os.Stderr, "error: directory watches require the runner; drop --foreground")
os.Exit(1)
}
if *glob != "" {
if _, err := filepath.Match(*glob, "x"); err != nil {
log.Fatalf("invalid --glob: %v", err)
}
}
canonical, err := canonicalPath(absPath)
if err != nil {
log.Fatalf("watch: %v", err)
}
if err := handOffWatchDir(canonical, string(modeDirLocal), dirWatchOpts{
Recursive: !*noRecursive,
Glob: *glob,
Existing: *existing,
Yes: *yes,
}); err != nil {
log.Fatalf("watch: %v", err)
}
return
}
if dirFlags {
fmt.Fprintln(os.Stderr, "error: --existing, --no-recursive, --glob, and --yes only apply to directories")
os.Exit(1)
}

if useWatch {
if *foreground {
if err := runWatch(absPath, cfg, *silent); err != nil {
Expand Down Expand Up @@ -273,14 +315,16 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, "gander — render Markdown, optionally share it on gander.md")
fmt.Fprintln(w)
fmt.Fprintln(w, "Usage:")
fmt.Fprintln(w, " gander <file.md> [options] Render and open locally")
fmt.Fprintln(w, " gander <file.md|dir> [options] Render and open locally (--watch a dir adopts new .md)")
fmt.Fprintln(w, " gander signup --email <addr> Open signup form in your browser, save the API token")
if authed {
fmt.Fprintln(w, " gander share [--watch] [--silent] [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file>")
fmt.Fprintln(w, " gander share [--watch] [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes]")
fmt.Fprintln(w, " [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file|dir>")
fmt.Fprintln(w, " Upload to gander.md (keeps a dashboard share of the same file)")
fmt.Fprintln(w, " gander watch [--silent] [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file>")
fmt.Fprintln(w, " gander watch [--silent] [--existing] [--no-recursive] [--glob=pattern] [--yes]")
fmt.Fprintln(w, " [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file|dir>")
fmt.Fprintln(w, " Live-share to gander.md and push every save (alias for `share --watch`)")
fmt.Fprintln(w, " gander remove [--all|--pick <short_id>|--yes|--non-interactive] <file|short_id|url>")
fmt.Fprintln(w, " Delete a share from gander.md")
Expand All @@ -306,6 +350,10 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, " -watch Live-reload the local browser preview on save (via the runner)")
fmt.Fprintln(w, " -foreground With -watch, run the blocking watcher in-process (CI / debug)")
fmt.Fprintln(w, " -silent Do not open a browser; still print the preview or share URL")
fmt.Fprintln(w, " -existing With -watch on a directory, also onboard unmatched .md already in the folder")
fmt.Fprintln(w, " -no-recursive With -watch on a directory, do not watch subdirectories")
fmt.Fprintln(w, " -glob string With -watch on a directory, filename glob (default **/*.md)")
fmt.Fprintln(w, " -yes With -watch -existing, confirm more than 50 files")
if !authed {
fmt.Fprintln(w)
fmt.Fprintln(w, "Run `gander signup --email you@example.com` to enable share / watch / remove / list / invite / manage / auth.")
Expand Down
Loading