From 798bc95fe93b3671ccba5819c7020d3086aa725a Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 7 May 2026 16:10:03 +0300 Subject: [PATCH] feat: preview and confirm before push `mdcx push` now prints the exact PUT request (URL, masked auth header, title, version bump, body size delta, and a 20-line body excerpt) and prompts for confirmation before sending. Add `--yes`/`-y` to skip the prompt and `--dry-run` to preview only. Non-interactive stdin without `--yes` errors out instead of hanging. Splits `Client.UpdateContent` into `BuildUpdate` + send so the CLI can render the same payload that will be transmitted. --- api/client_test.go | 2 +- api/content.go | 19 ++++--- cmd/mdcx/push.go | 125 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 16 deletions(-) diff --git a/api/client_test.go b/api/client_test.go index b4994e7247c07041541ebb0475f6eb22dacb9426..10d2caaeddfe75ac08615febb3db709b853f6541 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -89,7 +89,7 @@ func TestUpdateContent(t *testing.T) { assert.Equal(t, "/rest/api/content/12345", r.URL.Path) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - var req updateRequest + var req UpdateRequest err := json.NewDecoder(r.Body).Decode(&req) require.NoError(t, err) diff --git a/api/content.go b/api/content.go index 2464ada5dce4fb60fd498181ceec518f1a8ee51d..36ad873516ad68d900537eac952df14fbced50ab 100644 --- a/api/content.go +++ b/api/content.go @@ -35,8 +35,8 @@ type StorageBody struct { Representation string `json:"representation"` } -// updateRequest is the JSON payload for updating a page. -type updateRequest struct { +// UpdateRequest is the JSON payload for updating a page. +type UpdateRequest struct { Version Version `json:"version"` Title string `json:"title"` Type string `json:"type"` @@ -117,9 +117,10 @@ func (c *Client) GetPage(ref *PageRef) (*ContentResponse, error) { return c.FindContent(ref.SpaceKey, ref.Title) } -// UpdateContent updates a page's storage body, incrementing the version. -func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody string, message string) error { - payload := updateRequest{ +// BuildUpdate returns the API path and payload that UpdateContent would send, +// without making any HTTP request. The CLI uses this to preview the request. +func (c *Client) BuildUpdate(pageID string, current *ContentResponse, newBody, message string) (path string, payload UpdateRequest) { + payload = UpdateRequest{ Version: Version{ Number: current.Version.Number + 1, Message: message, @@ -133,13 +134,19 @@ func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody }, }, } + path = fmt.Sprintf("/rest/api/content/%s", pageID) + return path, payload +} + +// UpdateContent updates a page's storage body, incrementing the version. +func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody string, message string) error { + path, payload := c.BuildUpdate(pageID, current, newBody, message) data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling update: %w", err) } - path := fmt.Sprintf("/rest/api/content/%s", pageID) req, err := c.newRequest(http.MethodPut, path) if err != nil { return err diff --git a/cmd/mdcx/push.go b/cmd/mdcx/push.go index 8cfcaa94fabd9a57f0dc297a723287c1a1688106..79a8f913e87058fca33c818c2013fe511cae477c 100644 --- a/cmd/mdcx/push.go +++ b/cmd/mdcx/push.go @@ -1,9 +1,11 @@ package main import ( + "bufio" "fmt" "io" "os" + "strings" "github.com/spf13/cobra" @@ -13,11 +15,13 @@ import ( ) var ( - pushMessage string - pushRaw bool - pushTemplate bool - pushMarkerStart string - pushMarkerEnd string + pushMessage string + pushRaw bool + pushTemplate bool + pushMarkerStart string + pushMarkerEnd string + pushYes bool + pushDryRun bool ) var pushCmd = &cobra.Command{ @@ -36,6 +40,9 @@ With --raw, the input is treated as Confluence storage XML (no conversion). Reads from stdin if no input file is specified. +Before sending, mdcx prints the exact request it will make and asks for +confirmation. Use --yes to skip the prompt or --dry-run to preview only. + Authentication via --token flag or CONFLUENCE_TOKEN environment variable.`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { @@ -79,9 +86,6 @@ Authentication via --token flag or CONFLUENCE_TOKEN environment variable.`, return err } - fmt.Fprintf(os.Stderr, "Updating page: %s (id=%s, version=%d -> %d)\n", - page.Title, page.ID, page.Version.Number, page.Version.Number+1) - // If template mode, embed into existing page body body := newXML if pushTemplate { @@ -91,20 +95,123 @@ Authentication via --token flag or CONFLUENCE_TOKEN environment variable.`, } } + path, payload := client.BuildUpdate(page.ID, page, body, pushMessage) + printPushPreview(client.BaseURL+path, page, payload) + + if pushDryRun { + fmt.Fprintln(os.Stderr, "(dry-run, not sending)") + return nil + } + + if !pushYes { + ok, err := confirmPush() + if err != nil { + return err + } + if !ok { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + if err := client.UpdateContent(page.ID, page, body, pushMessage); err != nil { return err } - fmt.Fprintf(os.Stderr, "Page updated successfully\n") + fmt.Fprintln(os.Stderr, "Page updated successfully") return nil }, } +// printPushPreview writes an API-oriented summary of the upcoming PUT to stderr. +func printPushPreview(fullURL string, page *api.ContentResponse, payload api.UpdateRequest) { + w := os.Stderr + fmt.Fprintf(w, "PUT %s\n", fullURL) + fmt.Fprintln(w, " Content-Type: application/json") + fmt.Fprintln(w, " Authorization: Bearer ********") + fmt.Fprintln(w) + fmt.Fprintf(w, "Page: %q (id=%s, type=%s)\n", payload.Title, page.ID, payload.Type) + fmt.Fprintf(w, "Version: %d -> %d\n", page.Version.Number, payload.Version.Number) + if payload.Version.Message == "" { + fmt.Fprintln(w, "Message: (none)") + } else { + fmt.Fprintf(w, "Message: %q\n", payload.Version.Message) + } + oldSize := len(page.Body.Storage.Value) + newSize := len(payload.Body.Storage.Value) + fmt.Fprintf(w, "Body: %s bytes -> %s bytes (storage format)\n", + formatInt(oldSize), formatInt(newSize)) + if pushTemplate { + fmt.Fprintf(w, " (template embed: replaced region between %s and %s)\n", + pushMarkerStart, pushMarkerEnd) + } + fmt.Fprintln(w) + + const maxLines = 20 + lines := strings.Split(payload.Body.Storage.Value, "\n") + fmt.Fprintf(w, "--- new body (first %d lines) ---\n", min(maxLines, len(lines))) + for i := 0; i < len(lines) && i < maxLines; i++ { + fmt.Fprintln(w, lines[i]) + } + if len(lines) > maxLines { + fmt.Fprintf(w, "--- (%d more lines) ---\n", len(lines)-maxLines) + } else { + fmt.Fprintln(w, "--- (end) ---") + } + fmt.Fprintln(w) +} + +// confirmPush prompts the user on a TTY. Returns an error when stdin is +// non-interactive and --yes was not given. +func confirmPush() (bool, error) { + stat, err := os.Stdin.Stat() + if err != nil { + return false, fmt.Errorf("stat stdin: %w", err) + } + if stat.Mode()&os.ModeCharDevice == 0 { + return false, fmt.Errorf("non-interactive stdin: pass --yes to confirm or --dry-run to preview only") + } + + fmt.Fprint(os.Stderr, "Proceed with update? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return false, fmt.Errorf("reading confirmation: %w", err) + } + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} + +// formatInt returns n as a comma-separated decimal string ("23,481"). +func formatInt(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var b strings.Builder + pre := len(s) % 3 + if pre > 0 { + b.WriteString(s[:pre]) + if len(s) > pre { + b.WriteByte(',') + } + } + for i := pre; i < len(s); i += 3 { + b.WriteString(s[i : i+3]) + if i+3 < len(s) { + b.WriteByte(',') + } + } + return b.String() +} + func init() { pushCmd.Flags().StringVarP(&pushMessage, "message", "m", "", "Version message for the update") pushCmd.Flags().BoolVar(&pushRaw, "raw", false, "Input is raw Confluence storage XML (skip conversion)") pushCmd.Flags().BoolVar(&pushTemplate, "template", false, "Embed content into existing page body between markers") pushCmd.Flags().StringVar(&pushMarkerStart, "marker-start", template.DefaultMarkerStart, "Start marker comment (with --template)") pushCmd.Flags().StringVar(&pushMarkerEnd, "marker-end", template.DefaultMarkerEnd, "End marker comment (with --template)") + pushCmd.Flags().BoolVarP(&pushYes, "yes", "y", false, "Skip confirmation prompt") + pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "Show the request that would be sent and exit") rootCmd.AddCommand(pushCmd) }