Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ dist/

# cli running assets
zeabur.zip
.env.local
20 changes: 20 additions & 0 deletions internal/cmd/file/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package file

import (
"github.com/spf13/cobra"

filePullCmd "github.com/zeabur/cli/internal/cmd/file/pull"
"github.com/zeabur/cli/internal/cmdutil"
)

// NewCmdFile creates the file command.
func NewCmdFile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "file <command>",
Short: "Manage uploaded files",
}

cmd.AddCommand(filePullCmd.NewCmdPull(f))

return cmd
}
59 changes: 59 additions & 0 deletions internal/cmd/file/pull/pull.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package pull

import (
"fmt"
"strings"

"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 <upload-id> [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 = strings.TrimSpace(id)
if uploadID == "" {
return fmt.Errorf("upload-id is required")
}
}
Comment thread
leechenghsiu marked this conversation as resolved.

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
}
2 changes: 2 additions & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions pkg/api/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 {
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
}

// PullUploadFiles downloads all files from an upload to a local directory.
// 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, int, error) {
baseDir := filepath.Clean(localDir)

var pathPtr *string
if remotePath != "" {
pathPtr = &remotePath
}

entries, err := c.ListUploadFiles(ctx, uploadID, pathPtr)
if err != nil {
return 0, 0, fmt.Errorf("list %q: %w", remotePath, err)
}

count, skipped := 0, 0
for _, entry := range entries {
fullRemote := remotePath + entry
fullLocal := filepath.Join(baseDir, 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)
}

Comment thread
leechenghsiu marked this conversation as resolved.
if strings.HasSuffix(entry, "/") {
if err := os.MkdirAll(fullLocal, 0o755); err != nil {
return count, skipped, fmt.Errorf("mkdir %q: %w", fullLocal, err)
}
n, s, err := c.pullDir(ctx, uploadID, fullRemote, localDir)
count += n
skipped += s
if err != nil {
return count, skipped, err
}
} else {
ext := strings.ToLower(filepath.Ext(entry))
if binaryExtensions[ext] {
skipped++
continue
Comment thread
leechenghsiu marked this conversation as resolved.
}
content, err := c.ReadUploadFile(ctx, uploadID, fullRemote)
if err != nil {
return count, skipped, fmt.Errorf("read %q: %w", fullRemote, err)
}
dir := filepath.Dir(fullLocal)
if err := os.MkdirAll(dir, 0o755); err != nil {
return count, skipped, fmt.Errorf("mkdir %q: %w", dir, err)
}
if err := os.WriteFile(fullLocal, []byte(content), 0o644); err != nil {
return count, skipped, fmt.Errorf("write %q: %w", fullLocal, err)
}
count++
}
}

return count, skipped, nil
}
7 changes: 7 additions & 0 deletions pkg/api/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Client interface {
AIHubAPI
ZSendAPI
RegisteredDomainAPI
FileAPI
}

type (
Expand Down Expand Up @@ -178,6 +179,12 @@ 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)
PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, int, error)
}

ZSendAPI interface {
GetZSendOnboardingStatus(ctx context.Context) (*model.ZSendOnboardingStatus, error)
GetZSendUserStatus(ctx context.Context) (*model.ZSendUserStatus, error)
Expand Down
Loading