From 2342beff104f7788224bc9d924b199e0299a9c06 Mon Sep 17 00:00:00 2001 From: 28allday Date: Sun, 7 Jun 2026 15:13:25 +0100 Subject: [PATCH] Send files and folders headlessly with -to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `omarchy-send -to ` now sends files without the TUI — from scripts, cron, or AI agents — alongside or instead of -message. Folders are sent whole with their structure recreated on the receiver, and a result line is printed per file. Paths without -to still open the TUI quick-send as before. The new SendFilesSync mirrors SendMessageSync: it blocks until the batch lands and returns errors directly (including ErrPinRequired). A missing path is a hard error up front — a script wants a non-zero exit, not a silent skip — while a file that vanishes mid-batch is skipped and reported, like the TUI path. The -dir override also rides through the new config.ExpandHome. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 20 +++-- cmd/omarchy-send/main.go | 95 +++++++++++++++------ internal/client/client.go | 68 +++++++++++++++ internal/client/files_sync_test.go | 128 +++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 31 deletions(-) create mode 100644 internal/client/files_sync_test.go diff --git a/README.md b/README.md index a7a6a10..1c98f7f 100644 --- a/README.md +++ b/README.md @@ -101,24 +101,30 @@ omarchy-send --no-notify # don't raise desktop notifications on incomin ### Headless send (no TUI) -Send a one-off message to a peer by name, with no terminal UI — handy from -scripts, cron, or an SSH session with no TTY: +Send a one-off message, files, or folders to a peer by name, with no terminal +UI — handy from scripts, cron, AI agents, or an SSH session with no TTY: ```sh omarchy-send --to "Strong Onion" --message "hello" omarchy-send --to "Strong Onion" --message "deploy finished" --wait 20s omarchy-send --to "Strong Onion" --message "hi" --send-pin 2468 # if the peer requires a PIN +omarchy-send --to "Strong Onion" report.pdf photos/ # files and folders +omarchy-send --to "Strong Onion" --message "build log attached" build.log ``` The target is matched against the peer's display name, case-insensitively. The command discovers the peer over multicast and, like the TUI, directly probes your known peers and online Tailscale peers (waiting up to `--wait`, default 15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a -valid `--to` target from a script too. It sends the message, prints a one-line -result, and exits non-zero if the peer isn't found or the send fails. It starts -discovery only — not the receiver — so it's safe to run while another -`omarchy-send` instance is up. Both `--to` and `--message` are required; file -sending stays in the TUI for now. +valid `--to` target from a script too. It sends the message and/or files, +prints a result line per file, and exits non-zero if the peer isn't found or +the send fails. It starts discovery only — not the receiver — so it's safe to +run while another `omarchy-send` instance is up. + +`--to` requires a `--message`, file/folder paths, or both (flags must come +before the paths). A folder is sent whole and its structure recreated on the +receiver. Paths *without* `--to` instead open the TUI with them pre-staged +(quick-send) — that form needs a TTY. ### Sending files diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index f01a59b..e6db536 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -162,7 +162,7 @@ func main() { noNotify = flag.Bool("no-notify", false, "don't raise desktop notifications on incoming messages/files") // Headless one-shot send (no TUI): -to -message . - toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); requires -message") + toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); combine with -message and/or file paths") messageFlag = flag.String("message", "", "headless send: plain-text message to send to -to") sendPINFlag = flag.String("send-pin", "", "headless send: PIN to present if the target peer requires one") waitFlag = flag.Duration("wait", 15*time.Second, "headless send: how long to wait for the target peer to be discovered") @@ -187,7 +187,7 @@ func main() { cfg.Port = *portFlag } if *dirFlag != "" { - cfg.ReceiveDir = *dirFlag + cfg.ReceiveDir = config.ExpandHome(*dirFlag) } if *pinFlag != "" { cfg.PIN = *pinFlag @@ -202,29 +202,27 @@ func main() { cfg.NoNotify = true } - // Headless one-shot send: resolve the target by alias over discovery, send, - // and exit — no TUI, no terminal required. Suitable for scripts and cron. + // Headless one-shot send: resolve the target by alias over discovery, send + // a message and/or positional file/folder paths, and exit — no TUI, no + // terminal required. Suitable for scripts, cron, and AI agents. if *toFlag != "" || *messageFlag != "" { - if *toFlag == "" || *messageFlag == "" { - fmt.Fprintln(os.Stderr, "headless send needs both -to and -message ") + paths := absPaths(flag.Args()) + if *toFlag == "" { + fmt.Fprintln(os.Stderr, "headless send needs -to (plus -message and/or file paths)") os.Exit(2) } - os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag)) + if *messageFlag == "" && len(paths) == 0 { + fmt.Fprintln(os.Stderr, "headless send needs -message , file/folder paths, or both") + os.Exit(2) + } + os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag, paths)) } // Quick-send: any positional arguments are file/folder paths to send (the // Nautilus right-click integration calls `omarchy-send `). Open the // TUI with them pre-staged, on the device list. if args := flag.Args(); len(args) > 0 { - paths := make([]string, 0, len(args)) - for _, a := range args { - if abs, err := filepath.Abs(a); err == nil { - paths = append(paths, abs) - } else { - paths = append(paths, a) - } - } - os.Exit(runQuickSend(cfg, paths)) + os.Exit(runQuickSend(cfg, absPaths(args))) } ctx, cancel := context.WithCancel(context.Background()) @@ -321,12 +319,27 @@ func runQuickSend(cfg config.Config, paths []string) int { return 0 } +// absPaths resolves each path to absolute (best-effort; a path that fails to +// resolve is passed through as-is and will fail with a clear error later). +func absPaths(args []string) []string { + paths := make([]string, 0, len(args)) + for _, a := range args { + if abs, err := filepath.Abs(a); err == nil { + paths = append(paths, abs) + } else { + paths = append(paths, a) + } + } + return paths +} + // runHeadlessSend discovers the peer whose alias matches target (case- -// insensitively), sends it a plain-text message, and returns a process exit -// code. It deliberately starts only discovery — not the HTTP receiver — so it -// can run alongside an already-running instance without fighting over the -// listen port. Status goes to stderr; the success line goes to stdout. -func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration) int { +// insensitively), sends it the given file/folder paths and/or a plain-text +// message, and returns a process exit code. It deliberately starts only +// discovery — not the HTTP receiver — so it can run alongside an +// already-running instance without fighting over the listen port. Status goes +// to stderr; the success lines go to stdout. +func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration, paths []string) int { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -364,16 +377,50 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti } sender := client.New(cfg.DeviceInfo()) - if err := sender.SendMessageSync(peer, message, sendPIN); err != nil { + + reportErr := func(err error) { switch { case errors.Is(err, transfer.ErrPinRequired): fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias) default: fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err) } - return 1 } - fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP) + if len(paths) > 0 { + fmt.Fprintf(os.Stderr, "Sending to %q (%s)… (waiting for the peer to accept)\n", peer.Info.Alias, peer.IP) + sent := 0 + err := sender.SendFilesSync(ctx, peer, paths, sendPIN, func(name string, size int64) { + sent++ + fmt.Printf(" sent %s (%s)\n", name, humanBytes(size)) + }) + if err != nil { + reportErr(err) + return 1 + } + fmt.Printf("%d file(s) sent to %q (%s).\n", sent, peer.Info.Alias, peer.IP) + } + + if message != "" { + if err := sender.SendMessageSync(peer, message, sendPIN); err != nil { + reportErr(err) + return 1 + } + fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP) + } return 0 } + +// humanBytes renders a byte count as a short human-readable size. +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/client/client.go b/internal/client/client.go index fd3fc0d..42f1d5f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -137,6 +137,74 @@ func (s *Sender) SendMessageSync(peer discovery.Peer, text, pin string) error { return err } +// SendFilesSync uploads the given file/folder paths to peer and blocks until +// the whole batch is done, returning the error directly — including +// transfer.ErrPinRequired when the peer needs a PIN. Unlike Send it reports +// nothing on Events(); it exists for the headless one-shot send path, where +// there is no TUI to consume events (the progress events uploadFile emits are +// harmlessly dropped). onDone, when non-nil, is called after each file lands, +// so the CLI can print per-file progress lines. +func (s *Sender) SendFilesSync(ctx context.Context, peer discovery.Peer, paths []string, pin string, onDone func(name string, size int64)) error { + // Explicitly named paths that don't exist are a hard error up front — in + // the TUI a stat failure is just an event on one staged entry, but a script + // passing a wrong path wants a non-zero exit, not a silent skip. + for _, p := range paths { + if _, err := os.Stat(p); err != nil { + return err + } + } + items := s.expand(paths) + if len(items) == 0 { + return errors.New("nothing to send: no readable files under the given paths") + } + + files := make(map[string]protocol.FileMetadata, len(items)) + pathByID := make(map[string]string, len(items)) + for _, it := range items { + id := randID() + files[id] = protocol.FileMetadata{ + ID: id, + FileName: it.name, + Size: it.size, + FileType: mimeType(it.path), + } + pathByID[id] = it.path + } + + base := s.url(peer) + prepResp, err := s.prepareUpload(ctx, base, files, pin) + if err != nil { + return err + } + + // An empty token map (204) means the peer accepted but wants nothing + // uploaded — e.g. it already has the files. That's success: the loop below + // simply finds no tokens to push. + var skipped []string + for id, token := range prepResp.Files { + meta := files[id] + key := prepResp.SessionID + ":" + id + if err := s.uploadFile(ctx, base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil { + // A failure to open a local file is specific to that file (it + // vanished or lost permissions since staging) — skip it and keep + // the batch going, like the TUI path does. Anything else means the + // peer/session is gone and can't be resumed, so abort the batch. + if errors.Is(err, errOpen) { + skipped = append(skipped, meta.FileName) + continue + } + return fmt.Errorf("upload %q: %w", meta.FileName, err) + } + if onDone != nil { + onDone(meta.FileName, meta.Size) + } + } + if len(skipped) > 0 { + return fmt.Errorf("could not read: %s", strings.Join(skipped, ", ")) + } + return nil +} + func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { // A new transfer to a peer supersedes any still-running one to the same // peer: cancel it so a half-finished old batch can't carry on once the user diff --git a/internal/client/files_sync_test.go b/internal/client/files_sync_test.go new file mode 100644 index 0000000..a060afd --- /dev/null +++ b/internal/client/files_sync_test.go @@ -0,0 +1,128 @@ +package client + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "omarchy-send/internal/discovery" + "omarchy-send/internal/protocol" + "omarchy-send/internal/server" + "omarchy-send/internal/transfer" +) + +// SendFilesSync delivers a file and a folder over loopback HTTP and returns +// nil, with the contents arriving intact — the synchronous path used by +// headless `-to ` sends. +func TestSendFilesSyncSuccess(t *testing.T) { + recvDir := t.TempDir() + recvInfo := protocol.DeviceInfo{ + Alias: "recv", Version: protocol.ProtocolVersion, Port: 53993, Protocol: "http", + } + srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatalf("server start: %v", err) + } + go func() { + for range srv.Transfers() { + } + }() + time.Sleep(50 * time.Millisecond) + + // One loose file plus a folder, so the relative-name path is covered too. + srcDir := t.TempDir() + loose := filepath.Join(srcDir, "report.pdf") + content := bytes.Repeat([]byte("headless-payload-"), 5000) // ~85KB + if err := os.WriteFile(loose, content, 0o644); err != nil { + t.Fatalf("write src: %v", err) + } + folder := filepath.Join(srcDir, "Trip") + if err := os.MkdirAll(filepath.Join(folder, "day1"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + nested := filepath.Join(folder, "day1", "img.jpg") + if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil { + t.Fatalf("write nested: %v", err) + } + + sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"}) + peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"} + + var done []string + err := sender.SendFilesSync(ctx, peer, []string{loose, folder}, "", func(name string, size int64) { + done = append(done, name) + }) + if err != nil { + t.Fatalf("SendFilesSync: %v", err) + } + if len(done) != 2 { + t.Fatalf("onDone called %d times, want 2 (%v)", len(done), done) + } + + got, err := os.ReadFile(filepath.Join(recvDir, "report.pdf")) + if err != nil { + t.Fatalf("read received: %v", err) + } + if !bytes.Equal(got, content) { + t.Fatalf("content mismatch: %d vs %d bytes", len(got), len(content)) + } + if _, err := os.Stat(filepath.Join(recvDir, "Trip", "day1", "img.jpg")); err != nil { + t.Fatalf("folder structure not recreated: %v", err) + } +} + +// A missing path is a hard error before any network work — a script passing a +// wrong path wants a non-zero exit, not a silent skip. +func TestSendFilesSyncMissingPath(t *testing.T) { + sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"}) + peer := discovery.Peer{Info: protocol.DeviceInfo{Protocol: "http", Port: 1}, IP: "127.0.0.1"} + err := sender.SendFilesSync(context.Background(), peer, []string{"/no/such/file"}, "", nil) + if err == nil || !os.IsNotExist(errors.Unwrap(err)) && !os.IsNotExist(err) { + t.Fatalf("err = %v, want not-exist", err) + } +} + +// A peer that requires a PIN rejects a PIN-less file send with ErrPinRequired, +// so the CLI can tell the user to pass -send-pin; with the PIN it goes through. +func TestSendFilesSyncPinRequired(t *testing.T) { + recvDir := t.TempDir() + recvInfo := protocol.DeviceInfo{ + Alias: "recv", Version: protocol.ProtocolVersion, Port: 53994, Protocol: "http", + } + srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true, PIN: "2468"}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatalf("server start: %v", err) + } + go func() { + for range srv.Transfers() { + } + }() + time.Sleep(50 * time.Millisecond) + + src := filepath.Join(t.TempDir(), "note.txt") + if err := os.WriteFile(src, []byte("pinned"), 0o644); err != nil { + t.Fatalf("write src: %v", err) + } + + sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"}) + peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"} + + err := sender.SendFilesSync(ctx, peer, []string{src}, "", nil) + if !errors.Is(err, transfer.ErrPinRequired) { + t.Fatalf("err = %v, want ErrPinRequired", err) + } + if err := sender.SendFilesSync(ctx, peer, []string{src}, "2468", nil); err != nil { + t.Fatalf("SendFilesSync with PIN: %v", err) + } + if _, err := os.Stat(filepath.Join(recvDir, "note.txt")); err != nil { + t.Fatalf("file not received: %v", err) + } +}