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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to the FlatRun CLI are documented in this file.

## [0.4.0-beta.4] - 2026-08-20

### Added

- Push a local directory to a deployment in one request, with optional destination cleanup
- File operations from the agent are available through the generated command catalogue

## [0.3.0] - 2026-08-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.0
0.4.0-beta.4
9 changes: 9 additions & 0 deletions internal/command/endpoints_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,8 @@ func runDeployment(args []string, stdout, stderr io.Writer) int {
return runDeploymentExec(args[1:], stdout, stderr)
case "image":
return runDeploymentImage(args[1:], stdout, stderr)
case "files":
return runDeploymentFiles(args[1:], stdout, stderr)
case "create":
return runDeploymentCreate(args[1:], stdout, stderr)
case "delete":
Expand All @@ -610,6 +612,25 @@ func runDeployment(args []string, stdout, stderr io.Writer) int {
}
}

func runDeploymentFiles(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 || args[0] != "push" {
_, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment files push DEPLOYMENT SOURCE DESTINATION [--delete]")
return 2
}
deleteMissing := false
return runClientCommand(clientCommand{
name: "deployment files push",
usage: "Usage: flatrun deployment files push DEPLOYMENT SOURCE DESTINATION [--delete]",
positionals: 3,
flags: func(fs *flag.FlagSet) {
fs.BoolVar(&deleteMissing, "delete", false, "Delete destination files missing from the source")
},
run: func(ctx context.Context, client *flatrun.Client, values []string) ([]byte, error) {
return client.PushDeploymentFiles(ctx, values[0], values[1], values[2], deleteMissing)
},
}, args[1:], stdout, stderr)
}

func runDeploymentList(args []string, stdout, stderr io.Writer) int {
return runClientCommand(clientCommand{
name: "deployment list",
Expand Down
85 changes: 85 additions & 0 deletions internal/command/root_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package command

import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -12,6 +15,88 @@ import (
"testing"
)

func TestDeploymentFilesPushSendsOneArchiveRequest(t *testing.T) {
source := t.TempDir()
if err := os.Mkdir(filepath.Join(source, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "index.html"), []byte("home"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "assets", "app.js"), []byte("app"), 0o644); err != nil {
t.Fatal(err)
}

requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.Method != http.MethodPost || r.URL.Path != "/api/deployments/whilesmart/files-push" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
reader, err := r.MultipartReader()
if err != nil {
t.Error(err)
return
}
fields := map[string]string{}
files := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Error(err)
return
}
if part.FormName() != "archive" {
value, _ := io.ReadAll(part)
fields[part.FormName()] = string(value)
continue
}
gzipReader, err := gzip.NewReader(part)
if err != nil {
t.Error(err)
return
}
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Error(err)
return
}
if header.Typeflag == tar.TypeReg {
content, _ := io.ReadAll(tarReader)
files[header.Name] = string(content)
}
}
}
if fields["destination"] != "sites/" || fields["delete"] != "true" {
t.Errorf("fields = %#v", fields)
}
if files["index.html"] != "home" || files["assets/app.js"] != "app" {
t.Errorf("files = %#v", files)
}
_, _ = w.Write([]byte(`{"item":{"message":"Pushed 2 files","destination":"sites/","deleted":true,"files":2}}`))
}))
defer server.Close()

t.Setenv("FLATRUN_URL", server.URL)
t.Setenv("FLATRUN_TOKEN", "secret")
var stdout, stderr bytes.Buffer
code := Run([]string{"deployment", "files", "push", "whilesmart", source, "sites/", "--delete"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
if requests != 1 {
t.Fatalf("requests = %d", requests)
}
}

func TestConfigureSetAndList(t *testing.T) {
t.Setenv("FLATRUN_CONFIG", filepath.Join(t.TempDir(), "config.json"))

Expand Down
1 change: 1 addition & 0 deletions internal/command/shaped.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var shapedCommands = []endpoint{
{family: "deployment", op: "action", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}},
{family: "deployment", op: "exec", method: "POST", path: "/deployments/:name/exec", args: []string{"name"}, flags: "[SERVICE] -- COMMAND"},
{family: "deployment", op: "image set", method: "PUT", path: "/deployments/:name/compose", args: []string{"name", "service", "image"}, flags: "--deploy --operation"},
{family: "deployment", op: "files push", method: "POST", path: "/deployments/:name/files-push", args: []string{"name", "source", "destination"}, flags: "--delete"},

{family: "image", op: "list", method: "GET", path: "/images"},
{family: "image", op: "pull", method: "POST", path: "/images/pull", args: []string{"image"}, flags: "--credential-id"},
Expand Down
4 changes: 4 additions & 0 deletions internal/flatrun/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any) ([]by
req.Header.Set("Authorization", "Bearer "+c.token)
}

return c.doRequest(req)
}

func (c *Client) doRequest(req *http.Request) ([]byte, error) {
if c.Debug != nil {
_, _ = fmt.Fprintf(c.Debug, "-> %s %s\n", req.Method, req.URL.String())
}
Expand Down
137 changes: 137 additions & 0 deletions internal/flatrun/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package flatrun

import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)

func (c *Client) PushDeploymentFiles(ctx context.Context, deployment, source, destination string, deleteMissing bool) ([]byte, error) {
info, err := os.Stat(source)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("source must be a directory")
}

reader, writer := io.Pipe()
multipartWriter := multipart.NewWriter(writer)
errCh := make(chan error, 1)
go func() {
errCh <- writePushBody(multipartWriter, writer, source, destination, deleteMissing)
}()

apiBase := strings.TrimRight(c.baseURL, "/")
if !strings.HasSuffix(apiBase, "/api") {
apiBase += "/api"
}
path := "/deployments/" + url.PathEscape(deployment) + "/files-push"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, reader)
if err != nil {
_ = reader.Close()
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
if c.token != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Authorization header setting is duplicated here and in the main request logic (as seen in internal/flatrun/client.go). To ensure consistency and simplify future changes (e.g., changing auth schemes), consider extracting this into a private helper method on the Client struct.

Suggested change
if c.token != "" {
c.setAuthHeader(req)

req.Header.Set("Authorization", "Bearer "+c.token)
}

data, requestErr := c.doRequest(req)
archiveErr := <-errCh
if requestErr != nil {
return nil, requestErr
}
if archiveErr != nil {
return nil, archiveErr
}
return data, nil
}

func writePushBody(multipartWriter *multipart.Writer, pipe *io.PipeWriter, source, destination string, deleteMissing bool) error {
fail := func(err error) error {
_ = pipe.CloseWithError(err)
return err
}
if err := multipartWriter.WriteField("destination", destination); err != nil {
return fail(err)
}
if err := multipartWriter.WriteField("delete", strconv.FormatBool(deleteMissing)); err != nil {
return fail(err)
}
part, err := multipartWriter.CreateFormFile("archive", "content.tar.gz")
if err != nil {
return fail(err)
}
gzipWriter := gzip.NewWriter(part)
tarWriter := tar.NewWriter(gzipWriter)
if err := writeDirectoryArchive(tarWriter, source); err != nil {
return fail(err)
}
if err := tarWriter.Close(); err != nil {
return fail(err)
}
if err := gzipWriter.Close(); err != nil {
return fail(err)
}
if err := multipartWriter.Close(); err != nil {
return fail(err)
}
return pipe.Close()
}

func writeDirectoryArchive(writer *tar.Writer, source string) error {
return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == source {
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symbolic links are not supported: %s", path)
}
info, err := entry.Info()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimization: You can check if the entry is a symbolic link using entry.Type() before calling entry.Info(). Since entry.Type() is typically available directly from the directory traversal (e.g., via getdents on Linux), this can avoid an unnecessary stat syscall for every file in the directory tree, significantly improving performance for large source directories.

Suggested change
info, err := entry.Info()
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("symbolic links are not supported: %s", path)
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }

if err != nil {
return err
}
relative, err := filepath.Rel(source, path)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relative)
if entry.IsDir() {
header.Name += "/"
}
if err := writer.WriteHeader(header); err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}
Loading