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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ This will:
3. Open it in your default browser via a `file://` URL
4. Exit — the process does not keep running, no port is held open

Pass `--silent` to print the `file://` URL without opening a browser:

```bash
gander --silent path/to/file.md
```

### Convert to HTML file (no browser)

```bash
Expand All @@ -189,6 +195,7 @@ gander -outfile readme.html README.md

```bash
gander --watch README.md
gander --watch --silent README.md # register the watch, print URL, skip the browser
```

`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.
Expand Down Expand Up @@ -220,9 +227,12 @@ If you're running an agent that streams markdown to a file, `gander watch` is th
```bash
gander signup --email you@example.com # opens browser form, polls for API token
gander share README.md # opens https://gander.md/s/xK7m2pQa
gander share README.md --silent # same share, print URL, skip the browser
gander watch README.md # upload + live-update the remote viewer on save
# (same short id if you already shared that file from the dashboard)
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 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 @@ -331,6 +341,9 @@ The legacy `~/.mdp` fallback only applies when `GANDER_CONFIG` is unset; named p
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.
-silent
Do not open a browser after rendering, sharing, or starting a watch.
The URL is still printed. Does not change visibility or commenting.
-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 @@ -342,8 +355,8 @@ Subcommands:

```
gander signup --email <addr> Open the signup form in your browser, save the API token
gander share [--watch] [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file>
gander watch [--visibility=anyone|private|hidden] [--private] [--comments=anyone|private|disabled] [--no-comments] <file>
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 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
137 changes: 137 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,143 @@ func TestWatchCmdEqualsShareWatch(t *testing.T) {
}
}

func TestOneShotSilentDoesNotOpenBrowser(t *testing.T) {
tmp := t.TempDir()
md := filepath.Join(tmp, "doc.md")
if err := os.WriteFile(md, []byte("# hi"), 0644); err != nil {
t.Fatal(err)
}
opened := 0
prev := openBrowser
openBrowser = func(url string) error {
opened++
return nil
}
t.Cleanup(func() { openBrowser = prev })

stdout, _ := captureStdIO(t, func() error {
return runOneShotPreview(md, true)
})
if opened != 0 {
t.Errorf("opened browser %d times for silent one-shot", opened)
}
if !strings.Contains(stdout, "Preview at:") {
t.Errorf("silent one-shot should still print URL:\n%s", stdout)
}
}

func TestOneShotOpensBrowserOnce(t *testing.T) {
tmp := t.TempDir()
md := filepath.Join(tmp, "doc.md")
if err := os.WriteFile(md, []byte("# hi"), 0644); err != nil {
t.Fatal(err)
}
opened := 0
prev := openBrowser
openBrowser = func(url string) error {
opened++
return nil
}
t.Cleanup(func() { openBrowser = prev })

stdout, _ := captureStdIO(t, func() error {
return runOneShotPreview(md, false)
})
if opened != 1 {
t.Errorf("opened browser %d times, want 1", opened)
}
if !strings.Contains(stdout, "Preview at:") {
t.Errorf("one-shot should print URL:\n%s", stdout)
}
}

func TestLocalWatchSilentDoesNotOpenBrowser(t *testing.T) {
tmp := t.TempDir()
md := filepath.Join(tmp, "doc.md")
if err := os.WriteFile(md, []byte("# hi"), 0644); err != nil {
t.Fatal(err)
}
opened := 0
prev := openBrowser
openBrowser = func(url string) error {
opened++
return nil
}
t.Cleanup(func() { openBrowser = prev })

ctx, cancel := context.WithCancel(context.Background())
cancel()
cfg := DefaultConfig()
cfg.Port = 0
stdout, _ := captureStdIO(t, func() error {
return runWatchCtx(ctx, md, cfg, true)
})
if opened != 0 {
t.Errorf("opened browser %d times for silent local watch", opened)
}
if !strings.Contains(stdout, "Preview at:") {
t.Errorf("silent local watch should still print URL:\n%s", stdout)
}
}

func TestLocalWatchOpensBrowserOnce(t *testing.T) {
tmp := t.TempDir()
md := filepath.Join(tmp, "doc.md")
if err := os.WriteFile(md, []byte("# hi"), 0644); err != nil {
t.Fatal(err)
}
opened := 0
prev := openBrowser
openBrowser = func(url string) error {
opened++
return nil
}
t.Cleanup(func() { openBrowser = prev })

ctx, cancel := context.WithCancel(context.Background())
cancel()
cfg := DefaultConfig()
cfg.Port = 0
stdout, _ := captureStdIO(t, func() error {
return runWatchCtx(ctx, md, cfg, false)
})
if opened != 1 {
t.Errorf("opened browser %d times, want 1", opened)
}
if !strings.Contains(stdout, "Preview at:") {
t.Errorf("local watch should print URL:\n%s", stdout)
}
}

func TestPrintUsageMentionsSilent(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
cfg := DefaultConfig()
cfg.APIToken = "gmd_t"
if err := WriteConfig(cfg); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
printUsage(&buf)
out := buf.String()
if !strings.Contains(out, "--silent") {
t.Errorf("usage missing --silent:\n%s", out)
}
if !strings.Contains(shareUsage, "--silent") {
t.Errorf("shareUsage missing --silent: %s", shareUsage)
}
for _, line := range strings.Split(out, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "-silent") {
continue
}
low := strings.ToLower(line)
if strings.Contains(low, "hidden") || strings.Contains(low, "unpublish") {
t.Errorf("usage --silent looks like access control: %s", line)
}
}
}

func TestWatchCmdRequiresAuth(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
Expand Down
13 changes: 11 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"} {
for _, want := range []string{"complete -F", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent"} {
if !strings.Contains(out, want) {
t.Errorf("bash completion missing %q\n%s", want, out)
}
Expand Down Expand Up @@ -72,14 +72,23 @@ 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"} {
for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "invite", "completion", "dashboard", "dash", "--d", "--visibility", "--comments", "--private", "--no-comments", "--silent"} {
if !strings.Contains(out, want) {
t.Errorf("zsh completion missing %q\n%s", want, out)
}
}
if strings.Contains(out, "comment-visibility") {
t.Errorf("zsh completion still mentions --comment-visibility")
}
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, "--silent") {
continue
}
low := strings.ToLower(line)
if strings.Contains(low, "hidden") || strings.Contains(low, "unpublish") {
t.Errorf("zsh --silent looks like access control: %s", line)
}
}
}

func TestRunCompletionRejectsUnknownShell(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions completions/_gander
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ _gander() {
_arguments \
'--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]' \
'--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)' \
Expand All @@ -54,6 +55,7 @@ _gander() {
watch)
_arguments \
'--foreground[Run the foreground blocking watcher instead of handing off to the runner]' \
'--silent[Create or refresh without opening a browser]' \
'--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)' \
Expand Down Expand Up @@ -128,6 +130,7 @@ _gander() {
*)
_arguments \
'-watch[Live-reload the browser preview]' \
'--silent[Do not open a browser; still print the preview URL]' \
'-outfile[Write HTML to a file]:file:_files -g "*.html"' \
'--help[Show help]' \
'*:markdown file:_files -g "*.md"'
Expand Down
7 changes: 4 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 --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY=( $(compgen -W "--watch --foreground --silent --visibility --comments --private --no-comments" -- "${cur}") )
return 0
;;
watch)
Expand All @@ -43,7 +43,7 @@ _gander_completions() {
return 0
;;
esac
COMPREPLY=( $(compgen -W "--foreground --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY=( $(compgen -W "--foreground --silent --visibility --comments --private --no-comments" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
Expand Down Expand Up @@ -101,7 +101,8 @@ _gander_completions() {
return 0
;;
*)
COMPREPLY=()
COMPREPLY=( $(compgen -W "--watch --foreground --silent --outfile --help" -- "${cur}") )
COMPREPLY+=( $(compgen -f -- "${cur}") )
return 0
;;
esac
Expand Down
35 changes: 25 additions & 10 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func main() {
outFile := flag.String("outfile", "", "Optional: write HTML output to file instead of opening in browser")
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")
upgrade := flag.Bool("upgrade", false, "Download and install the latest release, then exit")
flag.Parse()

Expand Down Expand Up @@ -191,39 +192,52 @@ func main() {

if useWatch {
if *foreground {
if err := runWatch(absPath, cfg); err != nil {
if err := runWatch(absPath, cfg, *silent); err != nil {
log.Fatalf("watch: %v", err)
}
return
}
if err := handOffWatch(absPath); err != nil {
if err := handOffWatch(absPath, *silent); err != nil {
log.Fatalf("watch: %v", err)
}
return
}

content, err := os.ReadFile(absPath)
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}

if *outFile != "" {
content, err := os.ReadFile(absPath)
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
if err := writeHTMLTo(*outFile, content); err != nil {
log.Fatalf("Failed to write HTML: %v", err)
}
fmt.Printf("HTML written to: %s\n", *outFile)
return
}

if err := runOneShotPreview(absPath, *silent); err != nil {
log.Fatalf("%v", err)
}
}

func runOneShotPreview(absPath string, silent bool) error {
content, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("Failed to read file: %v", err)
}
tmpPath, err := writeHTMLToTemp(content)
if err != nil {
log.Fatalf("Failed to write temp HTML: %v", err)
return fmt.Errorf("Failed to write temp HTML: %v", err)
}
url := "file://" + tmpPath
fmt.Printf("Preview at: %s\n", url)
if silent {
return nil
}
if err := openBrowser(url); err != nil {
log.Printf("Warning: could not open browser: %v", err)
}
return nil
}

func printVersion(w io.Writer) {
Expand Down Expand Up @@ -262,10 +276,10 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, " gander <file.md> [options] Render and open locally")
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] [--visibility=anyone|private|hidden] [--private]")
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, " Upload to gander.md (keeps a dashboard share of the same file)")
fmt.Fprintln(w, " gander watch [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " gander watch [--silent] [--visibility=anyone|private|hidden] [--private]")
fmt.Fprintln(w, " [--comments=anyone|private|disabled] [--no-comments] <file>")
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>")
Expand All @@ -291,6 +305,7 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, " -outfile string Write HTML to a file instead of opening in browser")
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")
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