From e4f4e5355f62705b409d95d9782815ffa8c226f1 Mon Sep 17 00:00:00 2001 From: erkancamli Date: Mon, 7 Sep 2026 16:35:02 +0000 Subject: [PATCH 1/3] feat(publish): read payload from stdin when --message/--file are omitted Closes #92 - publish now reads the payload from stdin when neither --message nor --file is given, so it composes with other tools in a pipe (echo/cat/curl ... | mump2p publish --topic=x) - --file=- reads from stdin explicitly (Unix convention) - running publish in an interactive terminal with no payload flags fails fast with a helpful error instead of blocking on input - payload resolution moved into resolvePublishPayload() and covered by unit tests - README, guide and --help updated --- README.md | 12 +++++ cmd/publish.go | 85 ++++++++++++++++++++++++++++------- cmd/publish_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++ docs/guide.md | 11 +++++ 4 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 cmd/publish_test.go diff --git a/README.md b/README.md index 42f3f3a..f30facc 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,18 @@ Published to 34.126.161.115:33211 (Singapore) in 259ms mump2p publish --topic test/data --file ./payload.json ``` +### From stdin + +When neither `--message` nor `--file` is given, the payload is read from stdin, so `publish` works like any other Unix tool in a pipe: + +```bash +echo "Hello World" | mump2p publish --topic test +cat ./payload.json | mump2p publish --topic test/data +curl -s https://api.example.com/status | mump2p publish --topic status +``` + +`--file -` also reads from stdin explicitly. + ## Debug Mode Use `--debug` to see session details, node scores, timing breakdowns, message IDs, and peer paths. diff --git a/cmd/publish.go b/cmd/publish.go index 16a1b60..2f8c61b 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "time" @@ -64,17 +65,79 @@ func shortMsgID(resp *pb.Response) string { return "" } +// resolvePublishPayload picks the message payload from, in order of +// preference: --message, --file, or stdin. Stdin is read to EOF and sent as +// a single message. It is used when neither flag is given (and stdin is not +// an interactive terminal), or when --file is "-", following the common +// Unix convention. +func resolvePublishPayload(message, filePath string, stdin *os.File) ([]byte, error) { + if message != "" { + return []byte(message), nil + } + + if filePath != "" && filePath != "-" { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %v", err) + } + return content, nil + } + + if filePath != "-" && isTerminal(stdin) { + return nil, errors.New("no message provided: use --message, --file, or pipe data via stdin") + } + + content, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("failed to read stdin: %v", err) + } + if len(content) == 0 { + return nil, errors.New("stdin is empty: nothing to publish") + } + return content, nil +} + +// isTerminal reports whether f is an interactive terminal (as opposed to a +// pipe or a redirected file). A nil file is treated as a terminal so that +// we never block waiting for input that cannot arrive. +func isTerminal(f *os.File) bool { + if f == nil { + return true + } + info, err := f.Stat() + if err != nil { + return true + } + return info.Mode()&os.ModeCharDevice != 0 +} + var publishCmd = &cobra.Command{ Use: "publish", Short: "Publish a message to the Optimum Network", + Long: `Publish a message to the Optimum Network. + +The payload comes from --message, --file, or stdin. When neither flag is +given, the payload is read from stdin so the command can be used in pipes: + + echo "hello" | mump2p publish --topic=test + curl -s https://api.example.com/status | mump2p publish --topic=status + +Use --file=- to read from stdin explicitly.`, + Example: ` mump2p publish --topic=test --message="Hello World" + mump2p publish --topic=test/data --file=./payload.json + cat payload.json | mump2p publish --topic=test/data`, RunE: func(cmd *cobra.Command, args []string) error { - if pubMessage == "" && file == "" { - return errors.New("either --message or --file must be provided") - } if pubMessage != "" && file != "" { return errors.New("only one of --message or --file should be used at a time") } + // Resolve the payload before authenticating so that flag/stdin + // errors surface immediately, without a login round-trip. + data, err := resolvePublishPayload(pubMessage, file, os.Stdin) + if err != nil { + return err + } + var claims *auth.TokenClaims var clientIDToUse string var accessToken string @@ -103,18 +166,6 @@ var publishCmd = &cobra.Command{ } } - var data []byte - - if file != "" { - content, err := os.ReadFile(file) - if err != nil { - return fmt.Errorf("failed to read file: %v", err) - } - data = content - } else { - data = []byte(pubMessage) - } - messageSize := int64(len(data)) if !IsAuthDisabled() { @@ -244,8 +295,8 @@ var publishCmd = &cobra.Command{ func init() { publishCmd.Flags().StringVar(&pubTopic, "topic", "", "Topic to publish to") - publishCmd.Flags().StringVar(&pubMessage, "message", "", "Message string to publish") - publishCmd.Flags().StringVar(&file, "file", "", "Path of the file to publish") + publishCmd.Flags().StringVar(&pubMessage, "message", "", "Message string to publish (reads stdin if neither --message nor --file is set)") + publishCmd.Flags().StringVar(&file, "file", "", "Path of the file to publish (use - for stdin)") publishCmd.Flags().StringVar(&serviceURL, "service-url", "", "Override the default proxy URL") publishCmd.Flags().Uint32Var(&pubExposeAmount, "expose-amount", 1, "Number of nodes to request from proxy") publishCmd.MarkFlagRequired("topic") //nolint:errcheck diff --git a/cmd/publish_test.go b/cmd/publish_test.go new file mode 100644 index 0000000..e55fa4e --- /dev/null +++ b/cmd/publish_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +// stdinFrom returns an *os.File whose read end yields the given content, +// simulating `echo "content" | mump2p publish ...`. An empty content string +// yields a pipe that is already closed on the write side. +func stdinFrom(t *testing.T, content string) *os.File { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + if content != "" { + if _, err := w.WriteString(content); err != nil { + t.Fatalf("write to pipe: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close pipe writer: %v", err) + } + t.Cleanup(func() { r.Close() }) + return r +} + +func TestResolvePublishPayload_MessageFlag(t *testing.T) { + got, err := resolvePublishPayload("hello", "", stdinFrom(t, "ignored")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "hello" { + t.Fatalf("want %q, got %q", "hello", got) + } +} + +func TestResolvePublishPayload_FileFlag(t *testing.T) { + path := filepath.Join(t.TempDir(), "payload.json") + if err := os.WriteFile(path, []byte(`{"a":1}`), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + got, err := resolvePublishPayload("", path, stdinFrom(t, "ignored")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != `{"a":1}` { + t.Fatalf("want %q, got %q", `{"a":1}`, got) + } +} + +func TestResolvePublishPayload_FileMissing(t *testing.T) { + _, err := resolvePublishPayload("", filepath.Join(t.TempDir(), "nope"), stdinFrom(t, "")) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestResolvePublishPayload_StdinWhenNoFlags(t *testing.T) { + got, err := resolvePublishPayload("", "", stdinFrom(t, "from stdin\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "from stdin\n" { + t.Fatalf("want %q, got %q", "from stdin\n", got) + } +} + +func TestResolvePublishPayload_FileDashReadsStdin(t *testing.T) { + got, err := resolvePublishPayload("", "-", stdinFrom(t, "dash")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "dash" { + t.Fatalf("want %q, got %q", "dash", got) + } +} + +func TestResolvePublishPayload_EmptyStdin(t *testing.T) { + _, err := resolvePublishPayload("", "", stdinFrom(t, "")) + if err == nil { + t.Fatal("expected error for empty stdin") + } +} + +func TestResolvePublishPayload_NilStdinIsTerminal(t *testing.T) { + // A nil stdin is treated as an interactive terminal: with no flags we + // must fail fast instead of blocking on input that can never arrive. + _, err := resolvePublishPayload("", "", nil) + if err == nil { + t.Fatal("expected error when no flags and stdin is a terminal") + } +} + +func TestIsTerminal(t *testing.T) { + if !isTerminal(nil) { + t.Fatal("nil file should be treated as a terminal") + } + if isTerminal(stdinFrom(t, "x")) { + t.Fatal("a pipe should not be treated as a terminal") + } +} diff --git a/docs/guide.md b/docs/guide.md index 8c2316a..29412e5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -252,6 +252,17 @@ To publish the contents of a file: mump2p publish --topic=your-topic-name --file=/path/to/your/file.json ``` +### Publish from stdin + +If you omit both `--message` and `--file`, the payload is read from standard input. This lets you pipe output from other commands straight into the network: + +```sh +echo "Your message content" | mump2p publish --topic=your-topic-name +curl -s https://api.example.com/status | mump2p publish --topic=status +``` + +You can also pass `--file=-` to read from stdin explicitly. Running `publish` with no payload flags in an interactive terminal (nothing piped in) returns an error instead of waiting for input. + Rate limits will be automatically applied based on your authentication token. --- From 5553bde27774f94d5eb73f12f9d6ecee37049423 Mon Sep 17 00:00:00 2001 From: erkancamli Date: Mon, 7 Sep 2026 16:55:23 +0000 Subject: [PATCH 2/3] fix(publish): track flag presence, bound stdin, stdin precedence per #92 Addresses review feedback: - distinguish an omitted flag from an explicit empty value via cmd.Flags().Changed; --message='' --file=x is rejected and --message='' no longer silently falls back to stdin - stdin now takes precedence over --message when it carries data (as specified in #92); an empty pipe falls back to --message - stdin is read through io.LimitReader capped at the account's MaxMessageSize (DefaultMaxMessageSize with --disable-auth), so an unbounded stream can no longer exhaust memory; the payload is read after the limit is known - shorter comments, doc comments on all touched functions - drop TestIsTerminal, add tests for explicit empty flags, precedence, fallback and the size limit --- README.md | 4 +- cmd/publish.go | 108 +++++++++++++++++++++++++--------- cmd/publish_test.go | 139 +++++++++++++++++++++++++++++++++++++------- docs/guide.md | 4 +- 4 files changed, 204 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index f30facc..aa83a5e 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ mump2p publish --topic test/data --file ./payload.json ### From stdin -When neither `--message` nor `--file` is given, the payload is read from stdin, so `publish` works like any other Unix tool in a pipe: +When stdin is a pipe or redirect, its contents are published (it takes precedence over `--message`), so `publish` works like any other Unix tool in a pipe: ```bash echo "Hello World" | mump2p publish --topic test @@ -135,7 +135,7 @@ cat ./payload.json | mump2p publish --topic test/data curl -s https://api.example.com/status | mump2p publish --topic status ``` -`--file -` also reads from stdin explicitly. +`--file -` also reads from stdin explicitly. Stdin is capped at your account's maximum message size. ## Debug Mode diff --git a/cmd/publish.go b/cmd/publish.go index 2f8c61b..5169f04 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -65,41 +65,85 @@ func shortMsgID(resp *pb.Response) string { return "" } -// resolvePublishPayload picks the message payload from, in order of -// preference: --message, --file, or stdin. Stdin is read to EOF and sent as -// a single message. It is used when neither flag is given (and stdin is not -// an interactive terminal), or when --file is "-", following the common -// Unix convention. -func resolvePublishPayload(message, filePath string, stdin *os.File) ([]byte, error) { - if message != "" { - return []byte(message), nil +// payloadSource describes where the publish payload should come from, as +// derived from the command flags. +type payloadSource struct { + message string + messageSet bool // --message was given explicitly (possibly empty) + filePath string + fileSet bool // --file was given explicitly (possibly empty) +} + +// validatePayloadFlags rejects flag combinations that cannot be resolved. +func validatePayloadFlags(src payloadSource) error { + if src.messageSet && src.fileSet { + return errors.New("only one of --message or --file should be used at a time") + } + if src.fileSet && src.filePath == "" { + return errors.New("--file requires a path (use - for stdin)") + } + return nil +} + +// resolvePublishPayload returns the payload from --file, stdin, or --message. +// Stdin (a pipe or redirect, never an interactive terminal) takes precedence +// over --message when it carries data, as requested in #92; --file=- reads +// stdin explicitly. Reads are capped at maxBytes to bound memory use. +func resolvePublishPayload(src payloadSource, stdin *os.File, maxBytes int64) ([]byte, error) { + if err := validatePayloadFlags(src); err != nil { + return nil, err } - if filePath != "" && filePath != "-" { - content, err := os.ReadFile(filePath) + if src.fileSet && src.filePath != "-" { + content, err := os.ReadFile(src.filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %v", err) } return content, nil } - if filePath != "-" && isTerminal(stdin) { - return nil, errors.New("no message provided: use --message, --file, or pipe data via stdin") + stdinAvailable := src.filePath == "-" || !isTerminal(stdin) + if stdinAvailable { + content, err := readStdinBounded(stdin, maxBytes) + if err != nil { + return nil, err + } + if len(content) > 0 { + return content, nil + } + if src.filePath == "-" || !src.messageSet { + return nil, errors.New("stdin is empty: nothing to publish") + } } - content, err := io.ReadAll(stdin) + if src.messageSet { + if src.message == "" { + return nil, errors.New("--message is empty: nothing to publish") + } + return []byte(src.message), nil + } + + return nil, errors.New("no message provided: use --message, --file, or pipe data via stdin") +} + +// readStdinBounded reads stdin to EOF, failing if it exceeds maxBytes. +func readStdinBounded(stdin *os.File, maxBytes int64) ([]byte, error) { + var r io.Reader = stdin + if maxBytes > 0 { + r = io.LimitReader(stdin, maxBytes+1) + } + content, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("failed to read stdin: %v", err) } - if len(content) == 0 { - return nil, errors.New("stdin is empty: nothing to publish") + if maxBytes > 0 && int64(len(content)) > maxBytes { + return nil, fmt.Errorf("stdin exceeds the maximum message size of %d bytes", maxBytes) } return content, nil } -// isTerminal reports whether f is an interactive terminal (as opposed to a -// pipe or a redirected file). A nil file is treated as a terminal so that -// we never block waiting for input that cannot arrive. +// isTerminal reports whether f is an interactive terminal. A nil or +// unreadable file counts as one so we never block on input that cannot arrive. func isTerminal(f *os.File) bool { if f == nil { return true @@ -116,8 +160,9 @@ var publishCmd = &cobra.Command{ Short: "Publish a message to the Optimum Network", Long: `Publish a message to the Optimum Network. -The payload comes from --message, --file, or stdin. When neither flag is -given, the payload is read from stdin so the command can be used in pipes: +The payload comes from --file, stdin, or --message. Piped stdin is used +whenever it carries data (it takes precedence over --message), so the +command composes in pipes: echo "hello" | mump2p publish --topic=test curl -s https://api.example.com/status | mump2p publish --topic=status @@ -127,20 +172,20 @@ Use --file=- to read from stdin explicitly.`, mump2p publish --topic=test/data --file=./payload.json cat payload.json | mump2p publish --topic=test/data`, RunE: func(cmd *cobra.Command, args []string) error { - if pubMessage != "" && file != "" { - return errors.New("only one of --message or --file should be used at a time") + src := payloadSource{ + message: pubMessage, + messageSet: cmd.Flags().Changed("message"), + filePath: file, + fileSet: cmd.Flags().Changed("file"), } - - // Resolve the payload before authenticating so that flag/stdin - // errors surface immediately, without a login round-trip. - data, err := resolvePublishPayload(pubMessage, file, os.Stdin) - if err != nil { + if err := validatePayloadFlags(src); err != nil { return err } var claims *auth.TokenClaims var clientIDToUse string var accessToken string + maxMessageSize := int64(config.DefaultMaxMessageSize) if !IsAuthDisabled() { authClient := auth.NewClient() @@ -159,6 +204,9 @@ Use --file=- to read from stdin explicitly.`, return fmt.Errorf("your account is inactive, please contact support") } clientIDToUse = claims.ClientID + if claims.MaxMessageSize > 0 { + maxMessageSize = claims.MaxMessageSize + } } else { clientIDToUse = GetClientID() if clientIDToUse == "" { @@ -166,6 +214,12 @@ Use --file=- to read from stdin explicitly.`, } } + // Read the payload only after the size limit is known so stdin is bounded. + data, err := resolvePublishPayload(src, os.Stdin, maxMessageSize) + if err != nil { + return err + } + messageSize := int64(len(data)) if !IsAuthDisabled() { diff --git a/cmd/publish_test.go b/cmd/publish_test.go index e55fa4e..7ed44fc 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -3,12 +3,13 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" ) -// stdinFrom returns an *os.File whose read end yields the given content, -// simulating `echo "content" | mump2p publish ...`. An empty content string -// yields a pipe that is already closed on the write side. +const testMaxBytes = 1 << 20 + +// stdinFrom returns the read end of a pipe pre-filled with content, simulating piped stdin. func stdinFrom(t *testing.T, content string) *os.File { t.Helper() r, w, err := os.Pipe() @@ -27,8 +28,33 @@ func stdinFrom(t *testing.T, content string) *os.File { return r } +// terminalStdin returns a char device (like an interactive terminal), or skips if unavailable. +func terminalStdin(t *testing.T) *os.File { + t.Helper() + f, err := os.Open(os.DevNull) + if err != nil { + t.Skipf("cannot open %s: %v", os.DevNull, err) + } + t.Cleanup(func() { f.Close() }) + if !isTerminal(f) { + t.Skipf("%s is not a char device on this platform", os.DevNull) + } + return f +} + +// messageSrc builds a payloadSource for an explicit --message. +func messageSrc(msg string) payloadSource { + return payloadSource{message: msg, messageSet: true} +} + +// fileSrc builds a payloadSource for an explicit --file. +func fileSrc(path string) payloadSource { + return payloadSource{filePath: path, fileSet: true} +} + +// TestResolvePublishPayload_MessageFlag checks --message is used when stdin is a terminal. func TestResolvePublishPayload_MessageFlag(t *testing.T) { - got, err := resolvePublishPayload("hello", "", stdinFrom(t, "ignored")) + got, err := resolvePublishPayload(messageSrc("hello"), terminalStdin(t), testMaxBytes) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -37,13 +63,48 @@ func TestResolvePublishPayload_MessageFlag(t *testing.T) { } } +// TestResolvePublishPayload_StdinOverridesMessage checks piped stdin wins over --message (#92). +func TestResolvePublishPayload_StdinOverridesMessage(t *testing.T) { + got, err := resolvePublishPayload(messageSrc("flag"), stdinFrom(t, "piped"), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "piped" { + t.Fatalf("want %q, got %q", "piped", got) + } +} + +// TestResolvePublishPayload_EmptyStdinFallsBackToMessage checks an empty pipe does not discard --message. +func TestResolvePublishPayload_EmptyStdinFallsBackToMessage(t *testing.T) { + got, err := resolvePublishPayload(messageSrc("flag"), stdinFrom(t, ""), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "flag" { + t.Fatalf("want %q, got %q", "flag", got) + } +} + +// TestResolvePublishPayload_ExplicitEmptyMessage checks an explicitly empty --message is an error, not a stdin fallback. +func TestResolvePublishPayload_ExplicitEmptyMessage(t *testing.T) { + _, err := resolvePublishPayload(messageSrc(""), terminalStdin(t), testMaxBytes) + if err == nil { + t.Fatal("expected error for explicit empty --message") + } + _, err = resolvePublishPayload(messageSrc(""), stdinFrom(t, ""), testMaxBytes) + if err == nil { + t.Fatal("expected error for explicit empty --message with empty stdin") + } +} + +// TestResolvePublishPayload_FileFlag checks --file contents are used as-is. func TestResolvePublishPayload_FileFlag(t *testing.T) { path := filepath.Join(t.TempDir(), "payload.json") if err := os.WriteFile(path, []byte(`{"a":1}`), 0o600); err != nil { t.Fatalf("write temp file: %v", err) } - got, err := resolvePublishPayload("", path, stdinFrom(t, "ignored")) + got, err := resolvePublishPayload(fileSrc(path), stdinFrom(t, "ignored"), testMaxBytes) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -52,15 +113,42 @@ func TestResolvePublishPayload_FileFlag(t *testing.T) { } } +// TestResolvePublishPayload_FileMissing checks a missing --file path is reported. func TestResolvePublishPayload_FileMissing(t *testing.T) { - _, err := resolvePublishPayload("", filepath.Join(t.TempDir(), "nope"), stdinFrom(t, "")) + _, err := resolvePublishPayload(fileSrc(filepath.Join(t.TempDir(), "nope")), stdinFrom(t, ""), testMaxBytes) if err == nil { t.Fatal("expected error for missing file") } } +// TestValidatePayloadFlags checks rejected flag combinations, including empty explicit values. +func TestValidatePayloadFlags(t *testing.T) { + cases := []struct { + name string + src payloadSource + }{ + {"message and file", payloadSource{message: "m", messageSet: true, filePath: "f", fileSet: true}}, + {"empty message and file", payloadSource{message: "", messageSet: true, filePath: "f", fileSet: true}}, + {"empty file path", payloadSource{filePath: "", fileSet: true}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := validatePayloadFlags(tc.src); err == nil { + t.Fatal("expected validation error") + } + if _, err := resolvePublishPayload(tc.src, stdinFrom(t, "data"), testMaxBytes); err == nil { + t.Fatal("expected resolver to reject invalid flags") + } + }) + } + if err := validatePayloadFlags(messageSrc("ok")); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestResolvePublishPayload_StdinWhenNoFlags checks stdin is used when no payload flags are set. func TestResolvePublishPayload_StdinWhenNoFlags(t *testing.T) { - got, err := resolvePublishPayload("", "", stdinFrom(t, "from stdin\n")) + got, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, "from stdin\n"), testMaxBytes) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -69,8 +157,9 @@ func TestResolvePublishPayload_StdinWhenNoFlags(t *testing.T) { } } +// TestResolvePublishPayload_FileDashReadsStdin checks --file=- reads stdin explicitly. func TestResolvePublishPayload_FileDashReadsStdin(t *testing.T) { - got, err := resolvePublishPayload("", "-", stdinFrom(t, "dash")) + got, err := resolvePublishPayload(fileSrc("-"), stdinFrom(t, "dash"), testMaxBytes) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -79,27 +168,37 @@ func TestResolvePublishPayload_FileDashReadsStdin(t *testing.T) { } } +// TestResolvePublishPayload_EmptyStdin checks empty stdin without a fallback is an error. func TestResolvePublishPayload_EmptyStdin(t *testing.T) { - _, err := resolvePublishPayload("", "", stdinFrom(t, "")) - if err == nil { + if _, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, ""), testMaxBytes); err == nil { t.Fatal("expected error for empty stdin") } + if _, err := resolvePublishPayload(fileSrc("-"), stdinFrom(t, ""), testMaxBytes); err == nil { + t.Fatal("expected error for empty stdin with --file=-") + } } -func TestResolvePublishPayload_NilStdinIsTerminal(t *testing.T) { - // A nil stdin is treated as an interactive terminal: with no flags we - // must fail fast instead of blocking on input that can never arrive. - _, err := resolvePublishPayload("", "", nil) - if err == nil { +// TestResolvePublishPayload_TerminalNoFlags checks the command fails fast instead of blocking on a terminal. +func TestResolvePublishPayload_TerminalNoFlags(t *testing.T) { + if _, err := resolvePublishPayload(payloadSource{}, terminalStdin(t), testMaxBytes); err == nil { t.Fatal("expected error when no flags and stdin is a terminal") } + if _, err := resolvePublishPayload(payloadSource{}, nil, testMaxBytes); err == nil { + t.Fatal("expected error when no flags and stdin is nil") + } } -func TestIsTerminal(t *testing.T) { - if !isTerminal(nil) { - t.Fatal("nil file should be treated as a terminal") +// TestResolvePublishPayload_StdinSizeLimit checks stdin larger than maxBytes is rejected. +func TestResolvePublishPayload_StdinSizeLimit(t *testing.T) { + const limit = 16 + if _, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, strings.Repeat("x", limit+1)), limit); err == nil { + t.Fatal("expected error for oversized stdin") + } + got, err := resolvePublishPayload(payloadSource{}, stdinFrom(t, strings.Repeat("x", limit)), limit) + if err != nil { + t.Fatalf("unexpected error at exact limit: %v", err) } - if isTerminal(stdinFrom(t, "x")) { - t.Fatal("a pipe should not be treated as a terminal") + if len(got) != limit { + t.Fatalf("want %d bytes, got %d", limit, len(got)) } } diff --git a/docs/guide.md b/docs/guide.md index 29412e5..93d09cc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -254,14 +254,14 @@ mump2p publish --topic=your-topic-name --file=/path/to/your/file.json ### Publish from stdin -If you omit both `--message` and `--file`, the payload is read from standard input. This lets you pipe output from other commands straight into the network: +When standard input is a pipe or redirect, its contents are published; piped data takes precedence over `--message`, while `--file` always wins. This lets you pipe output from other commands straight into the network: ```sh echo "Your message content" | mump2p publish --topic=your-topic-name curl -s https://api.example.com/status | mump2p publish --topic=status ``` -You can also pass `--file=-` to read from stdin explicitly. Running `publish` with no payload flags in an interactive terminal (nothing piped in) returns an error instead of waiting for input. +You can also pass `--file=-` to read from stdin explicitly. Running `publish` with no payload flags in an interactive terminal (nothing piped in) returns an error instead of waiting for input, and stdin larger than your account's maximum message size is rejected before it is buffered. Rate limits will be automatically applied based on your authentication token. From 767166708d0edc249e7e5ec670af34f6ffd0523a Mon Sep 17 00:00:00 2001 From: erkancamli Date: Mon, 7 Sep 2026 17:04:42 +0000 Subject: [PATCH 3/3] docs(publish): state the exact payload precedence and empty-stdin fallback --- README.md | 2 +- docs/guide.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aa83a5e..5bc80cc 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ mump2p publish --topic test/data --file ./payload.json ### From stdin -When stdin is a pipe or redirect, its contents are published (it takes precedence over `--message`), so `publish` works like any other Unix tool in a pipe: +When stdin is a pipe or redirect, its contents are published. Non-empty stdin takes precedence over `--message`; an empty pipe falls back to `--message`. This makes `publish` work like any other Unix tool in a pipe: ```bash echo "Hello World" | mump2p publish --topic test diff --git a/docs/guide.md b/docs/guide.md index 93d09cc..a2ea123 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -254,14 +254,14 @@ mump2p publish --topic=your-topic-name --file=/path/to/your/file.json ### Publish from stdin -When standard input is a pipe or redirect, its contents are published; piped data takes precedence over `--message`, while `--file` always wins. This lets you pipe output from other commands straight into the network: +When standard input is a pipe or redirect, its contents are published. Non-empty piped data takes precedence over `--message`; an empty pipe falls back to `--message` if given, otherwise the command errors. `--file=path` reads that file (it cannot be combined with `--message`), and `--file=-` selects stdin explicitly. This lets you pipe output from other commands straight into the network: ```sh echo "Your message content" | mump2p publish --topic=your-topic-name curl -s https://api.example.com/status | mump2p publish --topic=status ``` -You can also pass `--file=-` to read from stdin explicitly. Running `publish` with no payload flags in an interactive terminal (nothing piped in) returns an error instead of waiting for input, and stdin larger than your account's maximum message size is rejected before it is buffered. +You can also pass `--file=-` to read from stdin explicitly. Running `publish` with no payload flags in an interactive terminal (nothing piped in) returns an error instead of waiting for input, and stdin larger than your account's maximum message size is rejected without buffering the entire input. Rate limits will be automatically applied based on your authentication token.