From 8237de089ce2436524b1373d16dd2813d5908e4e Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Thu, 30 Apr 2026 16:20:59 +0800 Subject: [PATCH 1/6] feat: add `zeabur file list/read` commands for uploaded project files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow reading uploaded project files via CLI, enabling the skilled agent to access user uploads through bash + skill instead of SDK tools. - `zeabur file list [path]` — list files in an upload - `zeabur file read ` — read file content DES-536 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + internal/cmd/file/file.go | 22 ++++++++++++ internal/cmd/file/list/list.go | 65 ++++++++++++++++++++++++++++++++++ internal/cmd/file/read/read.go | 50 ++++++++++++++++++++++++++ internal/cmd/root/root.go | 2 ++ pkg/api/file.go | 39 ++++++++++++++++++++ pkg/api/interface.go | 6 ++++ 7 files changed, 185 insertions(+) create mode 100644 internal/cmd/file/file.go create mode 100644 internal/cmd/file/list/list.go create mode 100644 internal/cmd/file/read/read.go create mode 100644 pkg/api/file.go diff --git a/.gitignore b/.gitignore index 824e7e7..9855015 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ dist/ # cli running assets zeabur.zip +.env.local diff --git a/internal/cmd/file/file.go b/internal/cmd/file/file.go new file mode 100644 index 0000000..8277263 --- /dev/null +++ b/internal/cmd/file/file.go @@ -0,0 +1,22 @@ +package file + +import ( + "github.com/spf13/cobra" + + fileListCmd "github.com/zeabur/cli/internal/cmd/file/list" + fileReadCmd "github.com/zeabur/cli/internal/cmd/file/read" + "github.com/zeabur/cli/internal/cmdutil" +) + +// NewCmdFile creates the file command. +func NewCmdFile(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "file ", + Short: "Manage uploaded files", + } + + cmd.AddCommand(fileListCmd.NewCmdList(f)) + cmd.AddCommand(fileReadCmd.NewCmdRead(f)) + + return cmd +} diff --git a/internal/cmd/file/list/list.go b/internal/cmd/file/list/list.go new file mode 100644 index 0000000..583aec2 --- /dev/null +++ b/internal/cmd/file/list/list.go @@ -0,0 +1,65 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/zeabur/cli/internal/cmdutil" +) + +type Options struct { + uploadID string + path string +} + +// NewCmdList creates the file list command. +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "list [path]", + Short: "List files in an upload", + Aliases: []string{"ls"}, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.uploadID = args[0] + opts.path = "" + if len(args) > 1 { + opts.path = args[1] + } + return runList(f, opts) + }, + } + + return cmd +} + +func runList(f *cmdutil.Factory, opts *Options) error { + var pathPtr *string + if opts.path != "" { + pathPtr = &opts.path + } + + files, err := f.ApiClient.ListUploadFiles(context.Background(), opts.uploadID, pathPtr) + if err != nil { + return fmt.Errorf("list files failed: %w", err) + } + + if len(files) == 0 { + if f.JSON { + return f.Printer.JSON([]any{}) + } + f.Log.Infof("No files found") + return nil + } + + if f.JSON { + return f.Printer.JSON(files) + } + + fmt.Println(strings.Join(files, "\n")) + + return nil +} diff --git a/internal/cmd/file/read/read.go b/internal/cmd/file/read/read.go new file mode 100644 index 0000000..2c6e625 --- /dev/null +++ b/internal/cmd/file/read/read.go @@ -0,0 +1,50 @@ +package read + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/zeabur/cli/internal/cmdutil" +) + +type Options struct { + uploadID string + path string +} + +// NewCmdRead creates the file read command. +func NewCmdRead(f *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "read ", + Short: "Read a file from an upload", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.uploadID = args[0] + opts.path = args[1] + return runRead(f, opts) + }, + } + + return cmd +} + +func runRead(f *cmdutil.Factory, opts *Options) error { + content, err := f.ApiClient.ReadUploadFile(context.Background(), opts.uploadID, opts.path) + if err != nil { + return fmt.Errorf("read file failed: %w", err) + } + + if f.JSON { + return f.Printer.JSON(map[string]string{ + "path": opts.path, + "content": content, + }) + } + + fmt.Print(content) + + return nil +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 33cc1a5..49adf43 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -20,6 +20,7 @@ import ( deploymentCmd "github.com/zeabur/cli/internal/cmd/deployment" domainCmd "github.com/zeabur/cli/internal/cmd/domain" emailCmd "github.com/zeabur/cli/internal/cmd/email" + fileCmd "github.com/zeabur/cli/internal/cmd/file" profileCmd "github.com/zeabur/cli/internal/cmd/profile" projectCmd "github.com/zeabur/cli/internal/cmd/project" serverCmd "github.com/zeabur/cli/internal/cmd/server" @@ -161,6 +162,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman cmd.AddCommand(completionCmd.NewCmdCompletion(f)) cmd.AddCommand(variableCmd.NewCmdVariable(f)) cmd.AddCommand(emailCmd.NewCmdEmail(f)) + cmd.AddCommand(fileCmd.NewCmdFile(f)) cmd.AddCommand(aihubCmd.NewCmdAIHub(f)) // replace default help command with our custom one that supports --all diff --git a/pkg/api/file.go b/pkg/api/file.go new file mode 100644 index 0000000..7ac9a07 --- /dev/null +++ b/pkg/api/file.go @@ -0,0 +1,39 @@ +package api + +import ( + "context" +) + +// ListUploadFiles lists files in an uploaded project. +func (c *client) ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) { + var query struct { + Files []string `graphql:"files(uploadID: $uploadID, path: $path)"` + } + + err := c.Query(ctx, &query, V{ + "uploadID": ObjectID(uploadID), + "path": path, + }) + if err != nil { + return nil, err + } + + return query.Files, nil +} + +// ReadUploadFile reads the content of a file in an uploaded project. +func (c *client) ReadUploadFile(ctx context.Context, uploadID string, path string) (string, error) { + var query struct { + FileContent string `graphql:"fileContent(uploadID: $uploadID, path: $path)"` + } + + err := c.Query(ctx, &query, V{ + "uploadID": ObjectID(uploadID), + "path": path, + }) + if err != nil { + return "", err + } + + return query.FileContent, nil +} diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 81e33af..4fff231 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -23,6 +23,7 @@ type Client interface { AIHubAPI ZSendAPI RegisteredDomainAPI + FileAPI } type ( @@ -178,6 +179,11 @@ type ( UpdateRegistrantContact(ctx context.Context, registeredDomainID string, input model.UpdateRegistrantContactInput) error } + FileAPI interface { + ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) + ReadUploadFile(ctx context.Context, uploadID string, path string) (string, error) + } + ZSendAPI interface { GetZSendOnboardingStatus(ctx context.Context) (*model.ZSendOnboardingStatus, error) GetZSendUserStatus(ctx context.Context) (*model.ZSendUserStatus, error) From dd53f4bd84360cb2c84e282d59a8cd6fe5008c69 Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Thu, 30 Apr 2026 16:42:07 +0800 Subject: [PATCH 2/6] fix: add interactive fallback for file list/read commands Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/file/list/list.go | 17 +++++++++++++++-- internal/cmd/file/read/read.go | 32 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/cmd/file/list/list.go b/internal/cmd/file/list/list.go index 583aec2..5f68f39 100644 --- a/internal/cmd/file/list/list.go +++ b/internal/cmd/file/list/list.go @@ -22,9 +22,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { Use: "list [path]", Short: "List files in an upload", Aliases: []string{"ls"}, - Args: cobra.RangeArgs(1, 2), + Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - opts.uploadID = args[0] + if len(args) > 0 { + opts.uploadID = args[0] + } opts.path = "" if len(args) > 1 { opts.path = args[1] @@ -37,6 +39,17 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } func runList(f *cmdutil.Factory, opts *Options) error { + if opts.uploadID == "" { + if !f.Interactive { + return fmt.Errorf("upload-id is required") + } + id, err := f.Prompter.Input("Enter upload ID:", "") + if err != nil { + return err + } + opts.uploadID = id + } + var pathPtr *string if opts.path != "" { pathPtr = &opts.path diff --git a/internal/cmd/file/read/read.go b/internal/cmd/file/read/read.go index 2c6e625..0987aa9 100644 --- a/internal/cmd/file/read/read.go +++ b/internal/cmd/file/read/read.go @@ -20,10 +20,14 @@ func NewCmdRead(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "read ", Short: "Read a file from an upload", - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - opts.uploadID = args[0] - opts.path = args[1] + if len(args) > 0 { + opts.uploadID = args[0] + } + if len(args) > 1 { + opts.path = args[1] + } return runRead(f, opts) }, } @@ -32,6 +36,28 @@ func NewCmdRead(f *cmdutil.Factory) *cobra.Command { } func runRead(f *cmdutil.Factory, opts *Options) error { + if opts.uploadID == "" { + if !f.Interactive { + return fmt.Errorf("upload-id is required") + } + id, err := f.Prompter.Input("Enter upload ID:", "") + if err != nil { + return err + } + opts.uploadID = id + } + + if opts.path == "" { + if !f.Interactive { + return fmt.Errorf("path is required") + } + p, err := f.Prompter.Input("Enter file path:", "") + if err != nil { + return err + } + opts.path = p + } + content, err := f.ApiClient.ReadUploadFile(context.Background(), opts.uploadID, opts.path) if err != nil { return fmt.Errorf("read file failed: %w", err) From 8c2ca393e423fc0cba07fb8ac3563ddef4081b59 Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Thu, 30 Apr 2026 16:51:00 +0800 Subject: [PATCH 3/6] fix: move opts into RunE scope and use cmd.Context() Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/file/list/list.go | 11 ++++------- internal/cmd/file/read/read.go | 10 ++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/internal/cmd/file/list/list.go b/internal/cmd/file/list/list.go index 5f68f39..e73301e 100644 --- a/internal/cmd/file/list/list.go +++ b/internal/cmd/file/list/list.go @@ -1,7 +1,6 @@ package list import ( - "context" "fmt" "strings" @@ -16,29 +15,27 @@ type Options struct { // NewCmdList creates the file list command. func NewCmdList(f *cmdutil.Factory) *cobra.Command { - opts := &Options{} - cmd := &cobra.Command{ Use: "list [path]", Short: "List files in an upload", Aliases: []string{"ls"}, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { + opts := &Options{} if len(args) > 0 { opts.uploadID = args[0] } - opts.path = "" if len(args) > 1 { opts.path = args[1] } - return runList(f, opts) + return runList(cmd, f, opts) }, } return cmd } -func runList(f *cmdutil.Factory, opts *Options) error { +func runList(cmd *cobra.Command, f *cmdutil.Factory, opts *Options) error { if opts.uploadID == "" { if !f.Interactive { return fmt.Errorf("upload-id is required") @@ -55,7 +52,7 @@ func runList(f *cmdutil.Factory, opts *Options) error { pathPtr = &opts.path } - files, err := f.ApiClient.ListUploadFiles(context.Background(), opts.uploadID, pathPtr) + files, err := f.ApiClient.ListUploadFiles(cmd.Context(), opts.uploadID, pathPtr) if err != nil { return fmt.Errorf("list files failed: %w", err) } diff --git a/internal/cmd/file/read/read.go b/internal/cmd/file/read/read.go index 0987aa9..6e4b730 100644 --- a/internal/cmd/file/read/read.go +++ b/internal/cmd/file/read/read.go @@ -1,7 +1,6 @@ package read import ( - "context" "fmt" "github.com/spf13/cobra" @@ -15,27 +14,26 @@ type Options struct { // NewCmdRead creates the file read command. func NewCmdRead(f *cmdutil.Factory) *cobra.Command { - opts := &Options{} - cmd := &cobra.Command{ Use: "read ", Short: "Read a file from an upload", Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { + opts := &Options{} if len(args) > 0 { opts.uploadID = args[0] } if len(args) > 1 { opts.path = args[1] } - return runRead(f, opts) + return runRead(cmd, f, opts) }, } return cmd } -func runRead(f *cmdutil.Factory, opts *Options) error { +func runRead(cmd *cobra.Command, f *cmdutil.Factory, opts *Options) error { if opts.uploadID == "" { if !f.Interactive { return fmt.Errorf("upload-id is required") @@ -58,7 +56,7 @@ func runRead(f *cmdutil.Factory, opts *Options) error { opts.path = p } - content, err := f.ApiClient.ReadUploadFile(context.Background(), opts.uploadID, opts.path) + content, err := f.ApiClient.ReadUploadFile(cmd.Context(), opts.uploadID, opts.path) if err != nil { return fmt.Errorf("read file failed: %w", err) } From a836fcfba138ca30fa6846196cf0c42daa233ff5 Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Thu, 30 Apr 2026 18:30:49 +0800 Subject: [PATCH 4/6] refactor: replace file list/read with file pull Download entire uploaded project to local filesystem instead of remote querying. Agent can then use native bash tools (ls, cat, grep). - `zeabur file pull [target-dir]` - Recursively downloads all text files, skips binaries - Maintains directory structure Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/file/file.go | 6 +-- internal/cmd/file/list/list.go | 75 ---------------------------------- internal/cmd/file/pull/pull.go | 52 +++++++++++++++++++++++ internal/cmd/file/read/read.go | 74 --------------------------------- pkg/api/file.go | 69 +++++++++++++++++++++++++++++++ pkg/api/interface.go | 1 + 6 files changed, 124 insertions(+), 153 deletions(-) delete mode 100644 internal/cmd/file/list/list.go create mode 100644 internal/cmd/file/pull/pull.go delete mode 100644 internal/cmd/file/read/read.go diff --git a/internal/cmd/file/file.go b/internal/cmd/file/file.go index 8277263..9265ed9 100644 --- a/internal/cmd/file/file.go +++ b/internal/cmd/file/file.go @@ -3,8 +3,7 @@ package file import ( "github.com/spf13/cobra" - fileListCmd "github.com/zeabur/cli/internal/cmd/file/list" - fileReadCmd "github.com/zeabur/cli/internal/cmd/file/read" + filePullCmd "github.com/zeabur/cli/internal/cmd/file/pull" "github.com/zeabur/cli/internal/cmdutil" ) @@ -15,8 +14,7 @@ func NewCmdFile(f *cmdutil.Factory) *cobra.Command { Short: "Manage uploaded files", } - cmd.AddCommand(fileListCmd.NewCmdList(f)) - cmd.AddCommand(fileReadCmd.NewCmdRead(f)) + cmd.AddCommand(filePullCmd.NewCmdPull(f)) return cmd } diff --git a/internal/cmd/file/list/list.go b/internal/cmd/file/list/list.go deleted file mode 100644 index e73301e..0000000 --- a/internal/cmd/file/list/list.go +++ /dev/null @@ -1,75 +0,0 @@ -package list - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct { - uploadID string - path string -} - -// NewCmdList creates the file list command. -func NewCmdList(f *cmdutil.Factory) *cobra.Command { - cmd := &cobra.Command{ - Use: "list [path]", - Short: "List files in an upload", - Aliases: []string{"ls"}, - Args: cobra.RangeArgs(0, 2), - RunE: func(cmd *cobra.Command, args []string) error { - opts := &Options{} - if len(args) > 0 { - opts.uploadID = args[0] - } - if len(args) > 1 { - opts.path = args[1] - } - return runList(cmd, f, opts) - }, - } - - return cmd -} - -func runList(cmd *cobra.Command, f *cmdutil.Factory, opts *Options) error { - if opts.uploadID == "" { - if !f.Interactive { - return fmt.Errorf("upload-id is required") - } - id, err := f.Prompter.Input("Enter upload ID:", "") - if err != nil { - return err - } - opts.uploadID = id - } - - var pathPtr *string - if opts.path != "" { - pathPtr = &opts.path - } - - files, err := f.ApiClient.ListUploadFiles(cmd.Context(), opts.uploadID, pathPtr) - if err != nil { - return fmt.Errorf("list files failed: %w", err) - } - - if len(files) == 0 { - if f.JSON { - return f.Printer.JSON([]any{}) - } - f.Log.Infof("No files found") - return nil - } - - if f.JSON { - return f.Printer.JSON(files) - } - - fmt.Println(strings.Join(files, "\n")) - - return nil -} diff --git a/internal/cmd/file/pull/pull.go b/internal/cmd/file/pull/pull.go new file mode 100644 index 0000000..35fb563 --- /dev/null +++ b/internal/cmd/file/pull/pull.go @@ -0,0 +1,52 @@ +package pull + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/zeabur/cli/internal/cmdutil" +) + +// NewCmdPull creates the file pull command. +func NewCmdPull(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "pull [target-dir]", + Short: "Download uploaded project files to local directory", + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + uploadID := "" + targetDir := "." + if len(args) > 0 { + uploadID = args[0] + } + if len(args) > 1 { + targetDir = args[1] + } + return runPull(cmd, f, uploadID, targetDir) + }, + } + + return cmd +} + +func runPull(cmd *cobra.Command, f *cmdutil.Factory, uploadID string, targetDir string) error { + if uploadID == "" { + if !f.Interactive { + return fmt.Errorf("upload-id is required") + } + id, err := f.Prompter.Input("Enter upload ID:", "") + if err != nil { + return err + } + uploadID = id + } + + count, err := f.ApiClient.PullUploadFiles(cmd.Context(), uploadID, targetDir) + if err != nil { + return fmt.Errorf("pull files failed: %w", err) + } + + f.Log.Infof("Pulled %d files to %s", count, targetDir) + + return nil +} diff --git a/internal/cmd/file/read/read.go b/internal/cmd/file/read/read.go deleted file mode 100644 index 6e4b730..0000000 --- a/internal/cmd/file/read/read.go +++ /dev/null @@ -1,74 +0,0 @@ -package read - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct { - uploadID string - path string -} - -// NewCmdRead creates the file read command. -func NewCmdRead(f *cmdutil.Factory) *cobra.Command { - cmd := &cobra.Command{ - Use: "read ", - Short: "Read a file from an upload", - Args: cobra.RangeArgs(0, 2), - RunE: func(cmd *cobra.Command, args []string) error { - opts := &Options{} - if len(args) > 0 { - opts.uploadID = args[0] - } - if len(args) > 1 { - opts.path = args[1] - } - return runRead(cmd, f, opts) - }, - } - - return cmd -} - -func runRead(cmd *cobra.Command, f *cmdutil.Factory, opts *Options) error { - if opts.uploadID == "" { - if !f.Interactive { - return fmt.Errorf("upload-id is required") - } - id, err := f.Prompter.Input("Enter upload ID:", "") - if err != nil { - return err - } - opts.uploadID = id - } - - if opts.path == "" { - if !f.Interactive { - return fmt.Errorf("path is required") - } - p, err := f.Prompter.Input("Enter file path:", "") - if err != nil { - return err - } - opts.path = p - } - - content, err := f.ApiClient.ReadUploadFile(cmd.Context(), opts.uploadID, opts.path) - if err != nil { - return fmt.Errorf("read file failed: %w", err) - } - - if f.JSON { - return f.Printer.JSON(map[string]string{ - "path": opts.path, - "content": content, - }) - } - - fmt.Print(content) - - return nil -} diff --git a/pkg/api/file.go b/pkg/api/file.go index 7ac9a07..709f41b 100644 --- a/pkg/api/file.go +++ b/pkg/api/file.go @@ -2,8 +2,23 @@ package api import ( "context" + "fmt" + "os" + "path/filepath" + "strings" ) +var binaryExtensions = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".bmp": true, + ".ico": true, ".svg": true, ".webp": true, ".avif": true, + ".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true, + ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".7z": true, ".rar": true, + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, + ".exe": true, ".dll": true, ".so": true, ".dylib": true, ".wasm": true, + ".mp3": true, ".mp4": true, ".wav": true, ".ogg": true, ".webm": true, + ".bin": true, ".dat": true, ".db": true, ".sqlite": true, +} + // ListUploadFiles lists files in an uploaded project. func (c *client) ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) { var query struct { @@ -37,3 +52,57 @@ func (c *client) ReadUploadFile(ctx context.Context, uploadID string, path strin return query.FileContent, nil } + +// PullUploadFiles downloads all files from an upload to a local directory. +func (c *client) PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, error) { + return c.pullDir(ctx, uploadID, "", targetDir) +} + +func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string, localDir string) (int, error) { + var pathPtr *string + if remotePath != "" { + pathPtr = &remotePath + } + + entries, err := c.ListUploadFiles(ctx, uploadID, pathPtr) + if err != nil { + return 0, fmt.Errorf("list %q: %w", remotePath, err) + } + + count := 0 + for _, entry := range entries { + fullRemote := remotePath + entry + fullLocal := filepath.Join(localDir, fullRemote) + + if strings.HasSuffix(entry, "/") { + if err := os.MkdirAll(fullLocal, 0o755); err != nil { + return count, fmt.Errorf("mkdir %q: %w", fullLocal, err) + } + n, err := c.pullDir(ctx, uploadID, fullRemote, localDir) + count += n + if err != nil { + return count, err + } + } else { + ext := strings.ToLower(filepath.Ext(entry)) + if binaryExtensions[ext] { + count++ + continue + } + content, err := c.ReadUploadFile(ctx, uploadID, fullRemote) + if err != nil { + return count, fmt.Errorf("read %q: %w", fullRemote, err) + } + dir := filepath.Dir(fullLocal) + if err := os.MkdirAll(dir, 0o755); err != nil { + return count, fmt.Errorf("mkdir %q: %w", dir, err) + } + if err := os.WriteFile(fullLocal, []byte(content), 0o644); err != nil { + return count, fmt.Errorf("write %q: %w", fullLocal, err) + } + count++ + } + } + + return count, nil +} diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 4fff231..3788833 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -182,6 +182,7 @@ type ( FileAPI interface { ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) ReadUploadFile(ctx context.Context, uploadID string, path string) (string, error) + PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, error) } ZSendAPI interface { From 0cb34fc0ef022b50d0d08943123f86049dae332a Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Thu, 30 Apr 2026 18:38:16 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20validate=20input,=20prevent=20path=20traversal,=20f?= =?UTF-8?q?ix=20binary=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/file/pull/pull.go | 6 +++++- pkg/api/file.go | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/cmd/file/pull/pull.go b/internal/cmd/file/pull/pull.go index 35fb563..ba5485c 100644 --- a/internal/cmd/file/pull/pull.go +++ b/internal/cmd/file/pull/pull.go @@ -2,6 +2,7 @@ package pull import ( "fmt" + "strings" "github.com/spf13/cobra" "github.com/zeabur/cli/internal/cmdutil" @@ -38,7 +39,10 @@ func runPull(cmd *cobra.Command, f *cmdutil.Factory, uploadID string, targetDir if err != nil { return err } - uploadID = id + uploadID = strings.TrimSpace(id) + if uploadID == "" { + return fmt.Errorf("upload-id is required") + } } count, err := f.ApiClient.PullUploadFiles(cmd.Context(), uploadID, targetDir) diff --git a/pkg/api/file.go b/pkg/api/file.go index 709f41b..b4dc76d 100644 --- a/pkg/api/file.go +++ b/pkg/api/file.go @@ -59,6 +59,8 @@ func (c *client) PullUploadFiles(ctx context.Context, uploadID string, targetDir } func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string, localDir string) (int, error) { + baseDir := filepath.Clean(localDir) + var pathPtr *string if remotePath != "" { pathPtr = &remotePath @@ -72,7 +74,11 @@ func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string count := 0 for _, entry := range entries { fullRemote := remotePath + entry - fullLocal := filepath.Join(localDir, fullRemote) + fullLocal := filepath.Join(baseDir, fullRemote) + + if !strings.HasPrefix(filepath.Clean(fullLocal), baseDir) { + return count, fmt.Errorf("path traversal detected: %q", fullRemote) + } if strings.HasSuffix(entry, "/") { if err := os.MkdirAll(fullLocal, 0o755); err != nil { @@ -86,7 +92,6 @@ func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string } else { ext := strings.ToLower(filepath.Ext(entry)) if binaryExtensions[ext] { - count++ continue } content, err := c.ReadUploadFile(ctx, uploadID, fullRemote) From e7c7180fd32d44ae0f0a82775c3b680015750761 Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Sun, 3 May 2026 16:26:03 +0800 Subject: [PATCH 6/6] fix: use filepath.Rel for path traversal check, report skipped binaries - Replace HasPrefix with filepath.Rel to prevent /foo vs /foobar bypass - Return skipped binary count and show warning to user Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/file/pull/pull.go | 5 ++++- pkg/api/file.go | 30 +++++++++++++++++------------- pkg/api/interface.go | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/internal/cmd/file/pull/pull.go b/internal/cmd/file/pull/pull.go index ba5485c..9f3103c 100644 --- a/internal/cmd/file/pull/pull.go +++ b/internal/cmd/file/pull/pull.go @@ -45,12 +45,15 @@ func runPull(cmd *cobra.Command, f *cmdutil.Factory, uploadID string, targetDir } } - count, err := f.ApiClient.PullUploadFiles(cmd.Context(), uploadID, targetDir) + count, skipped, err := f.ApiClient.PullUploadFiles(cmd.Context(), uploadID, targetDir) if err != nil { return fmt.Errorf("pull files failed: %w", err) } f.Log.Infof("Pulled %d files to %s", count, targetDir) + if skipped > 0 { + f.Log.Infof("Skipped %d binary files (images, fonts, archives, etc.)", skipped) + } return nil } diff --git a/pkg/api/file.go b/pkg/api/file.go index b4dc76d..c73b4aa 100644 --- a/pkg/api/file.go +++ b/pkg/api/file.go @@ -54,11 +54,12 @@ func (c *client) ReadUploadFile(ctx context.Context, uploadID string, path strin } // PullUploadFiles downloads all files from an upload to a local directory. -func (c *client) PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, error) { +// Returns (pulled count, skipped binary count, error). +func (c *client) PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, int, error) { return c.pullDir(ctx, uploadID, "", targetDir) } -func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string, localDir string) (int, error) { +func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string, localDir string) (int, int, error) { baseDir := filepath.Clean(localDir) var pathPtr *string @@ -68,46 +69,49 @@ func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string entries, err := c.ListUploadFiles(ctx, uploadID, pathPtr) if err != nil { - return 0, fmt.Errorf("list %q: %w", remotePath, err) + return 0, 0, fmt.Errorf("list %q: %w", remotePath, err) } - count := 0 + count, skipped := 0, 0 for _, entry := range entries { fullRemote := remotePath + entry fullLocal := filepath.Join(baseDir, fullRemote) - if !strings.HasPrefix(filepath.Clean(fullLocal), baseDir) { - return count, fmt.Errorf("path traversal detected: %q", fullRemote) + rel, relErr := filepath.Rel(baseDir, filepath.Clean(fullLocal)) + if relErr != nil || strings.HasPrefix(rel, "..") { + return count, skipped, fmt.Errorf("path traversal detected: %q", fullRemote) } if strings.HasSuffix(entry, "/") { if err := os.MkdirAll(fullLocal, 0o755); err != nil { - return count, fmt.Errorf("mkdir %q: %w", fullLocal, err) + return count, skipped, fmt.Errorf("mkdir %q: %w", fullLocal, err) } - n, err := c.pullDir(ctx, uploadID, fullRemote, localDir) + n, s, err := c.pullDir(ctx, uploadID, fullRemote, localDir) count += n + skipped += s if err != nil { - return count, err + return count, skipped, err } } else { ext := strings.ToLower(filepath.Ext(entry)) if binaryExtensions[ext] { + skipped++ continue } content, err := c.ReadUploadFile(ctx, uploadID, fullRemote) if err != nil { - return count, fmt.Errorf("read %q: %w", fullRemote, err) + return count, skipped, fmt.Errorf("read %q: %w", fullRemote, err) } dir := filepath.Dir(fullLocal) if err := os.MkdirAll(dir, 0o755); err != nil { - return count, fmt.Errorf("mkdir %q: %w", dir, err) + return count, skipped, fmt.Errorf("mkdir %q: %w", dir, err) } if err := os.WriteFile(fullLocal, []byte(content), 0o644); err != nil { - return count, fmt.Errorf("write %q: %w", fullLocal, err) + return count, skipped, fmt.Errorf("write %q: %w", fullLocal, err) } count++ } } - return count, nil + return count, skipped, nil } diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 3788833..38734f1 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -182,7 +182,7 @@ type ( FileAPI interface { ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) ReadUploadFile(ctx context.Context, uploadID string, path string) (string, error) - PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, error) + PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, int, error) } ZSendAPI interface {