From 39d4462cb6795ff364294fed586a8dc0b31bbfb3 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 2 Sep 2026 14:47:25 -0600 Subject: [PATCH] feat(cli): add --comments, --private, and --no-comments Omit each policy JSON key unless the flag is set so a content refresh does not clobber share ACL. --no-comments aliases --comments team. Old servers that drop the fields error instead of printing a URL. Closes #96 --- README.md | 9 +- api.go | 27 ++- completion_test.go | 4 +- completions/_gander | 9 + completions/gander.bash | 22 ++- list.go | 11 +- main.go | 6 +- man/man1/gander.1 | 49 ++++- manpage_test.go | 1 + share.go | 93 ++++++++- share_test.go | 406 +++++++++++++++++++++++++++++++++++++++- 11 files changed, 614 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 88bddc1..f53b4d9 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ gander signup --email you@example.com # opens browser form, polls for API toke gander share README.md # opens https://gander.md/s/xK7m2pQa gander watch README.md # upload + live-update the remote viewer on save gander share README.md --watch # same as `watch`, spelled out +gander share README.md --comments team # only the author's team can comment +gander share README.md --no-comments # same as --comments team (hide threads from the public) +gander share README.md --private # private doc; team access + team-only threads gander list # table of active shares gander remove README.md # 404s the short link gander remove --all # remove every share in your account @@ -264,8 +267,10 @@ Subcommands: ``` gander signup --email Open the signup form in your browser, save the API token -gander share [--watch] Upload to gander.md and open the share link -gander watch Live-share to gander.md and push every save (alias for share --watch) +gander share [--watch] [--comments=anyone|team] [--private] [--no-comments] + Upload to gander.md and open the share link +gander watch [--comments=anyone|team] [--private] [--no-comments] + Live-share to gander.md and push every save (alias for share --watch) gander status Show runner + active watches + URLs gander stop [|] [--all] Stop a watch (by file, id, or --all) gander logs [] Tail the runner log (optionally filtered by watch id) diff --git a/api.go b/api.go index a0f17c6..822e070 100644 --- a/api.go +++ b/api.go @@ -43,6 +43,9 @@ type shareResp struct { Path string `json:"path,omitempty"` Watch bool `json:"watch"` URL string `json:"url"` + CommentAccess string `json:"comment_access"` + CommentVisibility string `json:"comment_visibility"` + DocVisibility string `json:"doc_visibility"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` SizeBytes int `json:"size_bytes"` @@ -50,6 +53,14 @@ type shareResp struct { AgentUnresolvedCount int `json:"agent_unresolved_count"` } +// shareOpts are POST /api/shares policy fields. Empty strings are omitted so +// an upsert does not clobber stored values (server omitted = no-touch). +type shareOpts struct { + CommentAccess string + CommentVisibility string + DocVisibility string +} + type commentView struct { UUID string `json:"uuid"` AuthorKind string `json:"author_kind"` @@ -152,14 +163,24 @@ func (c *apiClient) OpenManageIntent() (*manageIntentResp, error) { return &out, nil } -func (c *apiClient) CreateShare(filename, path, content string, watch bool) (*shareResp, bool, error) { +func (c *apiClient) CreateShare(filename, path, content string, watch bool, opts shareOpts) (*shareResp, bool, error) { var out shareResp - status, err := c.doStatus("POST", "/api/shares", map[string]any{ + body := map[string]any{ "filename": filename, "path": path, "content": content, "watch": watch, - }, &out) + } + if opts.CommentAccess != "" { + body["comment_access"] = opts.CommentAccess + } + if opts.CommentVisibility != "" { + body["comment_visibility"] = opts.CommentVisibility + } + if opts.DocVisibility != "" { + body["doc_visibility"] = opts.DocVisibility + } + status, err := c.doStatus("POST", "/api/shares", body, &out) if err != nil { return nil, false, err } diff --git a/completion_test.go b/completion_test.go index 9f63edf..8ac035f 100644 --- a/completion_test.go +++ b/completion_test.go @@ -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", "completion"} { + for _, want := range []string{"complete -F", "signup", "share", "remove", "completion", "--comments", "--no-comments", "--private"} { if !strings.Contains(out, want) { t.Errorf("bash completion missing %q\n%s", want, out) } @@ -69,7 +69,7 @@ func TestRunCompletionZshNonEmpty(t *testing.T) { if out == "" { t.Fatal("zsh completion is empty") } - for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "completion"} { + for _, want := range []string{"#compdef gander", "_gander", "signup", "share", "remove", "completion", "--comments", "--no-comments", "--private"} { if !strings.Contains(out, want) { t.Errorf("zsh completion missing %q\n%s", want, out) } diff --git a/completions/_gander b/completions/_gander index 4c21f55..b1a3efd 100644 --- a/completions/_gander +++ b/completions/_gander @@ -40,11 +40,20 @@ _gander() { share) _arguments \ '--watch[Live-update the shared page as the file changes]' \ + '--foreground[Keep share --watch in-process instead of handing off to the runner]' \ + '--comments[Who may comment]:access:(anyone team)' \ + '--comment-visibility[Who may see comment threads]:visibility:(public team)' \ + '--private[Make the document private (team access + team-only threads)]' \ + '--no-comments[Hide comments from the public (alias for --comments team)]' \ '*:markdown file:_files -g "*.md"' ;; watch) _arguments \ '--foreground[Run the foreground blocking watcher instead of handing off to the runner]' \ + '--comments[Who may comment]:access:(anyone team)' \ + '--comment-visibility[Who may see comment threads]:visibility:(public team)' \ + '--private[Make the document private (team access + team-only threads)]' \ + '--no-comments[Hide comments from the public (alias for --comments team)]' \ '*:markdown file:_files -g "*.md"' ;; remove) diff --git a/completions/gander.bash b/completions/gander.bash index e4b299f..a3bd417 100644 --- a/completions/gander.bash +++ b/completions/gander.bash @@ -18,12 +18,22 @@ _gander_completions() { COMPREPLY=( $(compgen -W "--email --force" -- "${cur}") ) return 0 ;; - share) - COMPREPLY=( $(compgen -W "--watch" -- "${cur}") ) - return 0 - ;; - watch) - COMPREPLY=( $(compgen -W "--foreground" -- "${cur}") ) + share|watch) + case "${prev}" in + --comments) + COMPREPLY=( $(compgen -W "anyone team" -- "${cur}") ) + return 0 + ;; + --comment-visibility) + COMPREPLY=( $(compgen -W "public team" -- "${cur}") ) + return 0 + ;; + esac + local share_flags="--watch --foreground --comments --comment-visibility --private --no-comments" + if [[ "${COMP_WORDS[1]}" == "watch" ]]; then + share_flags="--foreground --comments --comment-visibility --private --no-comments" + fi + COMPREPLY=( $(compgen -W "${share_flags}" -- "${cur}") ) COMPREPLY+=( $(compgen -f -- "${cur}") ) return 0 ;; diff --git a/list.go b/list.go index d64b299..6e49643 100644 --- a/list.go +++ b/list.go @@ -27,7 +27,7 @@ func runList(_ []string) error { } tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "SHORT ID\tFILE\tPATH\tWATCH\tUPDATED\tURL") + fmt.Fprintln(tw, "SHORT ID\tFILE\tPATH\tWATCH\tCOMMENTS\tTHREADS\tPRIVATE\tUPDATED\tURL") for i := range all { watch := "no" if all[i].Watch { @@ -37,12 +37,19 @@ func runList(_ []string) error { if path == "" { path = "-" } + priv := "" + if all[i].DocVisibility == "private" { + priv = "yes" + } updated, _ := time.Parse(time.RFC3339, all[i].UpdatedAt) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", all[i].ShortID, all[i].Filename, path, watch, + all[i].CommentAccess, + all[i].CommentVisibility, + priv, updated.Format("2006-01-02 15:04 MST"), all[i].URL, ) diff --git a/main.go b/main.go index 3da8e4b..4916560 100644 --- a/main.go +++ b/main.go @@ -255,8 +255,10 @@ func printUsage(w io.Writer) { fmt.Fprintln(w, " gander [options] Render and open locally") fmt.Fprintln(w, " gander signup --email Open signup form in your browser, save the API token") if authed { - fmt.Fprintln(w, " gander share [--watch] Upload to gander.md and open the share link") - fmt.Fprintln(w, " gander watch Live-share to gander.md and push every save (alias for `share --watch`)") + fmt.Fprintln(w, " gander share [--watch] [--comments=anyone|team] [--comment-visibility=public|team] [--private] [--no-comments] ") + fmt.Fprintln(w, " Upload to gander.md and open the share link") + fmt.Fprintln(w, " gander watch [--comments=anyone|team] [--comment-visibility=public|team] [--private] [--no-comments] ") + fmt.Fprintln(w, " Live-share to gander.md and push every save (alias for `share --watch`)") fmt.Fprintln(w, " gander remove [--all|--pick |--yes|--non-interactive] ") fmt.Fprintln(w, " Delete a share from gander.md") fmt.Fprintln(w, " gander list List shares currently on gander.md") diff --git a/man/man1/gander.1 b/man/man1/gander.1 index c27c181..89b7f5d 100644 --- a/man/man1/gander.1 +++ b/man/man1/gander.1 @@ -11,9 +11,17 @@ gander \- render Markdown locally, optionally share it on gander.md .sp .B gander share .RB [ \-watch ] +.RB [ \-\-comments =\fIanyone\fR|\fIteam\fR] +.RB [ \-\-comment\-visibility =\fIpublic\fR|\fIteam\fR] +.RB [ \-\-private ] +.RB [ \-\-no\-comments ] .IR file.md .sp .B gander watch +.RB [ \-\-comments =\fIanyone\fR|\fIteam\fR] +.RB [ \-\-comment\-visibility =\fIpublic\fR|\fIteam\fR] +.RB [ \-\-private ] +.RB [ \-\-no\-comments ] .IR file.md .sp .B gander status @@ -142,11 +150,40 @@ The address to register. Required. Upload .IR file.md to gander.md, print the share link, and open it in the browser. +Omit each policy flag to leave the stored value alone on an upsert +(new shares default to anyone may comment, public threads, public doc). .sp .RS .TP .B \-watch Continuously push local changes to the remote share until interrupted. +.TP +.BR \-\-comments =\fIanyone\fR|\fIteam +Who may write comments. +.I team +limits writes to the author and members with a comment grant. +Omitting thread visibility with +.B \-\-comments team +lets the server default threads to team\-only. +.TP +.BR \-\-comment\-visibility =\fIpublic\fR|\fIteam +Who may see comment threads. +.TP +.B \-\-private +Make the document private. The share URL is no longer a read +credential; viewers must be signed in as the owner or hold a read/comment +grant. Omitting the other policy flags lets the server default comment +access and thread visibility to team. +.TP +.B \-\-no\-comments +Alias for +.BR "\-\-comments team" : +hide threads from the public; the author's team can still comment. +Cannot be combined with +.BR "\-\-comments anyone" . +.B \-\-private +cannot be combined with +.BR "\-\-comments anyone" . .RE .SS "Watch" .TP @@ -157,6 +194,15 @@ Shorthand for .IR file.md to gander.md and live\-update the remote share on every save. The share is owned by the runner daemon and survives the CLI's lifetime. +Accepts the same +.BR \-\-comments "," +.BR \-\-comment\-visibility "," +.BR \-\-private "," +and +.B \-\-no\-comments +flags as +.BR share . +Content pushes send only the markdown body and never clobber policy. .SS "Status" .TP .B status @@ -231,7 +277,8 @@ when the argument is ambiguous. .TP .B list Print a table of every share in the user's gander.md account (short id, -file, watch flag, last update, URL). +file, watch flag, comment access, thread visibility, private, last +update, URL). .SS "Comments" .TP .B comments diff --git a/manpage_test.go b/manpage_test.go index fca60cc..308666a 100644 --- a/manpage_test.go +++ b/manpage_test.go @@ -33,6 +33,7 @@ func TestManPageExistsAndRenders(t *testing.T) { "NAME", "SYNOPSIS", "DESCRIPTION", "OPTIONS", "COMMANDS", "FILES", "EXIT STATUS", "EXAMPLES", "gander signup", "gander share", "gander watch", "gander remove", + "--comments", "--no-comments", "--private", "gander list", "gander auth", "gander completion", "gander uninstall", "--upgrade", diff --git a/share.go b/share.go index 475c75a..772e420 100644 --- a/share.go +++ b/share.go @@ -28,16 +28,27 @@ func runWatchCmdWithCtx(ctx context.Context, args []string) error { return runShareWithCtx(ctx, append([]string{"--watch"}, args...)) } +const shareUsage = "usage: gander share [--watch] [--foreground] [--comments=anyone|team] [--comment-visibility=public|team] [--private] [--no-comments] file.md" + func runShareWithCtx(ctx context.Context, args []string) error { fs := flag.NewFlagSet("share", flag.ContinueOnError) watch := fs.Bool("watch", false, "live-update the shared page as the file changes") foreground := fs.Bool("foreground", false, "keep share --watch in-process instead of handing off to the runner") + comments := fs.String("comments", "", "who may comment: anyone or team") + commentVis := fs.String("comment-visibility", "", "who may see comment threads: public or team") + private := fs.Bool("private", false, "make the document private (team access + team-only threads)") + noComments := fs.Bool("no-comments", false, "hide comments from the public (alias for --comments team)") if err := fs.Parse(args); err != nil { return err } rest := fs.Args() if len(rest) != 1 { - return fmt.Errorf("usage: gander share [--watch] [--foreground] file.md") + return fmt.Errorf("%s", shareUsage) + } + + opts, err := shareOptsFromFlags(fs, *comments, *commentVis, *private, *noComments) + if err != nil { + return err } canonical, err := canonicalPath(rest[0]) @@ -57,10 +68,13 @@ func runShareWithCtx(ctx context.Context, args []string) error { cli := newAPIClient(cfg.APIURL, cfg.APIToken) _, hadLocal := cfg.Shares[canonical] - sh, created, err := cli.CreateShare(filepath.Base(canonical), canonical, string(content), *watch) + sh, created, err := cli.CreateShare(filepath.Base(canonical), canonical, string(content), *watch, opts) if err != nil { return fmt.Errorf("create: %w", err) } + if err := checkSharePolicyEcho(opts, sh); err != nil { + return err + } cfg.Shares[canonical] = sh.ShortID if err := WriteConfig(cfg); err != nil { return fmt.Errorf("save mapping: %w", err) @@ -257,4 +271,79 @@ func requireAuth() (Config, error) { return cfg, nil } +func flagSetVisited(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + +func shareOptsFromFlags(fs *flag.FlagSet, comments, commentVis string, private, noComments bool) (shareOpts, error) { + commentsSet := flagSetVisited(fs, "comments") + visSet := flagSetVisited(fs, "comment-visibility") + + if commentsSet { + switch comments { + case "anyone", "team": + default: + return shareOpts{}, fmt.Errorf("--comments must be anyone or team") + } + } + if visSet { + switch commentVis { + case "public", "team": + default: + return shareOpts{}, fmt.Errorf("--comment-visibility must be public or team") + } + } + + access := "" + if commentsSet { + access = comments + } else if noComments { + access = "team" + } + + if noComments && access == "anyone" { + return shareOpts{}, fmt.Errorf("--no-comments cannot be combined with --comments anyone") + } + if private && access == "anyone" { + return shareOpts{}, fmt.Errorf("--private cannot be combined with --comments anyone") + } + if visSet && commentVis == "team" && access == "anyone" { + return shareOpts{}, fmt.Errorf("--comments anyone cannot be combined with --comment-visibility team") + } + if private && visSet && commentVis == "public" { + return shareOpts{}, fmt.Errorf("--private cannot be combined with --comment-visibility public") + } + + opts := shareOpts{} + if access != "" { + opts.CommentAccess = access + } + if visSet { + opts.CommentVisibility = commentVis + } + if private { + opts.DocVisibility = "private" + } + return opts, nil +} + +func checkSharePolicyEcho(opts shareOpts, sh *shareResp) error { + if opts.CommentAccess != "" && sh.CommentAccess == "" { + return fmt.Errorf("gandermd does not support --comments; upgrade the server") + } + if opts.CommentVisibility != "" && sh.CommentVisibility == "" { + return fmt.Errorf("gandermd does not support --comment-visibility; upgrade the server") + } + if opts.DocVisibility != "" && sh.DocVisibility == "" { + return fmt.Errorf("gandermd does not support --private; upgrade the server") + } + return nil +} + var _ = context.Background diff --git a/share_test.go b/share_test.go index a31eee6..7ebf24f 100644 --- a/share_test.go +++ b/share_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -93,7 +94,7 @@ func TestCreateShareSendsPathField(t *testing.T) { openBrowser = func(url string) error { return nil } t.Cleanup(func() { openBrowser = prev }) - if _, _, err := newAPIClient(srv.URL, "gmd_t").CreateShare("doc.md", mdFile, "# v1", false); err != nil { + if _, _, err := newAPIClient(srv.URL, "gmd_t").CreateShare("doc.md", mdFile, "# v1", false, shareOpts{}); err != nil { t.Fatalf("CreateShare: %v", err) } @@ -101,6 +102,11 @@ func TestCreateShareSendsPathField(t *testing.T) { if got != mdFile { t.Errorf("path = %q, want %q", got, mdFile) } + for _, k := range []string{"comment_access", "comment_visibility", "doc_visibility"} { + if _, ok := capturedBody[k]; ok { + t.Errorf("unset flags must omit %s; body=%v", k, capturedBody) + } + } } func TestCreateShareReturnsCreatedFlagOnUpdate(t *testing.T) { @@ -132,7 +138,7 @@ func TestCreateShareReturnsCreatedFlagOnUpdate(t *testing.T) { defer srv.Close() cli := newAPIClient(srv.URL, "gmd_t") - _, created, err := cli.CreateShare("doc.md", "/tmp/doc.md", "# v1", false) + _, created, err := cli.CreateShare("doc.md", "/tmp/doc.md", "# v1", false, shareOpts{}) if err != nil { t.Fatalf("CreateShare first: %v", err) } @@ -140,7 +146,7 @@ func TestCreateShareReturnsCreatedFlagOnUpdate(t *testing.T) { t.Errorf("first call: want created=true") } - _, created, err = cli.CreateShare("doc.md", "/tmp/doc.md", "# v2", false) + _, created, err = cli.CreateShare("doc.md", "/tmp/doc.md", "# v2", false, shareOpts{}) if err != nil { t.Fatalf("CreateShare second: %v", err) } @@ -231,6 +237,400 @@ func TestRunListShowsDashForMissingPath(t *testing.T) { } } +func TestRunListShowsCommentPolicyColumns(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/shares", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "short_id": "abc12345", + "filename": "doc.md", + "path": "/tmp/doc.md", + "watch": false, + "comment_access": "team", + "comment_visibility": "team", + "doc_visibility": "private", + "url": "https://gander.md/s/abc12345", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "size_bytes": 100, + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "short_id": "pub12345", + "filename": "open.md", + "watch": false, + "comment_access": "anyone", + "comment_visibility": "public", + "doc_visibility": "public", + "url": "https://gander.md/s/pub12345", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + _ = setupShareHome(t, srv.URL) + + stdout, stderr := captureStdIO(t, func() error { + return runList(nil) + }) + if stderr != "" { + t.Errorf("stderr = %q", stderr) + } + for _, want := range []string{"COMMENTS", "THREADS", "PRIVATE", "team", "anyone", "public", "yes"} { + if !strings.Contains(stdout, want) { + t.Errorf("list output missing %q\n%s", want, stdout) + } + } + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) < 3 { + t.Fatalf("want header + 2 rows, got:\n%s", stdout) + } + if strings.Contains(lines[2], "yes") { + t.Errorf("public row should leave PRIVATE blank:\n%s", lines[2]) + } +} + +func TestCreateSharePolicyBody(t *testing.T) { + cases := []struct { + name string + opts shareOpts + want map[string]string + omit []string + }{ + { + name: "flags omitted", + omit: []string{"comment_access", "comment_visibility", "doc_visibility"}, + }, + { + name: "comments team", + opts: shareOpts{CommentAccess: "team"}, + want: map[string]string{"comment_access": "team"}, + omit: []string{"comment_visibility", "doc_visibility"}, + }, + { + name: "no-comments alias", + opts: shareOpts{CommentAccess: "team"}, + want: map[string]string{"comment_access": "team"}, + omit: []string{"comment_visibility", "doc_visibility"}, + }, + { + name: "private", + opts: shareOpts{DocVisibility: "private"}, + want: map[string]string{"doc_visibility": "private"}, + omit: []string{"comment_access", "comment_visibility"}, + }, + { + name: "comment-visibility public", + opts: shareOpts{CommentVisibility: "public"}, + want: map[string]string{"comment_visibility": "public"}, + omit: []string{"comment_access", "doc_visibility"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var captured map[string]any + mux := http.NewServeMux() + mux.HandleFunc("/api/shares", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&captured) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "uuid": "11111111-1111-1111-1111-111111111111", + "short_id": "abc12345", + "filename": "doc.md", + "url": "https://gander.md/s/abc12345", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + if _, _, err := newAPIClient(srv.URL, "gmd_t").CreateShare("doc.md", "/tmp/doc.md", "# v1", false, tc.opts); err != nil { + t.Fatalf("CreateShare: %v", err) + } + for k, v := range tc.want { + got, _ := captured[k].(string) + if got != v { + t.Errorf("%s = %q, want %q (body=%v)", k, got, v, captured) + } + } + for _, k := range tc.omit { + if _, ok := captured[k]; ok { + t.Errorf("body unexpectedly has %s=%v", k, captured[k]) + } + } + }) + } +} + +func TestUpdateShareSendsContentOnly(t *testing.T) { + var captured map[string]any + mux := http.NewServeMux() + mux.HandleFunc("/api/shares/11111111-1111-1111-1111-111111111111", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&captured) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "uuid": "11111111-1111-1111-1111-111111111111", + "short_id": "abc12345", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + if _, err := newAPIClient(srv.URL, "gmd_t").UpdateShare("11111111-1111-1111-1111-111111111111", "# v2"); err != nil { + t.Fatalf("UpdateShare: %v", err) + } + if got, _ := captured["content"].(string); got != "# v2" { + t.Errorf("content = %q", got) + } + if len(captured) != 1 { + t.Errorf("PUT body = %v, want only content", captured) + } + for _, k := range []string{"comment_access", "comment_visibility", "doc_visibility"} { + if _, ok := captured[k]; ok { + t.Errorf("watch PUT must omit %s", k) + } + } +} + +func TestSharePolicyFlagsPOSTBody(t *testing.T) { + cases := []struct { + name string + args []string + want map[string]string + omit []string + }{ + { + name: "omitted", + omit: []string{"comment_access", "comment_visibility", "doc_visibility"}, + }, + { + name: "comments team", + args: []string{"--comments=team"}, + want: map[string]string{"comment_access": "team"}, + omit: []string{"comment_visibility", "doc_visibility"}, + }, + { + name: "no-comments", + args: []string{"--no-comments"}, + want: map[string]string{"comment_access": "team"}, + omit: []string{"comment_visibility", "doc_visibility"}, + }, + { + name: "private", + args: []string{"--private"}, + want: map[string]string{"doc_visibility": "private"}, + omit: []string{"comment_access", "comment_visibility"}, + }, + { + name: "comment-visibility public", + args: []string{"--comment-visibility=public"}, + want: map[string]string{"comment_visibility": "public"}, + omit: []string{"comment_access", "doc_visibility"}, + }, + { + name: "comments team and public threads", + args: []string{"--comments=team", "--comment-visibility=public"}, + want: map[string]string{"comment_access": "team", "comment_visibility": "public"}, + omit: []string{"doc_visibility"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var captured map[string]any + var posts int + srv := newSharePolicyServer(t, &captured, &posts, true) + md := setupShareHome(t, srv.URL) + args := append(append([]string{}, tc.args...), md) + if err := runShareWithCtx(context.Background(), args); err != nil { + t.Fatalf("share: %v", err) + } + if posts != 1 { + t.Errorf("POST count = %d, want 1", posts) + } + for k, v := range tc.want { + got, _ := captured[k].(string) + if got != v { + t.Errorf("%s = %q, want %q (body=%v)", k, got, v, captured) + } + } + for _, k := range tc.omit { + if _, ok := captured[k]; ok { + t.Errorf("body unexpectedly has %s=%v", k, captured[k]) + } + } + }) + } +} + +func TestWatchNoCommentsSamePOSTAsShare(t *testing.T) { + var captured map[string]any + var posts int + srv := newSharePolicyServer(t, &captured, &posts, true) + md := setupShareHome(t, srv.URL) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := runWatchCmdWithCtx(ctx, []string{"--foreground", "--no-comments", md}); err != nil { + t.Fatalf("watch: %v", err) + } + if posts != 1 { + t.Errorf("POST count = %d, want 1", posts) + } + got, _ := captured["comment_access"].(string) + if got != "team" { + t.Errorf("comment_access = %q, want team (body=%v)", got, captured) + } + if _, ok := captured["comment_visibility"]; ok { + t.Errorf("watch --no-comments must omit comment_visibility") + } + if _, ok := captured["doc_visibility"]; ok { + t.Errorf("watch --no-comments must omit doc_visibility") + } + watch, _ := captured["watch"].(bool) + if !watch { + t.Errorf("watch = %v, want true", captured["watch"]) + } +} + +func TestShareRejectsInvalidPolicyCombosBeforeHTTP(t *testing.T) { + posts := 0 + mux := http.NewServeMux() + mux.HandleFunc("/api/shares", func(w http.ResponseWriter, r *http.Request) { + posts++ + http.Error(w, "should not be called", http.StatusInternalServerError) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + md := setupShareHome(t, srv.URL) + + cases := []struct { + name string + args []string + want string + }{ + {"no-comments + anyone", []string{"--no-comments", "--comments=anyone", md}, "--no-comments cannot be combined with --comments anyone"}, + {"private + anyone", []string{"--private", "--comments=anyone", md}, "--private cannot be combined with --comments anyone"}, + {"bad comments", []string{"--comments=users", md}, "--comments must be anyone or team"}, + {"bad visibility", []string{"--comment-visibility=hidden", md}, "--comment-visibility must be public or team"}, + {"anyone + team threads", []string{"--comments=anyone", "--comment-visibility=team", md}, "--comments anyone cannot be combined with --comment-visibility team"}, + {"private + public threads", []string{"--private", "--comment-visibility=public", md}, "--private cannot be combined with --comment-visibility public"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runShareWithCtx(context.Background(), tc.args) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want substring %q", err, tc.want) + } + }) + } + if posts != 0 { + t.Errorf("invalid combos issued %d HTTP POSTs", posts) + } +} + +func TestShareCommentsFlagRejectedByOldServer(t *testing.T) { + var captured map[string]any + var posts int + srv := newSharePolicyServer(t, &captured, &posts, false) + md := setupShareHome(t, srv.URL) + opened := 0 + openBrowser = func(url string) error { + opened++ + return nil + } + + var shareErr error + stdout, _ := captureStdIO(t, func() error { + shareErr = runShareWithCtx(context.Background(), []string{"--comments=team", md}) + return nil + }) + if shareErr == nil || !strings.Contains(shareErr.Error(), "gandermd does not support --comments") { + t.Fatalf("err = %v", shareErr) + } + if opened != 0 { + t.Errorf("opened browser %d times", opened) + } + if strings.Contains(stdout, "https://") { + t.Errorf("printed URL on old server:\n%s", stdout) + } + if posts != 1 { + t.Errorf("POST count = %d, want 1", posts) + } + if got, _ := captured["comment_access"].(string); got != "team" { + t.Errorf("comment_access = %q, want team", got) + } +} + +func setupShareHome(t *testing.T, apiURL string) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + cfg := DefaultConfig() + cfg.APIURL = apiURL + cfg.APIToken = "gmd_t" + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + md := filepath.Join(tmp, "doc.md") + if err := os.WriteFile(md, []byte("# v1"), 0644); err != nil { + t.Fatal(err) + } + prev := openBrowser + openBrowser = func(url string) error { return nil } + t.Cleanup(func() { openBrowser = prev }) + return md +} + +func newSharePolicyServer(t *testing.T, captured *map[string]any, posts *int, echoPolicy bool) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/shares", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + *posts++ + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + *captured = body + resp := map[string]any{ + "uuid": "11111111-1111-1111-1111-111111111111", + "short_id": "abc12345", + "filename": "doc.md", + "watch": body["watch"], + "url": "https://gander.md/s/abc12345", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + if echoPolicy { + if v, ok := body["comment_access"]; ok { + resp["comment_access"] = v + } + if v, ok := body["comment_visibility"]; ok { + resp["comment_visibility"] = v + } + if v, ok := body["doc_visibility"]; ok { + resp["doc_visibility"] = v + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + func captureStdIO(t *testing.T, fn func() error) (string, string) { t.Helper() origOut := os.Stdout