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
20 changes: 4 additions & 16 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ type InitCmd struct {
type BuildCmd struct {
Args struct{ Names []string } `positional-args:"true"`
NoCache bool `long:"no-cache" description:"Clear build cache and rebuild from scratch"`
Yes bool `short:"y" long:"yes" description:"Skip upload size confirmation prompt"`
UI *UI `no-flag:"true"`
}

Expand Down Expand Up @@ -516,12 +515,12 @@ func (cmd *BuildCmd) Execute(args []string) error {
if ui == nil {
ui = defaultUI()
}
return RunBuild(dir, cmd.Args.Names, cmd.NoCache, cmd.Yes, ui)
return RunBuild(dir, cmd.Args.Names, cmd.NoCache, ui)
}

var errInitDeclined = errors.New("no project config found; run `tx init` to set up your project")

func runBuild(dir string, names []string, noCache bool, skipConfirm bool, ui *UI) error {
func runBuild(dir string, names []string, noCache bool, ui *UI) error {
buildStart := time.Now()

configPath := filepath.Join(dir, ".texops.yaml")
Expand Down Expand Up @@ -661,7 +660,7 @@ func runBuild(dir string, names []string, noCache bool, skipConfirm bool, ui *UI
continue
}

if err := handleUpload(ui, inst, projectID, dir, files, syncResult, skipConfirm, sp); err != nil {
if err := handleUpload(ui, inst, projectID, dir, files, syncResult, sp); err != nil {
// User cancellation should abort the entire build
if strings.Contains(err.Error(), "cancelled by user") {
return err
Expand Down Expand Up @@ -711,7 +710,7 @@ func runBuild(dir string, names []string, noCache bool, skipConfirm bool, ui *UI
}

// handleUpload processes file sync results and uploads missing files.
func handleUpload(ui *UI, inst *InstanceClient, projectID, dir string, files []FileEntry, syncResult SyncResult, skipConfirm bool, sp *Spinner) error {
func handleUpload(ui *UI, inst *InstanceClient, projectID, dir string, files []FileEntry, syncResult SyncResult, sp *Spinner) error {
if len(syncResult.Missing) > 0 {
knownPaths := make(map[string]bool)
filesByPath := make(map[string]FileEntry)
Expand Down Expand Up @@ -739,17 +738,6 @@ func handleUpload(ui *UI, inst *InstanceClient, projectID, dir string, files []F

sp.Stop(fmt.Sprintf("%d files to upload (%s)", len(validMissing), FormatSize(uploadSize)))

const sizeThreshold = 50_000_000
if uploadSize > sizeThreshold && !skipConfirm {
confirmed, err := ui.Confirm(fmt.Sprintf("Upload size is %s. Continue?", FormatSize(uploadSize)))
if err != nil {
return err
}
if !confirmed {
return fmt.Errorf("upload cancelled by user")
}
}

uploadLabel := fmt.Sprintf("Uploading %d files", len(validMissing))
pb := ui.Progress(uploadLabel, uploadSize)
if err := inst.Upload(projectID, dir, validMissing, func(sent, total int64) {
Expand Down
179 changes: 18 additions & 161 deletions internal/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ documents:
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600)

ui, buf := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.NoError(t, err)

written, err := os.ReadFile(filepath.Join(dir, "paper.pdf"))
Expand Down Expand Up @@ -812,7 +812,7 @@ documents:
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600)

ui, buf := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.NoError(t, err)

assert.Equal(t, "prj_auto123", createdProjectID)
Expand Down Expand Up @@ -855,7 +855,7 @@ documents:
t.Setenv("TX_API_URL", apiSrv.URL)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
// RunBuild will error after project creation (mock only handles /api/projects),
// but the project_key generation side effect should have completed.
require.Error(t, err)
Expand All @@ -876,7 +876,7 @@ func TestBuildCmd_AutoInit(t *testing.T) {
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.Error(t, err)
assert.Contains(t, err.Error(), "run `tx init` to set up your project")
})
Expand All @@ -891,7 +891,7 @@ func TestBuildCmd_AutoInit(t *testing.T) {
ui := cli.NewUIWithTTYOptions(buf, true, false, in)

// Build will init then fail on auth — that's fine, we just check init happened.
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)

configData, readErr := os.ReadFile(filepath.Join(dir, ".texops.yaml"))
require.NoError(t, readErr)
Expand All @@ -911,7 +911,7 @@ func TestBuildCmd_AutoInit(t *testing.T) {
in := strings.NewReader("n\n")
ui := cli.NewUIWithOptions(buf, true, in)

err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.Error(t, err)
assert.Contains(t, err.Error(), "run `tx init` to set up your project")

Expand Down Expand Up @@ -1004,7 +1004,7 @@ documents:
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, true, false, ui)
err := cli.RunBuild(dir, nil, true, ui)
require.NoError(t, err)
require.NotNil(t, receivedBuildOptions, "build_options should be sent in request")
assert.Equal(t, "true", receivedBuildOptions["no_cache"])
Expand Down Expand Up @@ -1089,156 +1089,13 @@ documents:
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.NoError(t, err)
_, hasBuildOptions := receivedBody["build_options"]
assert.False(t, hasBuildOptions, "build_options should not be sent when --no-cache is not set")
})
}

func TestBuildCmd_SizeConfirmation(t *testing.T) {
// Helper to set up a build environment with files that total the given size.
// The instance server reports all files as missing to trigger upload.
setupBuild := func(t *testing.T, fileSize int) string {
t.Helper()

pdfContent := []byte("%PDF-1.4 test")

instSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/projects/prj_conf/sync" && r.Method == "POST":
// Report main.tex as missing to trigger upload
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"missing": []string{"main.tex"},
})

case r.URL.Path == "/projects/prj_conf/upload" && r.Method == "POST":
w.WriteHeader(http.StatusOK)

case r.URL.Path == "/projects/prj_conf/build" && r.Method == "POST":
doneData, _ := json.Marshal(map[string]any{
"status": "success",
"pdfUrl": "/projects/prj_conf/builds/bld_001/output",
"build_id": "bld_001",
})
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("event: done\ndata: " + string(doneData) + "\n\n"))

case r.URL.Path == "/projects/prj_conf/builds/bld_001/output" && r.Method == "GET":
w.Header().Set("Content-Type", "application/pdf")
w.Write(pdfContent)

default:
w.WriteHeader(404)
}
}))
t.Cleanup(instSrv.Close)

apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/projects" && r.Method == "POST":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"id": "prj_conf",
"name": "test",
"distribution_version": "texlive:2021",
})
case r.URL.Path == "/api/projects/prj_conf/session" && r.Method == "POST":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"instance_url": instSrv.URL,
"jwt": "test-jwt",
"cache_cold": false,
})
default:
w.WriteHeader(404)
}
}))
t.Cleanup(apiSrv.Close)

mockKeyringForAuth(t, "test-jwt-token")

t.Setenv("TX_API_URL", apiSrv.URL)

origNewIC := cli.NewInstanceClientFn
cli.NewInstanceClientFn = func(instanceURL, jwt string) *cli.InstanceClient {
ic := cli.NewInstanceClient(instanceURL, jwt)
ic.SetHTTPClient(instSrv.Client())
return ic
}
t.Cleanup(func() { cli.NewInstanceClientFn = origNewIC })

dir := t.TempDir()
configContent := `project_key: "k7Gx9mR2pL4wN8qY5vBt3a"
texlive: "texlive:2021"
documents:
- name: main
main: main.tex
`
os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte(configContent), 0o600)

// Create a file of the specified size
content := make([]byte, fileSize)
for i := range content {
content[i] = 'x'
}
os.WriteFile(filepath.Join(dir, "main.tex"), content, 0o600)

return dir
}

t.Run("large upload prompts confirmation and user accepts", func(t *testing.T) {
dir := setupBuild(t, 51_000_000) // 51 MB, over threshold

buf := &bytes.Buffer{}
ui := cli.NewUIWithOptions(buf, true, strings.NewReader("y\n"))
err := cli.RunBuild(dir, nil, false, false, ui)
require.NoError(t, err)
assert.Contains(t, buf.String(), "Upload size")
assert.Contains(t, buf.String(), "[Y/n]")
})

t.Run("large upload prompts confirmation and user declines", func(t *testing.T) {
dir := setupBuild(t, 51_000_000) // 51 MB, over threshold

buf := &bytes.Buffer{}
ui := cli.NewUIWithOptions(buf, true, strings.NewReader("n\n"))
err := cli.RunBuild(dir, nil, false, false, ui)
require.Error(t, err)
assert.Contains(t, err.Error(), "upload cancelled by user")
})

t.Run("large upload with --yes skips confirmation", func(t *testing.T) {
dir := setupBuild(t, 51_000_000) // 51 MB, over threshold

ui, buf := testUI()
err := cli.RunBuild(dir, nil, false, true, ui) // skipConfirm=true
require.NoError(t, err)
assert.NotContains(t, buf.String(), "[Y/n]")
})

t.Run("small upload does not prompt confirmation", func(t *testing.T) {
dir := setupBuild(t, 1_000) // 1 KB, under threshold

ui, buf := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
require.NoError(t, err)
assert.NotContains(t, buf.String(), "[Y/n]")
})

t.Run("large upload auto-confirms in non-TTY mode", func(t *testing.T) {
dir := setupBuild(t, 51_000_000) // 51 MB, over threshold

ui, buf := testUI() // non-TTY
err := cli.RunBuild(dir, nil, false, false, ui)
require.NoError(t, err)
// Non-TTY should auto-confirm without prompt
assert.NotContains(t, buf.String(), "[Y/n]")
})
}

func TestBuildCmd_MultiDocument(t *testing.T) {
// multiDocSetup creates a common build environment for multi-document tests.
// It returns the temp dir and tracks session/build requests.
Expand Down Expand Up @@ -1376,7 +1233,7 @@ documents:
s := multiDocSetup(t, config, "")

ui, buf := testUI()
err := cli.RunBuild(s.dir, nil, false, false, ui)
err := cli.RunBuild(s.dir, nil, false, ui)
require.NoError(t, err)

// Should get exactly one session and one sync for same-version docs
Expand Down Expand Up @@ -1411,7 +1268,7 @@ documents:
s := multiDocSetup(t, config, "")

ui, buf := testUI()
err := cli.RunBuild(s.dir, nil, false, false, ui)
err := cli.RunBuild(s.dir, nil, false, ui)
require.NoError(t, err)

// Should get two sessions (one per version) and two syncs
Expand All @@ -1436,7 +1293,7 @@ documents:
s := multiDocSetup(t, config, "")

ui, _ := testUI()
err := cli.RunBuild(s.dir, []string{"paper"}, false, false, ui)
err := cli.RunBuild(s.dir, []string{"paper"}, false, ui)
require.NoError(t, err)

// Only one document should be built
Expand All @@ -1455,7 +1312,7 @@ documents:
os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte(config), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, []string{"nonexistent"}, false, false, ui)
err := cli.RunBuild(dir, []string{"nonexistent"}, false, ui)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown document")
assert.Contains(t, err.Error(), "nonexistent")
Expand All @@ -1473,7 +1330,7 @@ documents:
s := multiDocSetup(t, config, "slides.tex") // slides will fail

ui, buf := testUI()
err := cli.RunBuild(s.dir, nil, false, false, ui)
err := cli.RunBuild(s.dir, nil, false, ui)
require.Error(t, err)
assert.Contains(t, err.Error(), "one or more documents failed to build")

Expand All @@ -1499,7 +1356,7 @@ documents:
s := multiDocSetup(t, config, "")

ui, buf := testUI()
err := cli.RunBuild(s.dir, nil, false, false, ui)
err := cli.RunBuild(s.dir, nil, false, ui)
require.NoError(t, err)

output := buf.String()
Expand All @@ -1522,7 +1379,7 @@ documents:
os.WriteFile(filepath.Join(s.dir, "chapters", "paper", "paper.tex"), []byte(`\documentclass{article}\begin{document}Paper\end{document}`), 0o600)

ui, buf := testUI()
err := cli.RunBuild(s.dir, nil, false, false, ui)
err := cli.RunBuild(s.dir, nil, false, ui)
require.NoError(t, err)

assert.Len(t, *s.buildRequests, 2)
Expand Down Expand Up @@ -1623,7 +1480,7 @@ documents:
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.NoError(t, err)

assert.Equal(t, "xelatex", receivedCompiler, "compiler from config should be sent in build request")
Expand Down Expand Up @@ -1718,7 +1575,7 @@ documents:
os.WriteFile(filepath.Join(dir, "slides.tex"), []byte(`\documentclass{beamer}\begin{document}Slides\end{document}`), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.NoError(t, err)

require.Len(t, receivedCompilers, 2)
Expand Down Expand Up @@ -1806,7 +1663,7 @@ documents:
os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600)

ui, _ := testUI()
err := cli.RunBuild(dir, nil, false, false, ui)
err := cli.RunBuild(dir, nil, false, ui)
require.NoError(t, err)

assert.Equal(t, "pdflatex", receivedCompiler, "default compiler should be pdflatex")
Expand Down
7 changes: 1 addition & 6 deletions man/tx.1
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
.Nm
.Cm build
.Op Fl -no-cache
.Op Fl y | Fl -yes
.Op Ar name ...
.Nm
.Cm status
Expand Down Expand Up @@ -128,10 +127,6 @@ Flags:
.Bl -tag -width Ds
.It Fl -no-cache
Rebuild without using the remote build cache.
.It Fl y , Fl -yes
Skip the upload size confirmation prompt.
This prompt is shown when the combined size of files to upload exceeds 50 MB.
When stdout is not a TTY, confirmations default to yes.
.El
.Ss status
Show authentication status including email
Expand Down Expand Up @@ -431,7 +426,7 @@ CI pipeline snippet
.Pq assumes .texops.yaml No is committed :
.Bd -literal -offset indent
export TX_API_TOKEN="$TEXOPS_TOKEN"
tx build --yes
tx build
.Ed
.Pp
Multi-document configuration and build:
Expand Down
Loading
Loading