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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ tx login # Authenticate with TexOps
tx init # Initialize a project in the current directory
tx build # Build all documents
tx build <name> # Build a specific document
tx build --live # Watch for changes and rebuild automatically
tx status # Show project status
tx token create [--name "CI"] # Create an API token
tx token list # List API tokens
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.9.0
github.com/jessevdk/go-flags v1.6.1
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
Expand Down
76 changes: 52 additions & 24 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"archive/tar"
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -103,7 +104,7 @@ func (c *APIClient) SetHTTPClient(hc *http.Client) {
c.httpClient = hc
}

func (c *APIClient) CreateProject(name, distVersion, projectKey string) (CreateProjectResponse, error) {
func (c *APIClient) CreateProject(ctx context.Context, name, distVersion, projectKey string) (CreateProjectResponse, error) {
payload := map[string]string{
"name": name,
"distribution_version": distVersion,
Expand All @@ -116,7 +117,7 @@ func (c *APIClient) CreateProject(name, distVersion, projectKey string) (CreateP
return CreateProjectResponse{}, err
}

req, err := http.NewRequest("POST", c.baseURL+"/api/projects", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/projects", bytes.NewReader(body))
if err != nil {
return CreateProjectResponse{}, err
}
Expand All @@ -140,7 +141,7 @@ func (c *APIClient) CreateProject(name, distVersion, projectKey string) (CreateP
return result, nil
}

func (c *APIClient) GetSession(projectID, distributionVersion string) (SessionResponse, error) {
func (c *APIClient) GetSession(ctx context.Context, projectID, distributionVersion string) (SessionResponse, error) {
u := fmt.Sprintf("%s/api/projects/%s/session", c.baseURL, projectID)

body, err := json.Marshal(map[string]string{
Expand All @@ -150,7 +151,7 @@ func (c *APIClient) GetSession(projectID, distributionVersion string) (SessionRe
return SessionResponse{}, err
}

req, err := http.NewRequest("POST", u, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body))
if err != nil {
return SessionResponse{}, err
}
Expand Down Expand Up @@ -418,7 +419,7 @@ func (c *InstanceClient) SetHTTPClient(hc *http.Client) {
c.httpClient = hc
}

func (c *InstanceClient) Sync(projectID string, files []FileEntry) (SyncResult, error) {
func (c *InstanceClient) Sync(ctx context.Context, projectID string, files []FileEntry) (SyncResult, error) {
body := struct {
Files []FileEntry `json:"files"`
}{Files: files}
Expand All @@ -430,7 +431,7 @@ func (c *InstanceClient) Sync(projectID string, files []FileEntry) (SyncResult,

u := fmt.Sprintf("%s/projects/%s/sync", c.baseURL, projectID)

req, err := http.NewRequest("POST", u, bytes.NewReader(data))
req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(data))
if err != nil {
return SyncResult{}, err
}
Expand All @@ -454,12 +455,12 @@ func (c *InstanceClient) Sync(projectID string, files []FileEntry) (SyncResult,
return result, nil
}

func (c *InstanceClient) Upload(projectID, projectDir string, filePaths []string, onProgress func(sent, total int64)) error {
func (c *InstanceClient) Upload(ctx context.Context, projectID, projectDir string, filePaths []string, onProgress func(sent, total int64)) error {
if len(filePaths) == 0 {
return nil
}

tarData, err := createTar(projectDir, filePaths)
tarData, err := createTar(ctx, projectDir, filePaths)
if err != nil {
return err
}
Expand All @@ -479,7 +480,7 @@ func (c *InstanceClient) Upload(projectID, projectDir string, filePaths []string
}
}

req, err := http.NewRequest("POST", u, body)
req, err := http.NewRequestWithContext(ctx, "POST", u, body)
if err != nil {
return err
}
Expand All @@ -499,10 +500,10 @@ func (c *InstanceClient) Upload(projectID, projectDir string, filePaths []string
return nil
}

func (c *InstanceClient) UploadRaw(projectID string, tarData []byte) error {
func (c *InstanceClient) UploadRaw(ctx context.Context, projectID string, tarData []byte) error {
u := fmt.Sprintf("%s/projects/%s/upload", c.baseURL, projectID)

req, err := http.NewRequest("POST", u, bytes.NewReader(tarData))
req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(tarData))
if err != nil {
return err
}
Expand All @@ -521,7 +522,7 @@ func (c *InstanceClient) UploadRaw(projectID string, tarData []byte) error {
return nil
}

func (c *InstanceClient) BuildWithArgs(projectID, main, directory, distVersion, compiler string, args []string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) {
func (c *InstanceClient) BuildWithArgs(ctx context.Context, projectID, main, directory, distVersion, compiler string, args []string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) {
payload := map[string]any{
"main": main,
"distribution_version": distVersion,
Expand All @@ -542,7 +543,7 @@ func (c *InstanceClient) BuildWithArgs(projectID, main, directory, distVersion,

u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID)

req, err := http.NewRequest("POST", u, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body))
if err != nil {
return BuildDoneEvent{}, err
}
Expand All @@ -562,7 +563,7 @@ func (c *InstanceClient) BuildWithArgs(projectID, main, directory, distVersion,
return ParseSSEStream(resp.Body, onLog)
}

func (c *InstanceClient) Build(projectID, main, directory, distVersion, compiler string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) {
func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, distVersion, compiler string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) {
payload := map[string]any{
"main": main,
"distribution_version": distVersion,
Expand All @@ -580,7 +581,7 @@ func (c *InstanceClient) Build(projectID, main, directory, distVersion, compiler

u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID)

req, err := http.NewRequest("POST", u, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body))
if err != nil {
return BuildDoneEvent{}, err
}
Expand All @@ -600,13 +601,13 @@ func (c *InstanceClient) Build(projectID, main, directory, distVersion, compiler
return ParseSSEStream(resp.Body, onLog)
}

func (c *InstanceClient) DownloadPDF(projectID, buildID, outputPath string) error {
func (c *InstanceClient) DownloadPDF(ctx context.Context, projectID, buildID, outputPath string) error {
if !validIDPattern.MatchString(buildID) {
return fmt.Errorf("invalid build ID format")
}
u := fmt.Sprintf("%s/projects/%s/builds/%s/output", c.baseURL, projectID, buildID)

req, err := http.NewRequest("GET", u, nil)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return err
}
Expand All @@ -622,21 +623,45 @@ func (c *InstanceClient) DownloadPDF(projectID, buildID, outputPath string) erro
return fmt.Errorf("PDF download failed (%d)", resp.StatusCode)
}

f, err := os.Create(outputPath)
return writeFilePreserveInode(resp.Body, outputPath)
}

func writeFilePreserveInode(r io.Reader, outputPath string) error {
tmp, err := os.CreateTemp(filepath.Dir(outputPath), ".tx-download-*.tmp")
if err != nil {
return err
}
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
tmpPath := tmp.Name()
defer os.Remove(tmpPath)

_, copyErr := io.Copy(tmp, r)
closeErr := tmp.Close()
if copyErr != nil {
_ = os.Remove(outputPath)
return copyErr
}
if closeErr != nil {
_ = os.Remove(outputPath)
return closeErr
}
return nil

tmpRead, err := os.Open(tmpPath)
if err != nil {
return err
}

out, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
_ = tmpRead.Close()
return err
}

_, copyErr = io.Copy(out, tmpRead)
_ = tmpRead.Close()
closeErr = out.Close()

if copyErr != nil {
return copyErr
}
return closeErr
}

func ParseSSEStream(reader io.Reader, onLog func(string)) (BuildDoneEvent, error) {
Expand Down Expand Up @@ -699,11 +724,14 @@ func extractSSEMessage(data string) string {
return data
}

func createTar(dir string, filePaths []string) ([]byte, error) {
func createTar(ctx context.Context, dir string, filePaths []string) ([]byte, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)

for _, fp := range filePaths {
if ctx.Err() != nil {
return nil, ctx.Err()
}
data, err := os.ReadFile(filepath.Join(dir, fp))
if err != nil {
return nil, err
Expand Down
132 changes: 132 additions & 0 deletions internal/cli/client_download_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package cli_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/texops/tx/internal/cli"
)

func pdfServer(t *testing.T, content []byte) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, err := w.Write(content)
require.NoError(t, err)
}))
}

func failingPDFServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "build not found"})
}))
}

func TestDownloadPDF_CreatesNewFile(t *testing.T) {
t.Run("creates file when it does not exist", func(t *testing.T) {
content := []byte("%PDF-1.4 test content")
srv := pdfServer(t, content)
defer srv.Close()

dir := t.TempDir()
outputPath := filepath.Join(dir, "output.pdf")

client := cli.NewInstanceClient(srv.URL, "test-jwt")
err := client.DownloadPDF(t.Context(), "prj_abc123", "bld_abc123", outputPath)
require.NoError(t, err)

got, err := os.ReadFile(outputPath)
require.NoError(t, err)
assert.Equal(t, content, got)
})
}

func TestDownloadPDF_PreservesInode(t *testing.T) {
t.Run("preserves inode when file already exists", func(t *testing.T) {
dir := t.TempDir()
outputPath := filepath.Join(dir, "output.pdf")

require.NoError(t, os.WriteFile(outputPath, []byte("old content"), 0o600))

infoBefore, err := os.Stat(outputPath)
require.NoError(t, err)
inoBefore := infoBefore.Sys().(*syscall.Stat_t).Ino

content := []byte("%PDF-1.4 new content")
srv := pdfServer(t, content)
defer srv.Close()

client := cli.NewInstanceClient(srv.URL, "test-jwt")
err = client.DownloadPDF(t.Context(), "prj_abc123", "bld_abc123", outputPath)
require.NoError(t, err)

got, err := os.ReadFile(outputPath)
require.NoError(t, err)
assert.Equal(t, content, got)

infoAfter, err := os.Stat(outputPath)
require.NoError(t, err)
inoAfter := infoAfter.Sys().(*syscall.Stat_t).Ino

assert.Equal(t, inoBefore, inoAfter, "inode should be preserved after download")
})
}

func TestWriteFilePreserveInode_TempFileLocality(t *testing.T) {
t.Run("creates temp file in target directory", func(t *testing.T) {
dir := t.TempDir()
outputPath := filepath.Join(dir, "output.pdf")

content := []byte("%PDF-1.4 test content")
err := cli.WriteFilePreserveInode(strings.NewReader(string(content)), outputPath)
require.NoError(t, err)

got, err := os.ReadFile(outputPath)
require.NoError(t, err)
assert.Equal(t, content, got)

entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, e := range entries {
assert.False(t, strings.HasSuffix(e.Name(), ".tmp"), "temp file should be cleaned up: %s", e.Name())
}
})
}

func TestDownloadPDF_FailureLeavesOriginalIntact(t *testing.T) {
t.Run("server error leaves original file intact", func(t *testing.T) {
dir := t.TempDir()
outputPath := filepath.Join(dir, "output.pdf")
originalContent := []byte("original PDF content")

require.NoError(t, os.WriteFile(outputPath, originalContent, 0o600))

srv := failingPDFServer(t)
defer srv.Close()

client := cli.NewInstanceClient(srv.URL, "test-jwt")
err := client.DownloadPDF(t.Context(), "prj_abc123", "bld_abc123", outputPath)
require.Error(t, err)

got, err := os.ReadFile(outputPath)
require.NoError(t, err)
assert.Equal(t, originalContent, got, "original file should be untouched after failed download")

entries, dirErr := os.ReadDir(dir)
require.NoError(t, dirErr)
for _, e := range entries {
assert.False(t, strings.HasSuffix(e.Name(), ".tmp"), "temp file should be cleaned up: %s", e.Name())
}
})
}
Loading
Loading