From 3cb6bdbf5ef1da328e51024ce00eeb075b85a88f Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 19:42:02 +0100 Subject: [PATCH 01/11] Separate exit codes for usage, auth, config and LaTeX errors --- README.md | 15 + cmd/tx/main.go | 35 +- internal/cli/client.go | 34 +- internal/cli/commands.go | 92 +++-- internal/cli/commands_test.go | 26 +- internal/cli/exit.go | 135 +++++++ internal/cli/global_config.go | 3 +- internal/cli/run.go | 54 +++ internal/cli/run_test.go | 662 ++++++++++++++++++++++++++++++++++ internal/cli/ui.go | 160 +++++--- internal/cli/ui_test.go | 47 +++ internal/cli/watcher.go | 2 +- man/tx.1 | 37 +- man/tx.1.txt | 34 +- site/docs/cli.md | 16 +- 15 files changed, 1170 insertions(+), 182 deletions(-) create mode 100644 internal/cli/exit.go create mode 100644 internal/cli/run.go create mode 100644 internal/cli/run_test.go diff --git a/README.md b/README.md index 60b8c31..2a2dc6f 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ Project settings are stored in `.texops.yaml`: The API URL defaults to `https://api.texops.dev` and can be overridden with `TX_API_URL` or `api_url` in `.texops.yaml`. +### Output and exit codes + +Results (the build summary, `tx status` fields, token values) go to stdout; progress, the build log and errors go to stderr. + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Service or unexpected failure (network, server error, build timeout) | +| `2` | Usage error (unknown or missing command, unknown flag or document, missing input without a terminal) | +| `3` | Not authenticated, or the session or token expired or was rejected | +| `4` | `.texops.yaml` missing or invalid, or unsupported TeX Live version | +| `5` | A document failed to compile | + +When documents fail for different reasons, the most severe code wins (`1`, then `3`, then `4`); `5` means every failure was a compile failure. + ## License [MIT](LICENSE) diff --git a/cmd/tx/main.go b/cmd/tx/main.go index a3a133d..2a23d4c 100644 --- a/cmd/tx/main.go +++ b/cmd/tx/main.go @@ -1,14 +1,10 @@ package main import ( - "errors" - "fmt" "os" "runtime/debug" "strings" - flags "github.com/jessevdk/go-flags" - "github.com/texops/tx/internal/cli" ) @@ -29,34 +25,5 @@ func resolveVersion() string { } func main() { - var opts cli.Options - parser := flags.NewParser(&opts, flags.Default) - parser.Name = "tx" - - if len(os.Args) == 2 && (os.Args[1] == "--version" || os.Args[1] == "version") { - fmt.Println("tx " + resolveVersion()) - os.Exit(0) - } - - ui := cli.NewUI(os.Stdout) - opts.Login.UI = ui - opts.Init.UI = ui - opts.Build.UI = ui - opts.Status.UI = ui - opts.Token.Create.UI = ui - opts.Token.List.UI = ui - opts.Token.Delete.UI = ui - - if _, err := parser.Parse(); err != nil { - if flagsErr, ok := errors.AsType[*flags.Error](err); ok { - switch { - case errors.Is(flagsErr.Type, flags.ErrHelp): - os.Exit(0) - case errors.Is(flagsErr.Type, flags.ErrCommandRequired): - parser.WriteHelp(os.Stdout) - os.Exit(0) - } - } - os.Exit(1) - } + os.Exit(cli.Run(resolveVersion(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } diff --git a/internal/cli/client.go b/internal/cli/client.go index 6fe0e5d..c870257 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -21,12 +21,19 @@ var validIDPattern = regexp.MustCompile(`^[a-z]{3}_[0-9A-Za-z]+$`) // APIError represents an HTTP error response from the API. type APIError struct { + Op string StatusCode int Body string } func (e *APIError) Error() string { - return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Body) + if e.Op == "" { + return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Body) + } + if e.Body == "" { + return fmt.Sprintf("%s failed (%d)", e.Op, e.StatusCode) + } + return fmt.Sprintf("%s failed (%d): %s", e.Op, e.StatusCode, e.Body) } type APIClient struct { @@ -55,6 +62,7 @@ type BuildDoneEvent struct { PdfURL string `json:"pdfUrl,omitempty"` Message string `json:"message,omitempty"` BuildID string `json:"build_id,omitempty"` + Reason string `json:"reason,omitempty"` } type DeviceCodeResponse struct { @@ -131,7 +139,7 @@ func (c *APIClient) CreateProject(ctx context.Context, name, distVersion, projec defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return CreateProjectResponse{}, fmt.Errorf("create project failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return CreateProjectResponse{}, &APIError{Op: "create project", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } var result CreateProjectResponse @@ -165,7 +173,7 @@ func (c *APIClient) GetSession(ctx context.Context, projectID, distributionVersi defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return SessionResponse{}, fmt.Errorf("get session failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return SessionResponse{}, &APIError{Op: "get session", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } var result SessionResponse @@ -188,7 +196,7 @@ func (c *APIClient) RequestDeviceCode() (DeviceCodeResponse, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return DeviceCodeResponse{}, fmt.Errorf("device code request failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return DeviceCodeResponse{}, &APIError{Op: "device code request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } var result DeviceCodeResponse @@ -228,7 +236,7 @@ func (c *APIClient) PollToken(deviceCode string) (TokenResponse, error) { case http.StatusGone: // 410 — expired return TokenResponse{}, ErrDeviceCodeExpired default: - return TokenResponse{}, fmt.Errorf("token request failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return TokenResponse{}, &APIError{Op: "token request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } } @@ -246,7 +254,7 @@ func (c *APIClient) RefreshToken(jwt string) (TokenResponse, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return TokenResponse{}, fmt.Errorf("refresh failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return TokenResponse{}, &APIError{Op: "refresh", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } var result TokenResponse @@ -441,7 +449,7 @@ func (c *InstanceClient) Start(ctx context.Context, projectID string, onLog func return ErrStartUnsupported } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("sandbox start failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return &APIError{Op: "sandbox start", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } done, err := ParseSSEStream(resp.Body, onLog) @@ -480,7 +488,7 @@ func (c *InstanceClient) Sync(ctx context.Context, projectID string, files []Fil defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return SyncResult{}, fmt.Errorf("sync failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return SyncResult{}, &APIError{Op: "sync", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } var result SyncResult @@ -590,7 +598,7 @@ func (c *InstanceClient) UploadRaw(ctx context.Context, projectID string, tarDat defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("upload failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return &APIError{Op: "upload", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } return nil } @@ -630,7 +638,7 @@ func (c *InstanceClient) BuildWithArgs(ctx context.Context, projectID, main, dir defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return BuildDoneEvent{}, fmt.Errorf("build request failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return BuildDoneEvent{}, &APIError{Op: "build request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } return ParseSSEStream(resp.Body, onLog) @@ -668,7 +676,7 @@ func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return BuildDoneEvent{}, fmt.Errorf("build request failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return BuildDoneEvent{}, &APIError{Op: "build request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } return ParseSSEStream(resp.Body, onLog) @@ -693,7 +701,7 @@ func (c *InstanceClient) DownloadPDF(ctx context.Context, projectID, buildID, ou defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("PDF download failed (%d)", resp.StatusCode) + return &APIError{Op: "PDF download", StatusCode: resp.StatusCode} } return writeFilePreserveInode(resp.Body, outputPath) @@ -730,7 +738,7 @@ func (c *InstanceClient) uploadTar(ctx context.Context, projectID string, tarDat defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("upload failed (%d): %s", resp.StatusCode, readErrorBody(resp)) + return &APIError{Op: "upload", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } return nil } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 06a2051..bc651c8 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "net/http" "os" "os/exec" "os/signal" @@ -141,10 +142,7 @@ func (cmd *StatusCmd) Execute(args []string) error { token, err := ResolveAuth() if err != nil { - ui.Errorf("Not authenticated.") - ui.Log("") - ui.Log("Run 'tx login' to log in to TexOps.") - return nil + return authError(errors.New("Not authenticated. Run 'tx login' to log in to TexOps.")) } apiURL := ResolveAPIURL(Config{}) @@ -154,12 +152,9 @@ func (cmd *StatusCmd) Execute(args []string) error { resp, err := api.Whoami() if err != nil { sp.Fail("Authentication check failed") - var apiErr *APIError - if errors.As(err, &apiErr) && apiErr.StatusCode == 401 { - ui.Errorf("Session expired. Run 'tx login' to re-authenticate.") - return nil + if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.StatusCode == http.StatusUnauthorized { + return authError(errors.New("Session expired. Run 'tx login' to re-authenticate.")) } - ui.Errorf("Error: %s", err) return err } sp.Stop("Connected") @@ -192,7 +187,7 @@ func (cmd *LoginCmd) Execute(args []string) error { sp := ui.Spin("Requesting login code...") dcResp, err := api.RequestDeviceCode() if err != nil { - sp.Fail(fmt.Sprintf("Failed to request login code: %s", err)) + sp.Fail("Failed to request login code") return err } sp.Stop("Login code received") @@ -229,7 +224,7 @@ func (cmd *LoginCmd) Execute(args []string) error { sp.Fail("Login code expired") return fmt.Errorf("login code expired; please run 'tx login' again") } - sp.Fail(fmt.Sprintf("Token polling failed: %s", err)) + sp.Fail("Token polling failed") return err } sp.Stop("Authorized") @@ -419,13 +414,13 @@ func (cmd *InitCmd) Execute(args []string) error { configPath := filepath.Join(dir, ".texops.yaml") if _, err := os.Stat(configPath); err == nil { - return fmt.Errorf(".texops.yaml already exists") + return configError(errors.New(".texops.yaml already exists")) } else if !os.IsNotExist(err) { return fmt.Errorf("cannot check .texops.yaml: %w", err) } if cmd.Compiler != "" && !isValidCompiler(cmd.Compiler) { - return fmt.Errorf("invalid compiler %q: allowed values are %s", cmd.Compiler, strings.Join(AllowedCompilers, ", ")) + return usageErrorf("invalid compiler %q: allowed values are %s", cmd.Compiler, strings.Join(AllowedCompilers, ", ")) } return initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, ui) @@ -550,17 +545,17 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv configData, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { - return err + return configError(err) } if !ui.IsTTY() { - return errInitDeclined + return configError(errInitDeclined) } confirmed, confirmErr := ui.Confirm("No .texops.yaml found. Initialize project now?") if confirmErr != nil { return confirmErr } if !confirmed { - return errInitDeclined + return configError(errInitDeclined) } if initErr := initProject(dir, "", "", "main.tex", ui); initErr != nil { return initErr @@ -573,7 +568,7 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv config, err := ParseConfig(string(configData)) if err != nil { - return err + return configError(err) } // Resolve document list: filter by name or use all @@ -582,7 +577,7 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv for _, name := range names { doc, ok := config.DocumentByName(name) if !ok { - return fmt.Errorf("unknown document %q; available: %s", name, docNames(config.Documents)) + return usageErrorf("unknown document %q; available: %s", name, docNames(config.Documents)) } docs = append(docs, doc) } @@ -620,8 +615,8 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv sp := ui.Spin("Resolving project...") project, err := api.CreateProject(ctx, filepath.Base(dir), config.Texlive, config.ProjectKey) if err != nil { - sp.Fail(fmt.Sprintf("Failed to resolve project: %s", err)) - return err + sp.Fail("Failed to resolve project") + return classifyVersionError(err) } sp.Stop("Project ready") projectID := project.ID @@ -650,8 +645,8 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv } } elapsed := time.Since(buildStart) - ui.Log("") - ui.Status(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) + ui.Gap() + ui.Result(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) for _, r := range results { if r.Success { ui.Log(fmt.Sprintf(" %s => %s", r.Name, r.Output)) @@ -664,13 +659,16 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv return watchAndBuild(ctx, dir, p) } - for _, r := range results { - if !r.Success { - return fmt.Errorf("one or more documents failed to build") - } - } + return buildResultsError(results) +} - return nil +// classifyVersionError marks the server's 400 for an unsupported TeX Live +// version as a project config error. +func classifyVersionError(err error) error { + if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.StatusCode == http.StatusBadRequest { + return configError(err) + } + return err } func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { @@ -681,7 +679,7 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { sp.Cancel() return nil, ctx.Err() } - sp.Fail(fmt.Sprintf("Failed to collect files: %s", err)) + sp.Fail("Failed to collect files") return nil, err } @@ -730,7 +728,7 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { } sp.Fail(fmt.Sprintf("Failed to get session: %s", err)) for _, doc := range group.docs { - results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) + results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: classifyVersionError(err)}) } continue } @@ -882,7 +880,7 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, } compileStart := time.Now() result, err := inst.Build(ctx, projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { - ui.Log(line) + ui.StreamLog(line) }) if err != nil { ui.Errorf("Build request failed: %s", err) @@ -891,7 +889,7 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, if result.Status == "success" && result.PdfURL != "" { compileElapsed := time.Since(compileStart) - ui.Success(fmt.Sprintf("Build complete (%.1fs)", compileElapsed.Seconds())) + ui.StepSuccess(fmt.Sprintf("Build complete (%.1fs)", compileElapsed.Seconds())) outputPath := filepath.Join(dir, doc.Output) if err := os.MkdirAll(filepath.Dir(outputPath), 0o750); err != nil { return docResult{Name: doc.Name, Output: doc.Output, Err: fmt.Errorf("cannot create output directory: %w", err)} @@ -922,7 +920,7 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, msg = "unknown error" } ui.Errorf("Build failed: %s", msg) - return docResult{Name: doc.Name, Output: doc.Output, Err: fmt.Errorf("build failed: %s", msg)} + return docResult{Name: doc.Name, Output: doc.Output, Err: buildFailureError(result, fmt.Errorf("build failed: %s", msg))} } // docNames returns a comma-separated list of document names. @@ -986,7 +984,7 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { cmd.Name = strings.TrimSpace(cmd.Name) if cmd.Name == "" { if !ui.IsTTY() { - return fmt.Errorf("specify --name in non-interactive mode") + return usageErrorf("specify --name in non-interactive mode") } name, err := ui.TextInput("Token name:") if err != nil { @@ -997,7 +995,7 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { // Determine expiry if cmd.NoExpiry && cmd.ExpiresIn != "" { - return fmt.Errorf("--no-expiry and --expires-in are mutually exclusive") + return usageErrorf("--no-expiry and --expires-in are mutually exclusive") } var expiresIn *int64 if cmd.NoExpiry { @@ -1005,12 +1003,12 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { } else if cmd.ExpiresIn != "" { seconds, err := parseDuration(cmd.ExpiresIn) if err != nil { - return err + return usageError(err) } expiresIn = &seconds } else { if !ui.IsTTY() { - return fmt.Errorf("specify --expires-in or --no-expiry in non-interactive mode") + return usageErrorf("specify --expires-in or --no-expiry in non-interactive mode") } // Interactive selection labels := make([]string, len(expiryOptions)) @@ -1041,17 +1039,15 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { if err != nil { sp.Fail("Failed to create token") if errors.Is(err, ErrTokenConflict) { - ui.Errorf("A token named %q already exists.", cmd.Name) - return err + return usageError(fmt.Errorf("%w: %q", err, cmd.Name)) } - ui.Errorf("Error: %s", err) return err } sp.Stop("Token created") - ui.Log("") - ui.Status(resp.Token) - ui.Log("") + ui.Gap() + ui.Result(resp.Token) + ui.Gap() ui.DimInfo("This token won't be shown again. Copy it now.") ui.DimInfo("Expires: " + formatDatePtr(resp.ExpiresAt, "never")) @@ -1076,7 +1072,6 @@ func (cmd *TokenListCmd) Execute(args []string) error { tokens, err := api.ListAPITokens() if err != nil { sp.Fail("Failed to list tokens") - ui.Errorf("Error: %s", err) return err } sp.Stop(fmt.Sprintf("%d token(s)", len(tokens))) @@ -1110,7 +1105,7 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { } if len(args) == 0 && !ui.IsTTY() { - return fmt.Errorf("specify token name as argument in non-interactive mode") + return usageErrorf("specify token name as argument in non-interactive mode") } apiURL := ResolveAPIURL(Config{}) @@ -1120,7 +1115,6 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { tokens, err := api.ListAPITokens() if err != nil { sp.Fail("Failed to load tokens") - ui.Errorf("Error listing tokens: %s", err) return err } sp.Stop(fmt.Sprintf("%d token(s)", len(tokens))) @@ -1138,7 +1132,7 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { } } if tokenID == "" { - return fmt.Errorf("token %q not found", tokenName) + return usageErrorf("token %q not found", tokenName) } } else { if len(tokens) == 0 { @@ -1172,10 +1166,8 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { if err != nil { sp.Fail("Failed to delete token") if errors.Is(err, ErrTokenNotFound) { - ui.Errorf("Token not found (may have been already deleted).") - return err + return usageError(fmt.Errorf("%w (may have been already deleted)", err)) } - ui.Errorf("Error: %s", err) return err } sp.Stop("Token deleted") diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index dc4756c..81b832e 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -1941,11 +1941,10 @@ func TestStatusCmd(t *testing.T) { ui, buf := testUI() cmd := &cli.StatusCmd{UI: ui} err := cmd.Execute(nil) - require.NoError(t, err) + require.Error(t, err, buf.String()) - output := buf.String() - assert.Contains(t, output, "Not authenticated.") - assert.Contains(t, output, "tx login") + assert.Equal(t, "Not authenticated. Run 'tx login' to log in to TexOps.", err.Error()) + assert.Equal(t, cli.ExitAuth, cli.AsExitError(err).Code) }) t.Run("API returns 401 suggests re-login", func(t *testing.T) { @@ -1962,11 +1961,10 @@ func TestStatusCmd(t *testing.T) { ui, buf := testUI() cmd := &cli.StatusCmd{UI: ui} err := cmd.Execute(nil) - require.NoError(t, err) + require.Error(t, err, buf.String()) - output := buf.String() - assert.Contains(t, output, "Session expired") - assert.Contains(t, output, "tx login") + assert.Equal(t, "Session expired. Run 'tx login' to re-authenticate.", err.Error()) + assert.Equal(t, cli.ExitAuth, cli.AsExitError(err).Code) }) t.Run("API returns 500 propagates error", func(t *testing.T) { @@ -1983,10 +1981,11 @@ func TestStatusCmd(t *testing.T) { ui, buf := testUI() cmd := &cli.StatusCmd{UI: ui} err := cmd.Execute(nil) - require.Error(t, err) + require.Error(t, err, buf.String()) - output := buf.String() - assert.Contains(t, output, "Error:") + assert.Contains(t, err.Error(), "internal server error") + assert.Equal(t, cli.ExitFailure, cli.AsExitError(err).Code) + assert.NotContains(t, buf.String(), "internal server error", "the error is printed once by the caller, not by the command") }) } @@ -2177,7 +2176,10 @@ func TestTokenCreateCmd(t *testing.T) { } err := cmd.Execute(nil) require.Error(t, err) - assert.Contains(t, buf.String(), "already exists") + require.ErrorIs(t, err, cli.ErrTokenConflict) + assert.Equal(t, `token name already exists: "duplicate"`, err.Error()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) + assert.NotContains(t, buf.String(), "already exists") }) t.Run("create with interactive expiry selection", func(t *testing.T) { diff --git a/internal/cli/exit.go b/internal/cli/exit.go new file mode 100644 index 0000000..85098f2 --- /dev/null +++ b/internal/cli/exit.go @@ -0,0 +1,135 @@ +package cli + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" +) + +const ( + ExitOK = 0 + ExitFailure = 1 + ExitUsage = 2 + ExitAuth = 3 + ExitConfig = 4 + ExitBuildFailed = 5 +) + +const ( + KindInternal = "internal" + KindNetwork = "network" + KindTimeout = "timeout" + KindUsage = "usage" + KindNotAuthenticated = "not_authenticated" + KindConfig = "config" + KindBuildFailed = "build_failed" +) + +// ExitError carries the process exit code and the JSON error code (Kind) for an error. +type ExitError struct { + Code int + Kind string + Err error +} + +func (e *ExitError) Error() string { + return e.Err.Error() +} + +func (e *ExitError) Unwrap() error { + return e.Err +} + +func usageError(err error) error { + return &ExitError{Code: ExitUsage, Kind: KindUsage, Err: err} +} + +func usageErrorf(format string, args ...any) error { + return usageError(fmt.Errorf(format, args...)) +} + +func authError(err error) error { + return &ExitError{Code: ExitAuth, Kind: KindNotAuthenticated, Err: err} +} + +func configError(err error) error { + return &ExitError{Code: ExitConfig, Kind: KindConfig, Err: err} +} + +// AsExitError classifies err: an ExitError anywhere in the chain wins, then a +// 401 API response (not authenticated), then network errors; anything else is +// an internal failure. It returns nil for a nil error. +func AsExitError(err error) *ExitError { + if err == nil { + return nil + } + if exitErr, ok := errors.AsType[*ExitError](err); ok { + return exitErr + } + if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.StatusCode == http.StatusUnauthorized { + return &ExitError{Code: ExitAuth, Kind: KindNotAuthenticated, Err: err} + } + if isNetworkError(err) { + return &ExitError{Code: ExitFailure, Kind: KindNetwork, Err: err} + } + return &ExitError{Code: ExitFailure, Kind: KindInternal, Err: err} +} + +func isNetworkError(err error) bool { + if urlErr, ok := errors.AsType[*url.Error](err); ok && urlErr.Op != "parse" { + return true + } + _, ok := errors.AsType[net.Error](err) + return ok +} + +// buildFailureError maps a failed build's done event to an exit code: the +// server's reason decides; an old server without a reason counts as a LaTeX +// failure when it allocated a build. +func buildFailureError(done BuildDoneEvent, err error) error { + switch done.Reason { + case "latex_error", "no_pdf": + return &ExitError{Code: ExitBuildFailed, Kind: KindBuildFailed, Err: err} + case "timeout": + return &ExitError{Code: ExitFailure, Kind: KindTimeout, Err: err} + case "": + if done.BuildID != "" { + return &ExitError{Code: ExitBuildFailed, Kind: KindBuildFailed, Err: err} + } + } + return &ExitError{Code: ExitFailure, Kind: KindInternal, Err: err} +} + +var exitCodePriority = map[int]int{ + ExitFailure: 4, + ExitAuth: 3, + ExitConfig: 2, + ExitUsage: 1, + ExitBuildFailed: 0, +} + +// buildResultsError summarizes failed documents into one error. A service +// failure anywhere makes the whole build a service failure (exit 1); only +// when every failure is a compile failure does it exit 5. +func buildResultsError(results []docResult) error { + var worst *ExitError + for _, r := range results { + if r.Success { + continue + } + err := r.Err + if err == nil { + err = errors.New("build failed") + } + exitErr := AsExitError(err) + if worst == nil || exitCodePriority[exitErr.Code] > exitCodePriority[worst.Code] { + worst = exitErr + } + } + if worst == nil { + return nil + } + return &ExitError{Code: worst.Code, Kind: worst.Kind, Err: errors.New("one or more documents failed to build")} +} diff --git a/internal/cli/global_config.go b/internal/cli/global_config.go index 2d707e6..ff889b7 100644 --- a/internal/cli/global_config.go +++ b/internal/cli/global_config.go @@ -3,6 +3,7 @@ package cli import ( "encoding/base64" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -103,7 +104,7 @@ func ResolveAuth() (string, error) { } } - return "", fmt.Errorf("not authenticated: run 'tx login' or set TX_API_TOKEN") + return "", authError(errors.New("not authenticated: run 'tx login' or set TX_API_TOKEN")) } func storeJWT(jwt string) error { diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..6f592cc --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,54 @@ +package cli + +import ( + "errors" + "fmt" + "io" + + flags "github.com/jessevdk/go-flags" +) + +// Run executes tx with args (without the program name) and returns the +// process exit code. Every error is printed exactly once, to stderr. +func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 1 && (args[0] == "--version" || args[0] == "version") { + fmt.Fprintln(stdout, "tx "+version) + return ExitOK + } + + ui := NewUIWithStreams(stdout, stderr, stdin) + + var opts Options + opts.Login.UI = ui + opts.Init.UI = ui + opts.Build.UI = ui + opts.Status.UI = ui + opts.Token.Create.UI = ui + opts.Token.List.UI = ui + opts.Token.Delete.UI = ui + + parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash) + parser.Name = "tx" + + _, err := parser.ParseArgs(args) + if err == nil { + return ExitOK + } + + if flagsErr, ok := errors.AsType[*flags.Error](err); ok { + switch { + case errors.Is(flagsErr.Type, flags.ErrHelp): + fmt.Fprintln(stdout, flagsErr.Message) + return ExitOK + case errors.Is(flagsErr.Type, flags.ErrCommandRequired) && len(args) == 0: + parser.WriteHelp(stdout) + return ExitOK + case errors.Is(flagsErr.Type, flags.ErrCommandRequired): + parser.WriteHelp(stderr) + } + err = usageError(err) + } + + ui.Errorf("%s", err.Error()) + return AsExitError(err).Code +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..933c0ff --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,662 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +type fakeTexOps struct { + srv *httptest.Server + + projectStatus int + projectBody string + sessionStatus int + syncStatus int + whoamiStatus int + deviceCodeStatus int + done map[string]map[string]any + + mu sync.Mutex + exchange []string +} + +func newFakeTexOps(t *testing.T) *fakeTexOps { + t.Helper() + f := &fakeTexOps{done: map[string]map[string]any{}} + f.srv = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.srv.Close) + t.Setenv("TX_API_URL", f.srv.URL) + t.Setenv("TX_API_TOKEN", "test-token") + return f +} + +type recordingWriter struct { + http.ResponseWriter + + status int + body bytes.Buffer +} + +func (w *recordingWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *recordingWriter) Write(p []byte) (int, error) { + w.body.Write(p) + return w.ResponseWriter.Write(p) +} + +func (f *fakeTexOps) serve(w http.ResponseWriter, r *http.Request) { + reqBody := new(bytes.Buffer) + reqBody.ReadFrom(r.Body) + rw := &recordingWriter{ResponseWriter: w, status: http.StatusOK} + f.route(rw, r, reqBody.Bytes()) + f.mu.Lock() + defer f.mu.Unlock() + f.exchange = append(f.exchange, fmt.Sprintf("%s %s %s\n -> %d %s", r.Method, r.URL.Path, reqBody.String(), rw.status, rw.body.String())) +} + +func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/auth/whoami": + if f.whoamiStatus != 0 { + w.WriteHeader(f.whoamiStatus) + w.Write([]byte(`{"error":"invalid token"}`)) + return + } + writeJSON(w, map[string]any{"user_id": "usr_1", "email": "user@example.com", "auth_method": "api_token"}) + case r.Method == http.MethodGet && r.URL.Path == "/auth/tokens": + writeJSON(w, []map[string]any{{"id": "tok_1", "name": "ci", "prefix": "tx_secr", "expires_at": "2027-01-01T00:00:00Z", "created_at": "2026-10-01T00:00:00Z"}}) + case r.Method == http.MethodPost && r.URL.Path == "/auth/device-code": + if f.deviceCodeStatus != 0 { + w.WriteHeader(f.deviceCodeStatus) + w.Write([]byte(`{"error":"device flow unavailable"}`)) + return + } + writeJSON(w, map[string]any{"device_code": "dev_1", "user_code": "ABCD-EFGH", "verification_url": f.srv.URL + "/verify", "expires_in": 1}) + case r.Method == http.MethodPost && r.URL.Path == "/auth/tokens": + w.WriteHeader(http.StatusCreated) + writeJSON(w, map[string]any{"token": "tx_secret_value", "id": "tok_1", "name": "ci", "prefix": "tx_secr", "created_at": "2026-10-01T00:00:00Z"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/projects": + if f.projectStatus != 0 { + w.WriteHeader(f.projectStatus) + w.Write([]byte(f.projectBody)) + return + } + writeJSON(w, map[string]any{"id": "prj_test", "name": "test", "distribution_version": "2025"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/projects/prj_test/session": + if f.sessionStatus != 0 { + w.WriteHeader(f.sessionStatus) + w.Write([]byte(`{"error":"session unavailable"}`)) + return + } + writeJSON(w, map[string]any{"instance_url": f.srv.URL, "jwt": "instance-jwt"}) + case r.Method == http.MethodPost && r.URL.Path == "/projects/prj_test/sync": + if f.syncStatus != 0 { + w.WriteHeader(f.syncStatus) + w.Write([]byte(`{"error":"disk full"}`)) + return + } + writeJSON(w, map[string]any{"missing": []string{}}) + case r.Method == http.MethodPost && r.URL.Path == "/projects/prj_test/build": + var req struct { + Main string `json:"main"` + } + json.Unmarshal(body, &req) + done, ok := f.done[req.Main] + if !ok { + done = map[string]any{"status": "success", "pdfUrl": "/projects/prj_test/builds/bld_ok/output", "build_id": "bld_ok"} + } + doneData, _ := json.Marshal(done) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: log\ndata: {\"message\":\"latexmk output for %s\"}\n\nevent: done\ndata: %s\n\n", req.Main, doneData) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/projects/prj_test/builds/") && strings.HasSuffix(r.URL.Path, "/output"): + w.Write([]byte("%PDF-1.4 test")) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func (f *fakeTexOps) exchanges() string { + f.mu.Lock() + defer f.mu.Unlock() + return strings.Join(f.exchange, "\n") +} + +type txRun struct { + code int + stdout string + stderr string + requests string +} + +func (r txRun) String() string { + return fmt.Sprintf("exit code: %d\n--- stdout ---\n%s\n--- stderr ---\n%s\n--- requests ---\n%s", r.code, r.stdout, r.stderr, r.requests) +} + +func runTx(t *testing.T, f *fakeTexOps, args ...string) txRun { + t.Helper() + var stdout, stderr bytes.Buffer + code := cli.Run("test", args, strings.NewReader(""), &stdout, &stderr) + r := txRun{code: code, stdout: stdout.String(), stderr: stderr.String()} + if f != nil { + r.requests = f.exchanges() + } + return r +} + +const projectConfig = `project_key: "k7Gx9mR2pL4wN8qY5vBt3a" +texlive: "2025" +documents: + - name: paper + main: paper.tex + - name: slides + main: slides.tex +` + +func projectDir(t *testing.T, config string) string { + t.Helper() + dir := t.TempDir() + if config != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte(config), 0o600)) + } + for _, name := range []string{"paper.tex", "slides.tex"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("\\documentclass{article}\\begin{document}Hi\\end{document}"), 0o600)) + } + t.Chdir(dir) + return dir +} + +func noCredentials(t *testing.T) { + t.Helper() + t.Setenv("TX_API_TOKEN", "") + origGet := cli.KeyringGet + cli.KeyringGet = func(service, user string) (string, error) { + return "", fmt.Errorf("not found") + } + t.Cleanup(func() { cli.KeyringGet = origGet }) + origPath := cli.CredentialsFilePath + cli.CredentialsFilePath = func() string { return filepath.Join(t.TempDir(), "credentials.yaml") } + t.Cleanup(func() { cli.CredentialsFilePath = origPath }) +} + +func failedBuild(reason, buildID string) map[string]any { + done := map[string]any{"status": "error", "message": "build failed with " + reason} + if reason != "" { + done["reason"] = reason + } + if buildID != "" { + done["build_id"] = buildID + } + return done +} + +func TestRunExitCodes(t *testing.T) { + t.Run("successful build exits 0", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Build complete: 2 succeeded, 0 failed", r) + }) + + t.Run("help exits 0 and prints to stdout", func(t *testing.T) { + r := runTx(t, nil, "--help") + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Usage:", r) + assert.Empty(t, r.stderr, r) + }) + + t.Run("subcommand help exits 0 and prints to stdout", func(t *testing.T) { + r := runTx(t, nil, "build", "--help") + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "--no-cache", r) + assert.Empty(t, r.stderr, r) + }) + + t.Run("version exits 0", func(t *testing.T) { + r := runTx(t, nil, "--version") + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, "tx test\n", r.stdout, r) + }) + + t.Run("unknown command exits 2", func(t *testing.T) { + r := runTx(t, nil, "bogus") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, "Unknown command `bogus'", r) + assert.Empty(t, r.stdout, r) + }) + + t.Run("missing subcommand exits 2", func(t *testing.T) { + r := runTx(t, nil, "token") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, "Please specify one command of: create, delete or list", r) + assert.Empty(t, r.stdout, r) + }) + + t.Run("no command prints help and exits 0", func(t *testing.T) { + r := runTx(t, nil) + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Usage:", r) + assert.Empty(t, r.stderr, r) + }) + + t.Run("unknown flag exits 2", func(t *testing.T) { + r := runTx(t, nil, "build", "--bogus") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, "unknown flag `bogus'", r) + }) + + t.Run("bad flag value exits 2", func(t *testing.T) { + projectDir(t, "") + + r := runTx(t, nil, "init", "--compiler", "tex") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, `invalid compiler "tex"`, r) + assert.NoFileExists(t, ".texops.yaml", r) + }) + + t.Run("unknown document name exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "thesis") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "unknown document \"thesis\"; available: paper, slides\n", r.stderr, r) + assert.Empty(t, r.requests, r) + }) + + t.Run("missing required input without a terminal exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "create") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "specify --name in non-interactive mode\n", r.stderr, r) + assert.Empty(t, r.requests, r) + }) + + t.Run("status with a rejected token exits 3", func(t *testing.T) { + f := newFakeTexOps(t) + f.whoamiStatus = http.StatusUnauthorized + + r := runTx(t, f, "status") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Contains(t, r.stderr, "Session expired. Run 'tx login' to re-authenticate.\n", r) + }) + + t.Run("status without credentials exits 3", func(t *testing.T) { + noCredentials(t) + + r := runTx(t, nil, "status") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Equal(t, "Not authenticated. Run 'tx login' to log in to TexOps.\n", r.stderr, r) + assert.Empty(t, r.stdout, r) + }) + + t.Run("build without credentials exits 3", func(t *testing.T) { + f := newFakeTexOps(t) + noCredentials(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Contains(t, r.stderr, "not authenticated", r) + assert.Empty(t, r.requests, r) + }) + + t.Run("401 from the API exits 3", func(t *testing.T) { + f := newFakeTexOps(t) + f.projectStatus = http.StatusUnauthorized + f.projectBody = `{"error":"invalid API token"}` + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Contains(t, r.stderr, "create project failed (401): invalid API token", r) + }) + + t.Run("401 from the session endpoint exits 3", func(t *testing.T) { + f := newFakeTexOps(t) + f.sessionStatus = http.StatusUnauthorized + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Equal(t, 1, strings.Count(r.stderr, "get session failed (401): session unavailable"), r) + assert.True(t, strings.HasSuffix(r.stderr, "\none or more documents failed to build\n"), r) + }) + + t.Run("missing config exits 4", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, "") + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitConfig, r.code, r) + assert.Contains(t, r.stderr, "no project config found", r) + }) + + t.Run("invalid config exits 4", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, "texlive: \"2025\"\n") + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitConfig, r.code, r) + assert.Equal(t, "invalid config: missing required field 'documents'\n", r.stderr, r) + }) + + t.Run("unsupported TeX Live version exits 4", func(t *testing.T) { + f := newFakeTexOps(t) + f.projectStatus = http.StatusBadRequest + f.projectBody = `{"error":"unsupported distribution version \"2099\" (supported: 2025, 2024)"}` + projectDir(t, strings.Replace(projectConfig, `"2025"`, `"2099"`, 1)) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitConfig, r.code, r) + assert.Equal(t, 1, strings.Count(r.stderr, `unsupported distribution version "2099"`), r) + }) + + t.Run("init when the config exists exits 4", func(t *testing.T) { + projectDir(t, projectConfig) + + r := runTx(t, nil, "init") + + assert.Equal(t, cli.ExitConfig, r.code, r) + assert.Equal(t, ".texops.yaml already exists\n", r.stderr, r) + }) + + t.Run("latex_error exits 5", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + assert.Equal(t, cli.ExitBuildFailed, r.code, r) + }) + + t.Run("no_pdf exits 5", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("no_pdf", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + assert.Equal(t, cli.ExitBuildFailed, r.code, r) + }) + + t.Run("old server failure with a build_id exits 5", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + assert.Equal(t, cli.ExitBuildFailed, r.code, r) + }) + + t.Run("old server failure without a build_id exits 1", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("", "") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + assert.Equal(t, cli.ExitFailure, r.code, r) + }) + + t.Run("timeout exits 1", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("timeout", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + assert.Equal(t, cli.ExitFailure, r.code, r) + }) + + t.Run("internal reason exits 1", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("internal", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + assert.Equal(t, cli.ExitFailure, r.code, r) + }) + + t.Run("server 5xx exits 1", func(t *testing.T) { + f := newFakeTexOps(t) + f.sessionStatus = http.StatusServiceUnavailable + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitFailure, r.code, r) + assert.Contains(t, r.stderr, "get session failed (503): session unavailable", r) + }) + + t.Run("sync failure exits 1", func(t *testing.T) { + f := newFakeTexOps(t) + f.syncStatus = http.StatusInternalServerError + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitFailure, r.code, r) + assert.Equal(t, 1, strings.Count(r.stderr, "sync failed (500): disk full"), r) + assert.True(t, strings.HasSuffix(r.stderr, "\none or more documents failed to build\n"), r) + }) + + t.Run("network failure exits 1", func(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + t.Setenv("TX_API_URL", srv.URL) + t.Setenv("TX_API_TOKEN", "test-token") + projectDir(t, projectConfig) + + r := runTx(t, nil, "build") + + assert.Equal(t, cli.ExitFailure, r.code, r) + assert.Contains(t, r.stderr, "connection refused", r) + }) + + t.Run("latex_error in every document exits 5", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + f.done["slides.tex"] = failedBuild("no_pdf", "bld_2") + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitBuildFailed, r.code, r) + }) + + t.Run("latex_error and timeout in different documents exit 1", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + f.done["slides.tex"] = failedBuild("timeout", "bld_2") + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitFailure, r.code, r) + }) +} + +func TestRunOutputStreams(t *testing.T) { + t.Run("build progress goes to stderr and the summary to stdout", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Regexp(t, `^Build complete: 2 succeeded, 0 failed \(\d+\.\ds\)\n paper => paper\.pdf\n slides => slides\.pdf\n$`, r.stdout, r) + assert.Contains(t, r.stderr, "Resolving project...\nProject ready\n", r) + assert.Contains(t, r.stderr, "Session acquired\n", r) + assert.Contains(t, r.stderr, "Building \"paper\" (paper.tex)...\n latexmk output for paper.tex\nBuild complete (", r) + }) + + t.Run("build failure message is printed once to stderr", func(t *testing.T) { + f := newFakeTexOps(t) + f.projectStatus = http.StatusInternalServerError + f.projectBody = `{"error":"database unavailable"}` + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + require.Equal(t, cli.ExitFailure, r.code, r) + assert.Equal(t, 1, strings.Count(r.stderr, "database unavailable"), r) + assert.Equal(t, "Resolving project...\nFailed to resolve project\ncreate project failed (500): database unavailable\n", r.stderr, r) + assert.Empty(t, r.stdout, r) + }) + + t.Run("failed document summary goes to stdout and the summary error once to stderr", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stdout, " paper !! FAILED\n", r) + assert.Equal(t, 1, strings.Count(r.stderr, "one or more documents failed to build"), r) + assert.Equal(t, 1, strings.Count(r.stderr, "build failed with latex_error"), r) + }) + + t.Run("status fields go to stdout", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "status") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, "Authenticated\n Email: user@example.com\n Method: API token\n Expires: never\n", r.stdout, r) + assert.Contains(t, r.stderr, "Checking authentication...", r) + }) + + t.Run("token list table goes to stdout", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "list") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, + " NAME PREFIX EXPIRES LAST USED CREATED \n"+ + " ci tx_secr 01 Jan 2027 never 01 Oct 2026 \n", + r.stdout, r) + assert.Equal(t, "Loading tokens...\n1 token(s)\n", r.stderr, r) + }) + + t.Run("login failure message is printed once to stderr", func(t *testing.T) { + f := newFakeTexOps(t) + f.deviceCodeStatus = http.StatusInternalServerError + + r := runTx(t, f, "login") + + require.Equal(t, cli.ExitFailure, r.code, r) + assert.Equal(t, "Requesting login code...\nFailed to request login code\ndevice code request failed (500): device flow unavailable\n", r.stderr, r) + assert.Empty(t, r.stdout, r) + }) + + t.Run("token create prints only the token on stdout", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "create", "--name", "ci", "--no-expiry") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, "tx_secret_value\n", r.stdout, r) + assert.Contains(t, r.stderr, "This token won't be shown again.", r) + }) +} + +func TestRunBuildErrorKind(t *testing.T) { + t.Run("latex_error is build_failed", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + dir := projectDir(t, projectConfig) + ui, buf := testUI() + + err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, ui) + + require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) + assert.Equal(t, cli.KindBuildFailed, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) + }) + + t.Run("timeout is timeout", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("timeout", "bld_1") + dir := projectDir(t, projectConfig) + ui, buf := testUI() + + err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, ui) + + require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) + assert.Equal(t, cli.KindTimeout, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) + }) + + t.Run("unreachable API is network", func(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + t.Setenv("TX_API_URL", srv.URL) + t.Setenv("TX_API_TOKEN", "test-token") + dir := projectDir(t, projectConfig) + ui, buf := testUI() + + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + + require.Error(t, err, buf.String()) + assert.Equal(t, cli.KindNetwork, cli.AsExitError(err).Kind, buf.String()) + }) + + t.Run("unsupported version is config", func(t *testing.T) { + f := newFakeTexOps(t) + f.projectStatus = http.StatusBadRequest + f.projectBody = `{"error":"unsupported distribution version"}` + dir := projectDir(t, projectConfig) + ui, buf := testUI() + + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + + require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) + assert.Equal(t, cli.KindConfig, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) + }) +} diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 3e952bf..6dc9e1b 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -28,14 +28,18 @@ type uiStyles struct { progressTo string } +// UI writes results to out (stdout) and progress, logs and errors to errOut +// (stderr), so that stdout carries only what a script would capture. type UI struct { isTTY bool + errIsTTY bool stdinIsTTY bool out io.Writer ttyOut io.Writer // original *os.File for Bubble Tea (needs Fd() for TTY detection) errOut io.Writer in io.Reader styles uiStyles + errStyles uiStyles } func newStyles(hasDarkBG bool, r *lipgloss.Renderer) uiStyles { @@ -61,52 +65,74 @@ func newStyles(hasDarkBG bool, r *lipgloss.Renderer) uiStyles { } func NewUI(out io.Writer) *UI { - isTTY := false - if f, ok := out.(*os.File); ok { - isTTY = isatty.IsTerminal(f.Fd()) - } - stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd()) + return NewUIWithStreams(out, os.Stderr, os.Stdin) +} +// NewUIWithStreams creates a UI over the given streams, detecting terminals +// for those that are *os.File. +func NewUIWithStreams(out, errOut io.Writer, in io.Reader) *UI { + isTTY := isTerminal(out) + errIsTTY := isTerminal(errOut) + outR := lipgloss.NewRenderer(out) + errR := lipgloss.NewRenderer(errOut) hasDarkBG := true // safe default for non-TTY - if isTTY { - hasDarkBG = lipgloss.HasDarkBackground() + switch { + case isTTY: + hasDarkBG = outR.HasDarkBackground() + case errIsTTY: + hasDarkBG = errR.HasDarkBackground() } - return &UI{ isTTY: isTTY, - stdinIsTTY: stdinIsTTY, + errIsTTY: errIsTTY, + stdinIsTTY: isTerminal(in), out: out, - ttyOut: out, // preserve original writer for Bubble Tea TTY detection - errOut: os.Stderr, - in: os.Stdin, - styles: newStyles(hasDarkBG, nil), + ttyOut: out, + errOut: errOut, + in: in, + styles: newStyles(hasDarkBG, outR), + errStyles: newStyles(hasDarkBG, errR), } } +func isTerminal(v any) bool { + f, ok := v.(*os.File) + return ok && f != nil && isatty.IsTerminal(f.Fd()) +} + // NewUIWithOptions creates a UI with explicit TTY mode and input reader. // Used in tests to simulate TTY/non-TTY behavior. // Error output goes to out for easy test capture. func NewUIWithOptions(out io.Writer, isTTY bool, in io.Reader) *UI { - return newUIWithOptions(out, isTTY, isTTY, in) + return newUIWithOptions(out, out, isTTY, isTTY, in) } // NewUIWithTTYOptions creates a UI with separate output-TTY and stdin-TTY flags. // Use this when testing code that distinguishes IsTTY() from IsInteractive(). func NewUIWithTTYOptions(out io.Writer, outIsTTY, stdinIsTTY bool, in io.Reader) *UI { - return newUIWithOptions(out, outIsTTY, stdinIsTTY, in) + return newUIWithOptions(out, out, outIsTTY, stdinIsTTY, in) +} + +// NewUIWithSplitOptions is NewUIWithOptions with separate stdout and stderr writers. +func NewUIWithSplitOptions(out, errOut io.Writer, isTTY bool, in io.Reader) *UI { + return newUIWithOptions(out, errOut, isTTY, isTTY, in) } -func newUIWithOptions(out io.Writer, isTTY, stdinIsTTY bool, in io.Reader) *UI { +func newUIWithOptions(out, errOut io.Writer, isTTY, stdinIsTTY bool, in io.Reader) *UI { r := lipgloss.NewRenderer(out) r.SetColorProfile(termenv.TrueColor) + errR := lipgloss.NewRenderer(errOut) + errR.SetColorProfile(termenv.TrueColor) return &UI{ isTTY: isTTY, + errIsTTY: isTTY, stdinIsTTY: stdinIsTTY, out: out, ttyOut: out, - errOut: out, + errOut: errOut, in: in, styles: newStyles(true, r), + errStyles: newStyles(true, errR), } } @@ -122,45 +148,53 @@ func (ui *UI) Out() io.Writer { return ui.out } -func (ui *UI) Status(msg string) { - if ui.isTTY { - fmt.Fprintln(ui.out, ui.styles.status.Render(msg)) - } else { - fmt.Fprintln(ui.out, msg) - } +// Result prints a highlighted result line to stdout. +func (ui *UI) Result(msg string) { + ui.printOut(ui.styles.status, msg) } +// Success prints a success result to stdout. func (ui *UI) Success(msg string) { if ui.isTTY { - fmt.Fprintln(ui.out, ui.styles.success.Render("✓ "+msg)) - } else { - fmt.Fprintln(ui.out, msg) + msg = "✓ " + msg } + ui.printOut(ui.styles.success, msg) } +// Log prints an indented result line to stdout. func (ui *UI) Log(line string) { - if ui.isTTY { - fmt.Fprintln(ui.out, ui.styles.logLine.Render(" "+line)) - } else { - fmt.Fprintln(ui.out, " "+line) + ui.printOut(ui.styles.logLine, " "+line) +} + +// Status prints a progress step to stderr. +func (ui *UI) Status(msg string) { + ui.printErr(ui.errStyles.status, msg) +} + +// StepSuccess prints a completed progress step to stderr. +func (ui *UI) StepSuccess(msg string) { + if ui.errIsTTY { + msg = "✓ " + msg } + ui.printErr(ui.errStyles.success, msg) +} + +// StreamLog prints an indented line of streamed output (the build log) to stderr. +func (ui *UI) StreamLog(line string) { + ui.printErr(ui.errStyles.logLine, " "+line) +} + +// Gap prints an empty separator line to stderr. +func (ui *UI) Gap() { + fmt.Fprintln(ui.errOut) } func (ui *UI) Errorf(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - if ui.isTTY { - fmt.Fprintln(ui.errOut, ui.styles.errMsg.Render(msg)) - } else { - fmt.Fprintln(ui.errOut, msg) - } + ui.printErr(ui.errStyles.errMsg, fmt.Sprintf(format, args...)) } func (ui *UI) DimInfo(msg string) { - if ui.isTTY { - fmt.Fprintln(ui.out, ui.styles.dimInfo.Render(msg)) - } else { - fmt.Fprintln(ui.out, msg) - } + ui.printErr(ui.errStyles.dimInfo, msg) } // Select displays an interactive selection list. In TTY mode, uses a simple numbered @@ -225,15 +259,15 @@ type Spinner struct { func (ui *UI) Spin(msg string) *Spinner { s := &Spinner{ui: ui, done: make(chan struct{})} - if !ui.isTTY { - fmt.Fprintln(ui.out, msg) + if !ui.errIsTTY { + fmt.Fprintln(ui.errOut, msg) return s } - model := newSpinnerModel(msg, ui.styles) + model := newSpinnerModel(msg, ui.errStyles) p := tea.NewProgram( model, - tea.WithOutput(ui.ttyOut), + tea.WithOutput(ui.errOut), tea.WithInput(nil), ) s.program = p @@ -252,7 +286,7 @@ func (s *Spinner) Update(msg string) { s.program.Send(spinnerTextMsg(msg)) return } - fmt.Fprintln(s.ui.out, msg) + fmt.Fprintln(s.ui.errOut, msg) } // Stop ends the spinner and prints a success message. @@ -260,12 +294,11 @@ func (s *Spinner) Stop(successMsg string) { if s.program != nil { s.program.Send(spinnerDoneMsg{ text: "✓ " + successMsg, - style: s.ui.styles.success, + style: s.ui.errStyles.success, }) <-s.done } else { - // non-TTY - fmt.Fprintln(s.ui.out, successMsg) + fmt.Fprintln(s.ui.errOut, successMsg) } } @@ -274,11 +307,10 @@ func (s *Spinner) Fail(errMsg string) { if s.program != nil { s.program.Send(spinnerDoneMsg{ text: errMsg, - style: s.ui.styles.errMsg, + style: s.ui.errStyles.errMsg, }) <-s.done } else { - // non-TTY fmt.Fprintln(s.ui.errOut, errMsg) } } @@ -395,15 +427,15 @@ func (ui *UI) Progress(label string, total int64) *ProgressBar { label: label, } - if !ui.isTTY { - fmt.Fprintf(ui.out, "%s (%s)...\n", label, FormatSize(total)) + if !ui.errIsTTY { + fmt.Fprintf(ui.errOut, "%s (%s)...\n", label, FormatSize(total)) return pb } - model := newProgressModel(label, ui.styles) + model := newProgressModel(label, ui.errStyles) p := tea.NewProgram( model, - tea.WithOutput(ui.ttyOut), + tea.WithOutput(ui.errOut), tea.WithInput(nil), ) pb.program = p @@ -427,7 +459,7 @@ func (pb *ProgressBar) Update(fraction float64) { defer pb.mu.Unlock() for _, milestone := range []int{25, 50, 75, 100} { if pct >= milestone && pb.lastMilestone < milestone { - fmt.Fprintf(pb.ui.out, "Upload: %d%%\n", milestone) + fmt.Fprintf(pb.ui.errOut, "Upload: %d%%\n", milestone) pb.lastMilestone = milestone } } @@ -451,7 +483,7 @@ func (pb *ProgressBar) Done() { pb.program.Send(progressDoneMsg{success: true}) <-pb.done } else { - fmt.Fprintln(pb.ui.out, "Upload complete") + fmt.Fprintln(pb.ui.errOut, "Upload complete") } } @@ -828,3 +860,17 @@ func (ui *UI) TextInput(label string) (string, error) { } return m.textInput.Value(), nil } + +func (ui *UI) printOut(style lipgloss.Style, msg string) { + if ui.isTTY { + msg = style.Render(msg) + } + fmt.Fprintln(ui.out, msg) +} + +func (ui *UI) printErr(style lipgloss.Style, msg string) { + if ui.errIsTTY { + msg = style.Render(msg) + } + fmt.Fprintln(ui.errOut, msg) +} diff --git a/internal/cli/ui_test.go b/internal/cli/ui_test.go index cf7b254..d6a8538 100644 --- a/internal/cli/ui_test.go +++ b/internal/cli/ui_test.go @@ -644,3 +644,50 @@ func TestTextInput_NonTTY_ReturnsError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "TTY") } + +func TestUI_SplitStreams(t *testing.T) { + newSplitUI := func() (*cli.UI, *bytes.Buffer, *bytes.Buffer) { + var out, errOut bytes.Buffer + return cli.NewUIWithSplitOptions(&out, &errOut, false, nil), &out, &errOut + } + + t.Run("results go to stdout", func(t *testing.T) { + ui, out, errOut := newSplitUI() + ui.Result("Build complete") + ui.Success("Authenticated") + ui.Log("Email: user@example.com") + assert.Equal(t, "Build complete\nAuthenticated\n Email: user@example.com\n", out.String()) + assert.Empty(t, errOut.String()) + }) + + t.Run("progress, streamed log and errors go to stderr", func(t *testing.T) { + ui, out, errOut := newSplitUI() + ui.Status("Building...") + ui.StreamLog("latexmk -pdf main.tex") + ui.StepSuccess("Build complete (1.0s)") + ui.DimInfo("hint") + ui.Gap() + ui.Errorf("failed: %s", "boom") + assert.Equal(t, "Building...\n latexmk -pdf main.tex\nBuild complete (1.0s)\nhint\n\nfailed: boom\n", errOut.String()) + assert.Empty(t, out.String()) + }) + + t.Run("spinner goes to stderr", func(t *testing.T) { + ui, out, errOut := newSplitUI() + sp := ui.Spin("Loading...") + sp.Update("Still loading...") + sp.Stop("Loaded") + ui.Spin("Again...").Fail("Failed") + assert.Equal(t, "Loading...\nStill loading...\nLoaded\nAgain...\nFailed\n", errOut.String()) + assert.Empty(t, out.String()) + }) + + t.Run("progress bar goes to stderr", func(t *testing.T) { + ui, out, errOut := newSplitUI() + pb := ui.Progress("Uploading 1 files", 100) + pb.Update(1.0) + pb.Done() + assert.Equal(t, "Uploading 1 files (100 B)...\nUpload: 25%\nUpload: 50%\nUpload: 75%\nUpload: 100%\nUpload complete\n", errOut.String()) + assert.Empty(t, out.String()) + }) +} diff --git a/internal/cli/watcher.go b/internal/cli/watcher.go index 3559f65..3b51b14 100644 --- a/internal/cli/watcher.go +++ b/internal/cli/watcher.go @@ -270,7 +270,7 @@ func watchAndBuildWith(ctx context.Context, dir string, ui *UI, build func(conte } defer fw.Close() - ui.Log("") + ui.Gap() ui.Status("Watching for changes... (Ctrl+C to stop)") go fw.Run(ctx) diff --git a/man/tx.1 b/man/tx.1 index 03b2eaf..3522d93 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -141,7 +141,8 @@ Rebuild without using the remote build cache. Show authentication status including email .Pq when available , authentication method, and token expiry. -Always exits 0, even when not authenticated or the session has expired +Exits 3 when not authenticated or when the session or token has expired or +was rejected .Pq see EXIT STATUS . .Ss token create Create a new API token for use in CI pipelines or other non-interactive @@ -411,16 +412,40 @@ is set to an absolute path. Default credentials file location. .El .Sh EXIT STATUS +Results (the build summary, +.Cm status +fields, the +.Cm token list +table and the token printed by +.Cm token create ) +are written to standard output. +Progress, the streamed build log and error messages are written to standard +error; each error is printed once. .Bl -tag -width Ds .It 0 Success, including help and version output. .It 1 -Command failure, including parse errors, configuration errors, build failures, -and runtime errors. -Note: -.Cm status -exits 0 even when not authenticated or the session has expired. +Service or unexpected failure: network errors, server errors, sync or upload +failures, or a build that hit the time limit or failed inside the service. +.It 2 +Usage error: unknown command, missing subcommand or unknown flag, bad flag +value, unknown document name, +or missing required input when there is no terminal to prompt on. +.It 3 +Not authenticated, or the session or API token has expired or was rejected. +.It 4 +Project configuration error: +.Pa .texops.yaml +is missing or invalid, or its TeX Live version is not supported. +.It 5 +A document failed to compile (LaTeX errors, or no PDF was produced). .El +.Pp +When several documents fail for different reasons, +.Cm build +exits with the most severe code: 1 if any failure was a service failure, +otherwise 3 for an authentication failure, then 4 for a configuration failure. +It exits 5 only when every failure is a compile failure. .Sh EXAMPLES Build a specific document: .Pp diff --git a/man/tx.1.txt b/man/tx.1.txt index 0041f77..db3b46b 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -99,8 +99,9 @@ COMMANDS status Show authentication status including email (when available), - authentication method, and token expiry. Always exits 0, even when not - authenticated or the session has expired (see EXIT STATUS). + authentication method, and token expiry. Exits 3 when not authenticated + or when the session or token has expired or was rejected (see EXIT + STATUS). token create Create a new API token for use in CI pipelines or other non-interactive @@ -281,11 +282,34 @@ FILES Default credentials file location. EXIT STATUS + Results (the build summary, status fields, the token list table and the + token printed by token create) are written to standard output. Progress, + the streamed build log and error messages are written to standard error; + each error is printed once. + 0 Success, including help and version output. - 1 Command failure, including parse errors, configuration errors, - build failures, and runtime errors. Note: status exits 0 even - when not authenticated or the session has expired. + 1 Service or unexpected failure: network errors, server errors, + sync or upload failures, or a build that hit the time limit or + failed inside the service. + + 2 Usage error: unknown command, missing subcommand or unknown flag, + bad flag value, unknown document name, or missing required input + when there is no terminal to prompt on. + + 3 Not authenticated, or the session or API token has expired or was + rejected. + + 4 Project configuration error: .texops.yaml is missing or invalid, + or its TeX Live version is not supported. + + 5 A document failed to compile (LaTeX errors, or no PDF was + produced). + + When several documents fail for different reasons, build exits with the + most severe code: 1 if any failure was a service failure, otherwise 3 for + an authentication failure, then 4 for a configuration failure. It exits + 5 only when every failure is a compile failure. EXAMPLES Build a specific document: diff --git a/site/docs/cli.md b/site/docs/cli.md index 8e24944..c3ced31 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -31,7 +31,7 @@ If `.texops.yaml` does not exist and stdout is a TTY, an interactive prompt offe ## `tx status` -Show authentication status including email, authentication method, and token expiry. Always exits `0`, even when not authenticated. +Show authentication status including email, authentication method, and token expiry. Exits `3` when not authenticated or when the session or token has expired or was rejected. ## `tx token create` @@ -80,9 +80,19 @@ Print the `tx` version and exit. Also available as `tx --version`. | `$XDG_CONFIG_HOME/texops/credentials.yaml` | Credentials file when `XDG_CONFIG_HOME` is set. | | `~/.config/texops/credentials.yaml` | Default credentials file location. | +## Output streams + +Results go to stdout: the build summary, the `tx status` fields, the `tx token list` table and the token value printed by `tx token create`. Progress, the streamed build log, hints and error messages go to stderr. On a terminal both appear on screen; in a pipe, stdout carries only the results. Each error is printed once, to stderr. + ## Exit status | Code | Meaning | |------|---------| -| `0` | Success (including `tx status` when not authenticated). | -| `1` | Failure: parse errors, configuration errors, build failures, runtime errors. | +| `0` | Success, including `--help` and `tx version`. | +| `1` | Service or unexpected failure: network errors, server errors (5xx), sync or upload failures, a build that hit the time limit or failed inside the service. | +| `2` | Usage error: unknown command, missing subcommand or unknown flag, bad flag value, unknown document name, missing required input when there is no terminal to prompt on. | +| `3` | Not authenticated, or the session or API token has expired or was rejected. | +| `4` | Project configuration error: `.texops.yaml` is missing or invalid, or its TeX Live version is not supported. | +| `5` | A document failed to compile (LaTeX errors, or no PDF was produced). | + +When several documents fail for different reasons, `tx build` exits with the most severe code: `1` if any failure was a service failure, otherwise `3` for an authentication failure, then `4` for a configuration failure. It exits `5` only when every failure is a compile failure. From 4e3f57a0205f8c5c921a7632d00500c92757e023 Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 19:46:33 +0100 Subject: [PATCH 02/11] Detect coding agents and send a User-Agent --- internal/cli/agent.go | 67 ++++++++++ internal/cli/agent_test.go | 254 +++++++++++++++++++++++++++++++++++++ internal/cli/client.go | 41 +++--- internal/cli/run.go | 2 + internal/cli/run_test.go | 17 ++- 5 files changed, 363 insertions(+), 18 deletions(-) create mode 100644 internal/cli/agent.go create mode 100644 internal/cli/agent_test.go diff --git a/internal/cli/agent.go b/internal/cli/agent.go new file mode 100644 index 0000000..0cd8d19 --- /dev/null +++ b/internal/cli/agent.go @@ -0,0 +1,67 @@ +package cli + +import ( + "fmt" + "os" + "runtime" + "strings" + "sync/atomic" +) + +// DetectAgent returns the name of the coding agent tx runs under, or "" when none is detected. +func DetectAgent(getenv func(string) string) string { + if getenv("TX_AGENT") == "none" { + return "" + } + if name, _, _ := strings.Cut(getenv("AI_AGENT"), "_"); name != "" { + return name + } + if getenv("CLAUDECODE") == "1" { + return "claude-code" + } + if getenv("CODEX_THREAD_ID") != "" { + return "codex" + } + if getenv("CURSOR_AGENT") != "" { + return "cursor" + } + if getenv("GEMINI_CLI") != "" { + return "gemini-cli" + } + return "" +} + +// UserAgent formats the User-Agent header value sent with every request. +func UserAgent(version, agent string) string { + ua := fmt.Sprintf("tx/%s (%s; %s)", version, runtime.GOOS, runtime.GOARCH) + if agent = sanitizeUAToken(agent); agent != "" { + ua += " agent/" + agent + } + return ua +} + +func sanitizeUAToken(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', strings.ContainsRune(".-+", r): + return r + default: + return -1 + } + }, s) +} + +var appVersion atomic.Value + +// SetVersion sets the tx version reported in the User-Agent header. +func SetVersion(v string) { + appVersion.Store(v) +} + +func currentUserAgent() string { + v, _ := appVersion.Load().(string) + if v == "" { + v = "dev" + } + return UserAgent(v, DetectAgent(os.Getenv)) +} diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go new file mode 100644 index 0000000..b80b90f --- /dev/null +++ b/internal/cli/agent_test.go @@ -0,0 +1,254 @@ +package cli_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +var agentEnvVars = []string{"TX_AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_THREAD_ID", "CURSOR_AGENT", "GEMINI_CLI"} + +func envFrom(vars map[string]string) func(string) string { + return func(key string) string { return vars[key] } +} + +func setAgentEnv(t *testing.T, kv ...string) { + t.Helper() + for _, name := range agentEnvVars { + t.Setenv(name, "") + } + for i := 0; i+1 < len(kv); i += 2 { + t.Setenv(kv[i], kv[i+1]) + } +} + +func setVersion(t *testing.T, v string) { + t.Helper() + cli.SetVersion(v) + t.Cleanup(func() { cli.SetVersion("") }) +} + +func platform() string { + return fmt.Sprintf("(%s; %s)", runtime.GOOS, runtime.GOARCH) +} + +func TestDetectAgent(t *testing.T) { + t.Run("no variables", func(t *testing.T) { + assert.Empty(t, cli.DetectAgent(envFrom(nil))) + }) + + t.Run("TX_AGENT=none disables detection", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{ + "TX_AGENT": "none", + "AI_AGENT": "amp", + "CLAUDECODE": "1", + "CODEX_THREAD_ID": "thr_1", + "CURSOR_AGENT": "1", + "GEMINI_CLI": "1", + })) + assert.Empty(t, got) + }) + + t.Run("TX_AGENT with another value does not disable detection", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"TX_AGENT": "yes", "CLAUDECODE": "1"})) + assert.Equal(t, "claude-code", got) + }) + + t.Run("AI_AGENT is used up to the first underscore", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"AI_AGENT": "amp_1.2.3_extra"})) + assert.Equal(t, "amp", got) + }) + + t.Run("AI_AGENT without an underscore is used whole", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"AI_AGENT": "opencode"})) + assert.Equal(t, "opencode", got) + }) + + t.Run("AI_AGENT starting with an underscore falls through", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"AI_AGENT": "_x", "CLAUDECODE": "1"})) + assert.Equal(t, "claude-code", got) + }) + + t.Run("AI_AGENT wins over agent-specific variables", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"AI_AGENT": "amp", "CLAUDECODE": "1", "CODEX_THREAD_ID": "thr_1"})) + assert.Equal(t, "amp", got) + }) + + t.Run("CLAUDECODE=1 is claude-code", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"CLAUDECODE": "1"})) + assert.Equal(t, "claude-code", got) + }) + + t.Run("CLAUDECODE other than 1 is ignored", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"CLAUDECODE": "0"})) + assert.Empty(t, got) + }) + + t.Run("CLAUDECODE wins over CODEX_THREAD_ID", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"CLAUDECODE": "1", "CODEX_THREAD_ID": "thr_1"})) + assert.Equal(t, "claude-code", got) + }) + + t.Run("CODEX_THREAD_ID is codex", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"CODEX_THREAD_ID": "thr_1"})) + assert.Equal(t, "codex", got) + }) + + t.Run("CURSOR_AGENT is cursor", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"CURSOR_AGENT": "1"})) + assert.Equal(t, "cursor", got) + }) + + t.Run("GEMINI_CLI is gemini-cli", func(t *testing.T) { + got := cli.DetectAgent(envFrom(map[string]string{"GEMINI_CLI": "1"})) + assert.Equal(t, "gemini-cli", got) + }) +} + +func TestUserAgent(t *testing.T) { + t.Run("without an agent", func(t *testing.T) { + assert.Equal(t, "tx/1.2.3 "+platform(), cli.UserAgent("1.2.3", "")) + }) + + t.Run("with an agent", func(t *testing.T) { + assert.Equal(t, "tx/1.2.3 "+platform()+" agent/claude-code", cli.UserAgent("1.2.3", "claude-code")) + }) + + t.Run("agent name is reduced to token characters", func(t *testing.T) { + assert.Equal(t, "tx/1.2.3 "+platform()+" agent/myagent", cli.UserAgent("1.2.3", "my agent\r\n")) + }) + + t.Run("agent name without token characters is dropped", func(t *testing.T) { + assert.Equal(t, "tx/1.2.3 "+platform(), cli.UserAgent("1.2.3", " /;")) + }) +} + +type uaRecorder struct { + srv *httptest.Server + + mu sync.Mutex + requests []string +} + +func newUARecorder(t *testing.T, handler http.HandlerFunc) *uaRecorder { + t.Helper() + rec := &uaRecorder{} + rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.requests = append(rec.requests, fmt.Sprintf("%s %s User-Agent: %q", r.Method, r.URL.Path, r.UserAgent())) + rec.mu.Unlock() + handler(w, r) + })) + t.Cleanup(rec.srv.Close) + return rec +} + +func (rec *uaRecorder) log() string { + rec.mu.Lock() + defer rec.mu.Unlock() + return strings.Join(rec.requests, "\n") +} + +func TestUserAgentHeader(t *testing.T) { + whoami := func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"user_id": "usr_1", "auth_method": "api_token"}) + } + pdf := func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("%PDF-1.4 test")) + } + + t.Run("API request carries version, platform and agent", func(t *testing.T) { + setAgentEnv(t, "CLAUDECODE", "1") + setVersion(t, "1.2.3") + rec := newUARecorder(t, whoami) + + api := cli.NewAPIClient(rec.srv.URL, "test-token") + api.SetHTTPClient(rec.srv.Client()) + _, err := api.Whoami() + require.NoError(t, err, rec.log()) + + assert.Equal(t, fmt.Sprintf("GET /auth/whoami User-Agent: %q", "tx/1.2.3 "+platform()+" agent/claude-code"), rec.log()) + }) + + t.Run("unauthenticated API request carries the agent", func(t *testing.T) { + setAgentEnv(t, "GEMINI_CLI", "1") + setVersion(t, "1.2.3") + rec := newUARecorder(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"device_code": "dev_1", "user_code": "ABCD", "verification_url": "https://example.com", "expires_in": 60}) + }) + + api := cli.NewUnauthenticatedAPIClient(rec.srv.URL) + api.SetHTTPClient(rec.srv.Client()) + _, err := api.RequestDeviceCode() + require.NoError(t, err, rec.log()) + + assert.Equal(t, fmt.Sprintf("POST /auth/device-code User-Agent: %q", "tx/1.2.3 "+platform()+" agent/gemini-cli"), rec.log()) + }) + + t.Run("instance request carries version, platform and agent", func(t *testing.T) { + setAgentEnv(t, "CODEX_THREAD_ID", "thr_1") + setVersion(t, "1.2.3") + rec := newUARecorder(t, pdf) + + inst := cli.NewInstanceClient(rec.srv.URL, "instance-jwt") + inst.SetHTTPClient(rec.srv.Client()) + err := inst.DownloadPDF(t.Context(), "prj_test", "bld_1", t.TempDir()+"/out.pdf") + require.NoError(t, err, rec.log()) + + assert.Equal(t, fmt.Sprintf("GET /projects/prj_test/builds/bld_1/output User-Agent: %q", "tx/1.2.3 "+platform()+" agent/codex"), rec.log()) + }) + + t.Run("TX_AGENT=none omits the agent", func(t *testing.T) { + setAgentEnv(t, "TX_AGENT", "none", "CLAUDECODE", "1") + setVersion(t, "1.2.3") + rec := newUARecorder(t, pdf) + + inst := cli.NewInstanceClient(rec.srv.URL, "instance-jwt") + inst.SetHTTPClient(rec.srv.Client()) + err := inst.DownloadPDF(t.Context(), "prj_test", "bld_1", t.TempDir()+"/out.pdf") + require.NoError(t, err, rec.log()) + + assert.Equal(t, fmt.Sprintf("GET /projects/prj_test/builds/bld_1/output User-Agent: %q", "tx/1.2.3 "+platform()), rec.log()) + }) + + t.Run("version defaults to dev", func(t *testing.T) { + setAgentEnv(t) + setVersion(t, "") + rec := newUARecorder(t, whoami) + + api := cli.NewAPIClient(rec.srv.URL, "test-token") + api.SetHTTPClient(rec.srv.Client()) + _, err := api.Whoami() + require.NoError(t, err, rec.log()) + + assert.Equal(t, fmt.Sprintf("GET /auth/whoami User-Agent: %q", "tx/dev "+platform()), rec.log()) + }) + + t.Run("tx build sends the Run version on every API and instance request", func(t *testing.T) { + setAgentEnv(t, "CURSOR_AGENT", "1") + t.Cleanup(func() { cli.SetVersion("") }) + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + require.Equal(t, cli.ExitOK, r.code, r) + + want := "tx/test " + platform() + " agent/cursor" + recorded := f.recordedUserAgents() + require.NotEmpty(t, recorded, r) + for _, rec := range recorded { + assert.Equal(t, want, rec.userAgent, "%s\n%s", rec.request, r) + } + assert.Contains(t, r.requests, "POST /api/projects/prj_test/session", r) + assert.Contains(t, r.requests, "POST /projects/prj_test/build", r) + }) +} diff --git a/internal/cli/client.go b/internal/cli/client.go index c870257..9756354 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -94,6 +94,15 @@ func readErrorBody(resp *http.Response) string { return s } +func newRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", currentUserAgent()) + return req, nil +} + func NewAPIClient(baseURL, apiKey string) *APIClient { return &APIClient{ baseURL: strings.TrimRight(baseURL, "/"), @@ -125,7 +134,7 @@ func (c *APIClient) CreateProject(ctx context.Context, name, distVersion, projec return CreateProjectResponse{}, err } - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/projects", bytes.NewReader(body)) + req, err := newRequest(ctx, "POST", c.baseURL+"/api/projects", bytes.NewReader(body)) if err != nil { return CreateProjectResponse{}, err } @@ -159,7 +168,7 @@ func (c *APIClient) GetSession(ctx context.Context, projectID, distributionVersi return SessionResponse{}, err } - req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) + req, err := newRequest(ctx, "POST", u, bytes.NewReader(body)) if err != nil { return SessionResponse{}, err } @@ -184,7 +193,7 @@ func (c *APIClient) GetSession(ctx context.Context, projectID, distributionVersi } func (c *APIClient) RequestDeviceCode() (DeviceCodeResponse, error) { - req, err := http.NewRequest("POST", c.baseURL+"/auth/device-code", nil) + req, err := newRequest(context.Background(), "POST", c.baseURL+"/auth/device-code", nil) if err != nil { return DeviceCodeResponse{}, err } @@ -212,7 +221,7 @@ func (c *APIClient) PollToken(deviceCode string) (TokenResponse, error) { return TokenResponse{}, err } - req, err := http.NewRequest("POST", c.baseURL+"/auth/token", bytes.NewReader(body)) + req, err := newRequest(context.Background(), "POST", c.baseURL+"/auth/token", bytes.NewReader(body)) if err != nil { return TokenResponse{}, err } @@ -241,7 +250,7 @@ func (c *APIClient) PollToken(deviceCode string) (TokenResponse, error) { } func (c *APIClient) RefreshToken(jwt string) (TokenResponse, error) { - req, err := http.NewRequest("POST", c.baseURL+"/auth/refresh", nil) + req, err := newRequest(context.Background(), "POST", c.baseURL+"/auth/refresh", nil) if err != nil { return TokenResponse{}, err } @@ -272,7 +281,7 @@ type WhoamiResponse struct { } func (c *APIClient) Whoami() (WhoamiResponse, error) { - req, err := http.NewRequest("GET", c.baseURL+"/auth/whoami", nil) + req, err := newRequest(context.Background(), "GET", c.baseURL+"/auth/whoami", nil) if err != nil { return WhoamiResponse{}, err } @@ -332,7 +341,7 @@ func (c *APIClient) CreateAPIToken(name string, expiresIn *int64) (APITokenRespo return APITokenResponse{}, err } - req, err := http.NewRequest("POST", c.baseURL+"/auth/tokens", bytes.NewReader(body)) + req, err := newRequest(context.Background(), "POST", c.baseURL+"/auth/tokens", bytes.NewReader(body)) if err != nil { return APITokenResponse{}, err } @@ -360,7 +369,7 @@ func (c *APIClient) CreateAPIToken(name string, expiresIn *int64) (APITokenRespo } func (c *APIClient) ListAPITokens() ([]APITokenListItem, error) { - req, err := http.NewRequest("GET", c.baseURL+"/auth/tokens", nil) + req, err := newRequest(context.Background(), "GET", c.baseURL+"/auth/tokens", nil) if err != nil { return nil, err } @@ -387,7 +396,7 @@ func (c *APIClient) DeleteAPIToken(tokenID string) error { if !validIDPattern.MatchString(tokenID) { return fmt.Errorf("invalid token ID format") } - req, err := http.NewRequest("DELETE", c.baseURL+"/auth/tokens/"+tokenID, nil) + req, err := newRequest(context.Background(), "DELETE", c.baseURL+"/auth/tokens/"+tokenID, nil) if err != nil { return err } @@ -433,7 +442,7 @@ var ErrStartUnsupported = errors.New("server does not support sandbox start") // Start brings the project's sandbox up, calling onLog with each progress message. func (c *InstanceClient) Start(ctx context.Context, projectID string, onLog func(string)) error { u := fmt.Sprintf("%s/projects/%s/start", c.baseURL, projectID) - req, err := http.NewRequestWithContext(ctx, "POST", u, nil) + req, err := newRequest(ctx, "POST", u, nil) if err != nil { return err } @@ -474,7 +483,7 @@ func (c *InstanceClient) Sync(ctx context.Context, projectID string, files []Fil u := fmt.Sprintf("%s/projects/%s/sync", c.baseURL, projectID) - req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(data)) + req, err := newRequest(ctx, "POST", u, bytes.NewReader(data)) if err != nil { return SyncResult{}, err } @@ -584,7 +593,7 @@ func planUploadChunks(dir string, filePaths []string, limit int64) ([]uploadChun 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.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(tarData)) + req, err := newRequest(ctx, "POST", u, bytes.NewReader(tarData)) if err != nil { return err } @@ -624,7 +633,7 @@ func (c *InstanceClient) BuildWithArgs(ctx context.Context, projectID, main, dir u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID) - req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) + req, err := newRequest(ctx, "POST", u, bytes.NewReader(body)) if err != nil { return BuildDoneEvent{}, err } @@ -662,7 +671,7 @@ func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID) - req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) + req, err := newRequest(ctx, "POST", u, bytes.NewReader(body)) if err != nil { return BuildDoneEvent{}, err } @@ -688,7 +697,7 @@ func (c *InstanceClient) DownloadPDF(ctx context.Context, projectID, buildID, ou } u := fmt.Sprintf("%s/projects/%s/builds/%s/output", c.baseURL, projectID, buildID) - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + req, err := newRequest(ctx, "GET", u, nil) if err != nil { return err } @@ -723,7 +732,7 @@ func (c *InstanceClient) uploadTar(ctx context.Context, projectID string, tarDat } } - req, err := http.NewRequestWithContext(ctx, "POST", u, body) + req, err := newRequest(ctx, "POST", u, body) if err != nil { return err } diff --git a/internal/cli/run.go b/internal/cli/run.go index 6f592cc..3e6072c 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -11,6 +11,8 @@ import ( // Run executes tx with args (without the program name) and returns the // process exit code. Every error is printed exactly once, to stderr. func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + SetVersion(version) + if len(args) == 1 && (args[0] == "--version" || args[0] == "version") { fmt.Fprintln(stdout, "tx "+version) return ExitOK diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 933c0ff..df83be0 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -29,8 +29,14 @@ type fakeTexOps struct { deviceCodeStatus int done map[string]map[string]any - mu sync.Mutex - exchange []string + mu sync.Mutex + exchange []string + userAgents []requestUA +} + +type requestUA struct { + request string + userAgent string } func newFakeTexOps(t *testing.T) *fakeTexOps { @@ -68,6 +74,7 @@ func (f *fakeTexOps) serve(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.exchange = append(f.exchange, fmt.Sprintf("%s %s %s\n -> %d %s", r.Method, r.URL.Path, reqBody.String(), rw.status, rw.body.String())) + f.userAgents = append(f.userAgents, requestUA{request: r.Method + " " + r.URL.Path, userAgent: r.UserAgent()}) } func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) { @@ -142,6 +149,12 @@ func (f *fakeTexOps) exchanges() string { return strings.Join(f.exchange, "\n") } +func (f *fakeTexOps) recordedUserAgents() []requestUA { + f.mu.Lock() + defer f.mu.Unlock() + return append([]requestUA(nil), f.userAgents...) +} + type txRun struct { code int stdout string From 5e12e458df2cd653ef9d6df9c96c94c235ec9bb4 Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 19:55:56 +0100 Subject: [PATCH 03/11] Add --json output to every command --- internal/cli/client.go | 10 + internal/cli/commands.go | 208 +++++++------ internal/cli/exit.go | 22 +- internal/cli/global_config.go | 19 +- internal/cli/json_test.go | 559 ++++++++++++++++++++++++++++++++++ internal/cli/output.go | 249 +++++++++++++++ internal/cli/run.go | 26 ++ internal/cli/run_test.go | 21 +- internal/cli/ui.go | 30 ++ 9 files changed, 1036 insertions(+), 108 deletions(-) create mode 100644 internal/cli/json_test.go create mode 100644 internal/cli/output.go diff --git a/internal/cli/client.go b/internal/cli/client.go index 9756354..42ffd10 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -65,6 +65,16 @@ type BuildDoneEvent struct { Reason string `json:"reason,omitempty"` } +// Diagnostic is one LaTeX error or warning reported for a build. +type Diagnostic struct { + Severity string `json:"severity"` + Kind string `json:"kind,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Message string `json:"message"` + Context string `json:"context,omitempty"` +} + type DeviceCodeResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` diff --git a/internal/cli/commands.go b/internal/cli/commands.go index bc651c8..9396bac 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -40,6 +40,7 @@ func openBrowser(url string) error { var PollInterval = 5 * time.Second type Options struct { + JSON bool `long:"json" description:"Print one JSON document to stdout (progress goes to stderr)"` Login LoginCmd `command:"login" description:"Log in to TexOps"` Init InitCmd `command:"init" description:"Initialize a new TexOps project"` Build BuildCmd `command:"build" description:"Build the LaTeX project"` @@ -98,10 +99,25 @@ var RunBuild = runBuild // docResult tracks the outcome of building a single document. type docResult struct { - Name string - Output string - Success bool - Err error + Name string + Main string + Output string + Success bool + Err error + Reason string + BuildID string + Log string + Duration time.Duration + Diagnostics []Diagnostic + Truncated bool +} + +func failedDoc(doc Document, err error, reason string) docResult { + return docResult{Name: doc.Name, Main: docMainPath(doc), Output: doc.Output, Err: err, Reason: reason} +} + +func docMainPath(doc Document) string { + return filepath.Join(doc.Directory, doc.Main) } type buildParams struct { @@ -140,9 +156,20 @@ func (cmd *StatusCmd) Execute(args []string) error { ui = defaultUI() } - token, err := ResolveAuth() + res, err := checkStatus(ui) + if err != nil { + if ui.JSON() && AsExitError(err).Code == ExitAuth { + _ = ui.WriteJSON(map[string]bool{"authenticated": false}) + } + return err + } + return renderStatus(ui, res) +} + +func checkStatus(ui *UI) (statusResult, error) { + token, source, err := resolveAuthWithSource() if err != nil { - return authError(errors.New("Not authenticated. Run 'tx login' to log in to TexOps.")) + return statusResult{}, authError(errors.New("Not authenticated. Run 'tx login' to log in to TexOps.")) } apiURL := ResolveAPIURL(Config{}) @@ -153,25 +180,19 @@ func (cmd *StatusCmd) Execute(args []string) error { if err != nil { sp.Fail("Authentication check failed") if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.StatusCode == http.StatusUnauthorized { - return authError(errors.New("Session expired. Run 'tx login' to re-authenticate.")) + return statusResult{}, authError(errors.New("Session expired. Run 'tx login' to re-authenticate.")) } - return err + return statusResult{}, err } sp.Stop("Connected") - ui.Success("Authenticated") - if resp.Email != "" { - ui.Log(fmt.Sprintf("Email: %s", resp.Email)) - } - - methodLabel := resp.AuthMethod - if resp.AuthMethod == "api_token" { - methodLabel = "API token" - } - ui.Log(fmt.Sprintf("Method: %s", methodLabel)) - ui.Log("Expires: " + formatDatePtr(resp.ExpiresAt, "never")) - - return nil + return statusResult{ + Authenticated: true, + Email: nullable(resp.Email), + Method: resp.AuthMethod, + Source: source, + ExpiresAt: resp.ExpiresAt, + }, nil } func (cmd *LoginCmd) Execute(args []string) error { @@ -234,8 +255,7 @@ func (cmd *LoginCmd) Execute(args []string) error { return err } - ui.Success("Logged in successfully") - return nil + return renderLogin(ui, loginResult{Authenticated: true}) } // DiscoverDocuments recursively finds .tex files containing \documentclass @@ -423,17 +443,21 @@ func (cmd *InitCmd) Execute(args []string) error { return usageErrorf("invalid compiler %q: allowed values are %s", cmd.Compiler, strings.Join(AllowedCompilers, ", ")) } - return initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, ui) + res, err := initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, ui) + if err != nil { + return err + } + return renderInit(ui, res) } -func initProject(dir, texlive, compiler, mainFallback string, ui *UI) error { +func initProject(dir, texlive, compiler, mainFallback string, ui *UI) (initResult, error) { configPath := filepath.Join(dir, ".texops.yaml") if texlive == "" { if ui.IsInteractive() { idx, err := ui.SelectOne("TexLive version:", TexliveVersions) if err != nil { - return fmt.Errorf("TexLive selection failed: %w", err) + return initResult{}, fmt.Errorf("TexLive selection failed: %w", err) } texlive = TexliveVersions[idx] } else { @@ -445,7 +469,7 @@ func initProject(dir, texlive, compiler, mainFallback string, ui *UI) error { if ui.IsInteractive() { idx, err := ui.SelectOne("Compiler:", AllowedCompilers) if err != nil { - return fmt.Errorf("compiler selection failed: %w", err) + return initResult{}, fmt.Errorf("compiler selection failed: %w", err) } compiler = AllowedCompilers[idx] } else { @@ -455,7 +479,7 @@ func initProject(dir, texlive, compiler, mainFallback string, ui *UI) error { discovered, err := DiscoverDocuments(dir) if err != nil { - return fmt.Errorf("failed to discover documents: %w", err) + return initResult{}, fmt.Errorf("failed to discover documents: %w", err) } var selected []Document @@ -463,10 +487,10 @@ func initProject(dir, texlive, compiler, mainFallback string, ui *UI) error { if ui.IsInteractive() { selected, err = ui.SelectDocuments(discovered) if err != nil { - return fmt.Errorf("document selection failed: %w", err) + return initResult{}, fmt.Errorf("document selection failed: %w", err) } if len(selected) == 0 { - return fmt.Errorf("no documents selected") + return initResult{}, fmt.Errorf("no documents selected") } } else { selected = discovered @@ -480,20 +504,25 @@ func initProject(dir, texlive, compiler, mainFallback string, ui *UI) error { projectKey, err := generateProjectKey() if err != nil { - return err + return initResult{}, err } configContent := generateConfigYAML(projectKey, texlive, compiler, selected) if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil { - return fmt.Errorf("failed to write .texops.yaml: %w", err) + return initResult{}, fmt.Errorf("failed to write .texops.yaml: %w", err) } - if len(discovered) > 0 { - ui.Success(fmt.Sprintf("Created .texops.yaml with %d document(s)", len(selected))) - } else { - ui.Success("Created .texops.yaml") + res := initResult{ + Config: ".texops.yaml", + Texlive: texlive, + Compiler: compiler, + Documents: make([]initDocument, 0, len(selected)), + discovered: len(discovered) > 0, } - return nil + for _, doc := range selected { + res.Documents = append(res.Documents, initDocument{Name: doc.Name, Main: doc.Main, Directory: doc.Directory}) + } + return res, nil } // generateConfigYAML produces .texops.yaml content from a project key, texlive version, compiler, and documents. @@ -521,6 +550,9 @@ func (cmd *BuildCmd) Execute(args []string) error { if ui == nil { ui = defaultUI() } + if cmd.Live && ui.JSON() { + return usageErrorf("--json cannot be used with --live") + } var ctx context.Context if cmd.Live { var stop context.CancelFunc @@ -557,9 +589,15 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv if !confirmed { return configError(errInitDeclined) } - if initErr := initProject(dir, "", "", "main.tex", ui); initErr != nil { + res, initErr := initProject(dir, "", "", "main.tex", ui) + if initErr != nil { return initErr } + if !ui.JSON() { + if err := renderInit(ui, res); err != nil { + return err + } + } configData, err = os.ReadFile(configPath) if err != nil { return err @@ -634,25 +672,8 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv return err } - // Print build summary - succeeded := 0 - failed := 0 - for _, r := range results { - if r.Success { - succeeded++ - } else { - failed++ - } - } - elapsed := time.Since(buildStart) - ui.Gap() - ui.Result(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) - for _, r := range results { - if r.Success { - ui.Log(fmt.Sprintf(" %s => %s", r.Name, r.Output)) - } else { - ui.Log(fmt.Sprintf(" %s !! FAILED", r.Name)) - } + if err := renderBuild(ui, results, time.Since(buildStart)); err != nil { + return err } if live { @@ -727,8 +748,9 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { return nil, ctx.Err() } sp.Fail(fmt.Sprintf("Failed to get session: %s", err)) + err = classifyVersionError(err) for _, doc := range group.docs { - results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: classifyVersionError(err)}) + results = append(results, failedDoc(doc, err, failureReason(err, "internal"))) } continue } @@ -741,7 +763,7 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { return nil, ctx.Err() } for _, doc := range group.docs { - results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) + results = append(results, failedDoc(doc, err, failureReason(err, "internal"))) } continue } @@ -755,7 +777,7 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { } sp.Fail(fmt.Sprintf("Sync failed: %s", err)) for _, doc := range group.docs { - results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) + results = append(results, failedDoc(doc, err, failureReason(err, "sync"))) } continue } @@ -768,7 +790,7 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { return nil, err } for _, doc := range group.docs { - results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) + results = append(results, failedDoc(doc, err, failureReason(err, "sync"))) } continue } @@ -869,22 +891,23 @@ func handleUpload(ctx context.Context, ui *UI, inst *InstanceClient, projectID, // buildDocument builds a single document and returns the result. func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, dir string, doc Document, noCache bool) docResult { - displayMain := doc.Main - if doc.Directory != "" { - displayMain = filepath.Join(doc.Directory, doc.Main) - } - ui.Status(fmt.Sprintf("Building %q (%s)...", doc.Name, displayMain)) + ui.Status(fmt.Sprintf("Building %q (%s)...", doc.Name, docMainPath(doc))) var buildOptions map[string]string if noCache { buildOptions = map[string]string{"no_cache": "true"} } compileStart := time.Now() + fail := func(err error, reason string) docResult { + r := failedDoc(doc, err, reason) + r.Duration = time.Since(compileStart) + return r + } result, err := inst.Build(ctx, projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { ui.StreamLog(line) }) if err != nil { ui.Errorf("Build request failed: %s", err) - return docResult{Name: doc.Name, Output: doc.Output, Err: err} + return fail(err, failureReason(err, "internal")) } if result.Status == "success" && result.PdfURL != "" { @@ -892,16 +915,18 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, ui.StepSuccess(fmt.Sprintf("Build complete (%.1fs)", compileElapsed.Seconds())) outputPath := filepath.Join(dir, doc.Output) if err := os.MkdirAll(filepath.Dir(outputPath), 0o750); err != nil { - return docResult{Name: doc.Name, Output: doc.Output, Err: fmt.Errorf("cannot create output directory: %w", err)} + return fail(fmt.Errorf("cannot create output directory: %w", err), "internal") } if result.BuildID == "" { - return docResult{Name: doc.Name, Output: doc.Output, Err: fmt.Errorf("server did not return build_id in done event")} + return fail(fmt.Errorf("server did not return build_id in done event"), "internal") } sp := ui.Spin(fmt.Sprintf("Downloading %s...", doc.Output)) if err := inst.DownloadPDF(ctx, projectID, result.BuildID, outputPath); err != nil { sp.Fail(fmt.Sprintf("Download failed: %s", err)) - return docResult{Name: doc.Name, Output: doc.Output, Err: err} + r := fail(err, failureReason(err, "internal")) + r.BuildID = result.BuildID + return r } info, _ := os.Stat(outputPath) @@ -912,7 +937,14 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, sizeInfo = fmt.Sprintf("%s (%.1fs)", doc.Output, compileElapsed.Seconds()) } sp.Stop(sizeInfo) - return docResult{Name: doc.Name, Output: doc.Output, Success: true} + return docResult{ + Name: doc.Name, + Main: docMainPath(doc), + Output: doc.Output, + Success: true, + BuildID: result.BuildID, + Duration: time.Since(compileStart), + } } msg := result.Message @@ -920,7 +952,9 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, msg = "unknown error" } ui.Errorf("Build failed: %s", msg) - return docResult{Name: doc.Name, Output: doc.Output, Err: buildFailureError(result, fmt.Errorf("build failed: %s", msg))} + r := fail(buildFailureError(result, fmt.Errorf("build failed: %s", msg)), buildFailureReason(result)) + r.BuildID = result.BuildID + return r } // docNames returns a comma-separated list of document names. @@ -1045,13 +1079,11 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { } sp.Stop("Token created") - ui.Gap() - ui.Result(resp.Token) - ui.Gap() - ui.DimInfo("This token won't be shown again. Copy it now.") - ui.DimInfo("Expires: " + formatDatePtr(resp.ExpiresAt, "never")) - - return nil + name := resp.Name + if name == "" { + name = cmd.Name + } + return renderTokenCreate(ui, tokenCreateResult{Name: name, Token: resp.Token, ExpiresAt: resp.ExpiresAt}) } func (cmd *TokenListCmd) Execute(args []string) error { @@ -1076,21 +1108,7 @@ func (cmd *TokenListCmd) Execute(args []string) error { } sp.Stop(fmt.Sprintf("%d token(s)", len(tokens))) - if len(tokens) == 0 { - ui.DimInfo("No tokens found. Create one with 'tx token create --name '.") - return nil - } - - // Print table - ui.Log(fmt.Sprintf("%-20s %-12s %-14s %-14s %-12s", "NAME", "PREFIX", "EXPIRES", "LAST USED", "CREATED")) - for _, tok := range tokens { - expires := formatDatePtr(tok.ExpiresAt, "never") - lastUsed := formatDatePtr(tok.LastUsedAt, "never") - created := formatDate(tok.CreatedAt) - ui.Log(fmt.Sprintf("%-20s %-12s %-14s %-14s %-12s", tok.Name, tok.Prefix, expires, lastUsed, created)) - } - - return nil + return renderTokenList(ui, tokens) } func (cmd *TokenDeleteCmd) Execute(args []string) error { @@ -1172,5 +1190,5 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { } sp.Stop("Token deleted") - return nil + return renderTokenDelete(ui, tokenDeleteResult{Deleted: tokenName}) } diff --git a/internal/cli/exit.go b/internal/cli/exit.go index 85098f2..7e0f287 100644 --- a/internal/cli/exit.go +++ b/internal/cli/exit.go @@ -85,19 +85,25 @@ func isNetworkError(err error) bool { return ok } -// buildFailureError maps a failed build's done event to an exit code: the -// server's reason decides; an old server without a reason counts as a LaTeX -// failure when it allocated a build. +// buildFailureReason is the server's reason for a failed build; an old server +// without a reason counts as a LaTeX failure when it allocated a build. +func buildFailureReason(done BuildDoneEvent) string { + switch { + case done.Reason != "": + return done.Reason + case done.BuildID != "": + return "latex_error" + } + return "internal" +} + +// buildFailureError maps a failed build's done event to an exit code. func buildFailureError(done BuildDoneEvent, err error) error { - switch done.Reason { + switch buildFailureReason(done) { case "latex_error", "no_pdf": return &ExitError{Code: ExitBuildFailed, Kind: KindBuildFailed, Err: err} case "timeout": return &ExitError{Code: ExitFailure, Kind: KindTimeout, Err: err} - case "": - if done.BuildID != "" { - return &ExitError{Code: ExitBuildFailed, Kind: KindBuildFailed, Err: err} - } } return &ExitError{Code: ExitFailure, Kind: KindInternal, Err: err} } diff --git a/internal/cli/global_config.go b/internal/cli/global_config.go index ff889b7..e78f6ef 100644 --- a/internal/cli/global_config.go +++ b/internal/cli/global_config.go @@ -81,9 +81,20 @@ func jwtExpiry(token string) time.Time { // JWT from keyring. Each source can override the ones below it. // If a JWT is found but expired, it falls through to the next source. func ResolveAuth() (string, error) { + token, _, err := resolveAuthWithSource() + return token, err +} + +const ( + authSourceEnv = "env" + authSourceFile = "file" + authSourceKeyring = "keyring" +) + +func resolveAuthWithSource() (string, string, error) { // 1. API token from environment variable (highest priority override) if token := os.Getenv("TX_API_TOKEN"); token != "" { - return token, nil + return token, authSourceEnv, nil } // 2. JWT from credentials file (explicit file override) @@ -91,7 +102,7 @@ func ResolveAuth() (string, error) { if exp := jwtExpiry(jwt); !exp.IsZero() && exp.Before(time.Now()) { // JWT expired, fall through } else { - return jwt, nil + return jwt, authSourceFile, nil } } @@ -100,11 +111,11 @@ func ResolveAuth() (string, error) { if exp := jwtExpiry(jwt); !exp.IsZero() && exp.Before(time.Now()) { // JWT expired, fall through } else { - return jwt, nil + return jwt, authSourceKeyring, nil } } - return "", authError(errors.New("not authenticated: run 'tx login' or set TX_API_TOKEN")) + return "", "", authError(errors.New("not authenticated: run 'tx login' or set TX_API_TOKEN")) } func storeJWT(jwt string) error { diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go new file mode 100644 index 0000000..a6935a1 --- /dev/null +++ b/internal/cli/json_test.go @@ -0,0 +1,559 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +// decodeStdout requires stdout to hold exactly one JSON document and decodes it into v. +func decodeStdout(t *testing.T, r txRun, v any) { + t.Helper() + dec := json.NewDecoder(strings.NewReader(r.stdout)) + require.NoError(t, dec.Decode(v), r) + var extra json.RawMessage + require.ErrorIs(t, dec.Decode(&extra), io.EOF, "more than one JSON document on stdout\n%s", r) +} + +// stdoutWithoutDurations returns stdout with every duration_ms field removed. +func stdoutWithoutDurations(t *testing.T, r txRun) string { + t.Helper() + var doc map[string]any + decodeStdout(t, r, &doc) + delete(doc, "duration_ms") + docs, ok := doc["documents"].([]any) + require.True(t, ok, r) + for _, d := range docs { + delete(d.(map[string]any), "duration_ms") + } + out, err := json.Marshal(doc) + require.NoError(t, err, r) + return string(out) +} + +func durationsMS(t *testing.T, r txRun) []float64 { + t.Helper() + var doc struct { + DurationMS *float64 `json:"duration_ms"` + Documents []struct { + DurationMS *float64 `json:"duration_ms"` + } `json:"documents"` + } + decodeStdout(t, r, &doc) + require.NotNil(t, doc.DurationMS, r) + out := make([]float64, 0, 1+len(doc.Documents)) + out = append(out, *doc.DurationMS) + for _, d := range doc.Documents { + require.NotNil(t, d.DurationMS, r) + out = append(out, *d.DurationMS) + } + return out +} + +func TestRunJSONBuild(t *testing.T) { + t.Run("successful build prints one document and progress on stderr", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{ + "ok": true, + "documents": [ + {"name": "paper", "main": "paper.tex", "status": "succeeded", "reason": null, "output": "paper.pdf", "log": null, "build_id": "bld_ok", "errors": [], "warnings": [], "truncated": false}, + {"name": "slides", "main": "slides.tex", "status": "succeeded", "reason": null, "output": "slides.pdf", "log": null, "build_id": "bld_ok", "errors": [], "warnings": [], "truncated": false} + ] + }`, stdoutWithoutDurations(t, r), r) + for _, d := range durationsMS(t, r) { + assert.GreaterOrEqual(t, d, float64(0), r) + } + assert.Contains(t, r.stderr, "Resolving project...\nProject ready\n", r) + assert.Contains(t, r.stderr, "Building \"paper\" (paper.tex)...\n", r) + assert.NotContains(t, r.stderr, "Build complete: ", r) + }) + + t.Run("--json before the subcommand", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "--json", "build", "paper") + + require.Equal(t, cli.ExitOK, r.code, r) + var doc struct { + OK bool `json:"ok"` + Documents []struct { + Name string `json:"name"` + } `json:"documents"` + } + decodeStdout(t, r, &doc) + assert.True(t, doc.OK, r) + require.Len(t, doc.Documents, 1, r) + assert.Equal(t, "paper", doc.Documents[0].Name, r) + }) + + t.Run("main includes the document directory", func(t *testing.T) { + f := newFakeTexOps(t) + dir := projectDir(t, "project_key: \"k7Gx9mR2pL4wN8qY5vBt3a\"\ntexlive: \"2025\"\ndocuments:\n - name: thesis\n main: thesis.tex\n directory: thesis\n") + require.NoError(t, os.Mkdir(dir+"/thesis", 0o750)) + require.NoError(t, os.WriteFile(dir+"/thesis/thesis.tex", []byte("\\documentclass{article}"), 0o600)) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + var doc struct { + Documents []struct { + Main string `json:"main"` + Output string `json:"output"` + } `json:"documents"` + } + decodeStdout(t, r, &doc) + require.Len(t, doc.Documents, 1, r) + assert.Equal(t, "thesis/thesis.tex", doc.Documents[0].Main, r) + assert.Equal(t, "thesis/thesis.pdf", doc.Documents[0].Output, r) + }) + + t.Run("failed build still prints the build document", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.JSONEq(t, `{ + "ok": false, + "documents": [ + {"name": "paper", "main": "paper.tex", "status": "failed", "reason": "latex_error", "output": null, "log": null, "build_id": "bld_1", "errors": [], "warnings": [], "truncated": false}, + {"name": "slides", "main": "slides.tex", "status": "succeeded", "reason": null, "output": "slides.pdf", "log": null, "build_id": "bld_ok", "errors": [], "warnings": [], "truncated": false} + ] + }`, stdoutWithoutDurations(t, r), r) + assert.Equal(t, 1, strings.Count(r.stderr, "Build failed: build failed with latex_error\n"), r) + assert.True(t, strings.HasSuffix(r.stderr, "\none or more documents failed to build\n"), r) + }) + + t.Run("old server failure with a build_id is a latex_error", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--json") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, stdoutWithoutDurations(t, r), `"reason":"latex_error"`, r) + }) + + t.Run("timeout reason comes from the server", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("timeout", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--json") + + require.Equal(t, cli.ExitFailure, r.code, r) + assert.Contains(t, stdoutWithoutDurations(t, r), `"reason":"timeout"`, r) + }) + + t.Run("sync failure reason is sync", func(t *testing.T) { + f := newFakeTexOps(t) + f.syncStatus = http.StatusInternalServerError + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitFailure, r.code, r) + assert.JSONEq(t, `{ + "ok": false, + "documents": [ + {"name": "paper", "main": "paper.tex", "status": "failed", "reason": "sync", "output": null, "log": null, "build_id": null, "errors": [], "warnings": [], "truncated": false}, + {"name": "slides", "main": "slides.tex", "status": "failed", "reason": "sync", "output": null, "log": null, "build_id": null, "errors": [], "warnings": [], "truncated": false} + ] + }`, stdoutWithoutDurations(t, r), r) + }) + + t.Run("rejected session reason is auth", func(t *testing.T) { + f := newFakeTexOps(t) + f.sessionStatus = http.StatusUnauthorized + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--json") + + require.Equal(t, cli.ExitAuth, r.code, r) + assert.Contains(t, stdoutWithoutDurations(t, r), `"reason":"auth"`, r) + }) + + t.Run("unreachable instance reason is network", func(t *testing.T) { + f := newFakeTexOps(t) + instance := httptest.NewServer(http.NotFoundHandler()) + instance.Close() + f.instanceURL = instance.URL + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitFailure, r.code, r) + var doc struct { + OK bool `json:"ok"` + Documents []struct { + Name string `json:"name"` + Status string `json:"status"` + Reason *string `json:"reason"` + } `json:"documents"` + } + decodeStdout(t, r, &doc) + assert.False(t, doc.OK, r) + require.Len(t, doc.Documents, 2, r) + for _, d := range doc.Documents { + assert.Equal(t, "failed", d.Status, r) + require.NotNil(t, d.Reason, r) + assert.Equal(t, "network", *d.Reason, r) + } + }) + + t.Run("unsupported TeX Live version reason is config", func(t *testing.T) { + f := newFakeTexOps(t) + f.sessionStatus = http.StatusBadRequest + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitConfig, r.code, r) + assert.JSONEq(t, `{ + "ok": false, + "documents": [ + {"name": "paper", "main": "paper.tex", "status": "failed", "reason": "config", "output": null, "log": null, "build_id": null, "errors": [], "warnings": [], "truncated": false}, + {"name": "slides", "main": "slides.tex", "status": "failed", "reason": "config", "output": null, "log": null, "build_id": null, "errors": [], "warnings": [], "truncated": false} + ] + }`, stdoutWithoutDurations(t, r), r) + }) + + t.Run("failure before any document prints an error document", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, "") + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitConfig, r.code, r) + assert.JSONEq(t, `{"error": {"code": "config", "message": "no project config found; run `+"`tx init`"+` to set up your project"}}`, r.stdout, r) + assert.Equal(t, "no project config found; run `tx init` to set up your project\n", r.stderr, r) + }) + + t.Run("unreachable API prints a network error document", func(t *testing.T) { + f := newFakeTexOps(t) + f.srv.Close() + projectDir(t, projectConfig) + + r := runTx(t, nil, "build", "--json") + + require.Equal(t, cli.ExitFailure, r.code, r) + var doc struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + decodeStdout(t, r, &doc) + assert.Equal(t, "network", doc.Error.Code, r) + assert.Contains(t, doc.Error.Message, "connection refused", r) + assert.Equal(t, 1, strings.Count(r.stderr, doc.Error.Message), r) + }) + + t.Run("--json with --live exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json", "--live") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.JSONEq(t, `{"error": {"code": "usage", "message": "--json cannot be used with --live"}}`, r.stdout, r) + assert.Equal(t, "--json cannot be used with --live\n", r.stderr, r) + assert.Empty(t, r.requests, r) + }) +} + +func TestRunJSONStatus(t *testing.T) { + t.Run("authenticated with an API token from the environment", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "status", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"authenticated": true, "email": "user@example.com", "method": "api_token", "source": "env", "expires_at": null}`, r.stdout, r) + assert.Equal(t, "Checking authentication...\nConnected\n", r.stderr, r) + }) + + t.Run("authenticated with a JWT from the keyring", func(t *testing.T) { + f := newFakeTexOps(t) + noCredentials(t) + cli.KeyringGet = func(service, user string) (string, error) { + return "header.payload.sig", nil + } + + r := runTx(t, f, "status", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + var doc map[string]any + decodeStdout(t, r, &doc) + assert.Equal(t, "keyring", doc["source"], r) + }) + + t.Run("authenticated with a JWT from the credentials file", func(t *testing.T) { + f := newFakeTexOps(t) + noCredentials(t) + credPath := withTempCredentialsDir(t) + require.NoError(t, os.MkdirAll(filepath.Dir(credPath), 0o700)) + require.NoError(t, os.WriteFile(credPath, []byte("jwt: "+makeTestJWT(time.Now().Add(time.Hour))+"\n"), 0o600)) + + r := runTx(t, f, "status", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + var doc map[string]any + decodeStdout(t, r, &doc) + assert.Equal(t, true, doc["authenticated"], r) + assert.Equal(t, "file", doc["source"], r) + }) + + t.Run("not authenticated exits 3", func(t *testing.T) { + noCredentials(t) + + r := runTx(t, nil, "status", "--json") + + require.Equal(t, cli.ExitAuth, r.code, r) + assert.JSONEq(t, `{"authenticated": false}`, r.stdout, r) + assert.Equal(t, "Not authenticated. Run 'tx login' to log in to TexOps.\n", r.stderr, r) + }) + + t.Run("rejected token exits 3", func(t *testing.T) { + f := newFakeTexOps(t) + f.whoamiStatus = http.StatusUnauthorized + + r := runTx(t, f, "status", "--json") + + require.Equal(t, cli.ExitAuth, r.code, r) + assert.JSONEq(t, `{"authenticated": false}`, r.stdout, r) + assert.Equal(t, "Checking authentication...\nAuthentication check failed\nSession expired. Run 'tx login' to re-authenticate.\n", r.stderr, r) + }) +} + +func TestRunJSONToken(t *testing.T) { + t.Run("token list", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "list", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `[{"name": "ci", "prefix": "tx_secr", "expires_at": "2027-01-01T00:00:00Z", "last_used_at": null, "created_at": "2026-10-01T00:00:00Z"}]`, r.stdout, r) + assert.Equal(t, "Loading tokens...\n1 token(s)\n", r.stderr, r) + }) + + t.Run("empty token list is an empty array", func(t *testing.T) { + f := newFakeTexOps(t) + f.tokens = []map[string]any{} + + r := runTx(t, f, "token", "list", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `[]`, r.stdout, r) + }) + + t.Run("token create", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "create", "--name", "ci", "--no-expiry", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"name": "ci", "token": "tx_secret_value", "expires_at": null}`, r.stdout, r) + assert.Equal(t, "Creating token...\nToken created\n", r.stderr, r) + }) + + t.Run("token create falls back to the requested name", func(t *testing.T) { + f := newFakeTexOps(t) + f.createdToken = map[string]any{"token": "tx_secret_value", "id": "tok_2", "prefix": "tx_secr", "created_at": "2026-10-01T00:00:00Z"} + + r := runTx(t, f, "token", "create", "--name", "deploy", "--no-expiry", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"name": "deploy", "token": "tx_secret_value", "expires_at": null}`, r.stdout, r) + }) + + t.Run("token create without a name prints an error document", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "--json", "token", "create") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.JSONEq(t, `{"error": {"code": "usage", "message": "specify --name in non-interactive mode"}}`, r.stdout, r) + assert.Equal(t, "specify --name in non-interactive mode\n", r.stderr, r) + }) + + t.Run("token delete", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "delete", "ci", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"deleted": "ci"}`, r.stdout, r) + assert.Equal(t, "Loading tokens...\n1 token(s)\nDeleting token...\nToken deleted\n", r.stderr, r) + assert.Contains(t, r.requests, "DELETE /auth/tokens/tok_1", r) + }) +} + +func TestRunJSONInit(t *testing.T) { + t.Run("discovered documents", func(t *testing.T) { + dir := projectDir(t, "") + + r := runTx(t, nil, "init", "--texlive", "2024", "--compiler", "xelatex", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"config": ".texops.yaml", "texlive": "2024", "compiler": "xelatex", "documents": [{"name": "paper", "main": "paper.tex"}, {"name": "slides", "main": "slides.tex"}]}`, r.stdout, r) + assert.Empty(t, r.stderr, r) + assert.FileExists(t, dir+"/.texops.yaml", r) + }) + + t.Run("documents in a directory", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(dir+"/thesis", 0o750)) + require.NoError(t, os.WriteFile(dir+"/thesis/thesis.tex", []byte("\\documentclass{article}"), 0o600)) + t.Chdir(dir) + + r := runTx(t, nil, "--json", "init") + + require.Equal(t, cli.ExitOK, r.code, r) + var doc map[string]any + decodeStdout(t, r, &doc) + assert.Equal(t, []any{map[string]any{"name": "thesis", "main": "thesis.tex", "directory": "thesis"}}, doc["documents"], r) + }) + + t.Run("existing config prints an error document", func(t *testing.T) { + projectDir(t, projectConfig) + + r := runTx(t, nil, "init", "--json") + + require.Equal(t, cli.ExitConfig, r.code, r) + assert.JSONEq(t, `{"error": {"code": "config", "message": ".texops.yaml already exists"}}`, r.stdout, r) + assert.Equal(t, ".texops.yaml already exists\n", r.stderr, r) + }) +} + +func TestRunJSONLogin(t *testing.T) { + t.Run("successful login", func(t *testing.T) { + f := newFakeTexOps(t) + origSet := cli.KeyringSet + var stored string + cli.KeyringSet = func(service, user, key string) error { + stored = key + return nil + } + t.Cleanup(func() { cli.KeyringSet = origSet }) + origBrowser := cli.OpenBrowser + cli.OpenBrowser = func(string) error { return nil } + t.Cleanup(func() { cli.OpenBrowser = origBrowser }) + origInterval := cli.PollInterval + cli.PollInterval = time.Millisecond + t.Cleanup(func() { cli.PollInterval = origInterval }) + + r := runTx(t, f, "login", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"authenticated": true}`, r.stdout, r) + assert.Equal(t, "header.payload.sig", stored, r) + assert.Contains(t, r.stderr, "Your login code: ABCD-EFGH\n", r) + }) + + t.Run("failed login prints an error document", func(t *testing.T) { + f := newFakeTexOps(t) + f.deviceCodeStatus = http.StatusInternalServerError + + r := runTx(t, f, "login", "--json") + + require.Equal(t, cli.ExitFailure, r.code, r) + assert.JSONEq(t, `{"error": {"code": "internal", "message": "device code request failed (500): device flow unavailable"}}`, r.stdout, r) + }) +} + +func TestRunJSONUsage(t *testing.T) { + t.Run("unknown command prints an error document", func(t *testing.T) { + r := runTx(t, nil, "bogus", "--json") + + require.Equal(t, cli.ExitUsage, r.code, r) + var doc struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + decodeStdout(t, r, &doc) + assert.Equal(t, "usage", doc.Error.Code, r) + assert.Contains(t, doc.Error.Message, "Unknown command `bogus'", r) + }) + + t.Run("no command prints an error document", func(t *testing.T) { + r := runTx(t, nil, "--json") + + require.Equal(t, cli.ExitUsage, r.code, r) + var doc struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + decodeStdout(t, r, &doc) + assert.Equal(t, "usage", doc.Error.Code, r) + assert.Contains(t, doc.Error.Message, "Please specify one command of", r) + }) + + t.Run("unknown flag prints an error document", func(t *testing.T) { + r := runTx(t, nil, "--json", "build", "--bogus") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.JSONEq(t, "{\"error\": {\"code\": \"usage\", \"message\": \"unknown flag `bogus'\"}}", r.stdout, r) + }) + + t.Run("--json after -- is not the flag", func(t *testing.T) { + r := runTx(t, nil, "bogus", "--", "--json") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.Empty(t, r.stdout, r) + }) +} + +func TestUIJSON(t *testing.T) { + t.Run("text results go to stderr in JSON mode", func(t *testing.T) { + var stdout, stderr strings.Builder + ui := cli.NewUIWithSplitOptions(&stdout, &stderr, true, nil) + ui.SetJSON(true) + + ui.Result("result") + ui.Success("done") + sp := ui.Spin("Working...") + sp.Stop("Worked") + require.NoError(t, ui.WriteJSON(map[string]string{"k": ""})) + + assert.Equal(t, "{\"k\":\"\"}\n", stdout.String()) + assert.Equal(t, "result\ndone\nWorking...\nWorked\n", stderr.String()) + assert.False(t, ui.IsTTY()) + assert.False(t, ui.IsInteractive()) + }) + + t.Run("JSON mode never prompts", func(t *testing.T) { + var stdout, stderr strings.Builder + ui := cli.NewUIWithSplitOptions(&stdout, &stderr, true, strings.NewReader("1\n")) + ui.SetJSON(true) + + _, err := ui.Select("Pick", []string{"a"}) + + require.Error(t, err) + assert.Empty(t, stdout.String()) + }) +} diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..8c1a113 --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,249 @@ +package cli + +import ( + "fmt" + "path/filepath" + "time" +) + +type errorJSON struct { + Error errorBodyJSON `json:"error"` +} + +type errorBodyJSON struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeErrorJSON(ui *UI, err error) { + _ = ui.WriteJSON(errorJSON{Error: errorBodyJSON{Code: AsExitError(err).Kind, Message: err.Error()}}) +} + +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} + +type statusResult struct { + Authenticated bool `json:"authenticated"` + Email *string `json:"email"` + Method string `json:"method"` + Source string `json:"source"` + ExpiresAt *string `json:"expires_at"` +} + +func renderStatus(ui *UI, res statusResult) error { + if ui.JSON() { + return ui.WriteJSON(res) + } + ui.Success("Authenticated") + if res.Email != nil { + ui.Log(fmt.Sprintf("Email: %s", *res.Email)) + } + methodLabel := res.Method + if res.Method == "api_token" { + methodLabel = "API token" + } + ui.Log(fmt.Sprintf("Method: %s", methodLabel)) + ui.Log("Expires: " + formatDatePtr(res.ExpiresAt, "never")) + return nil +} + +type initResult struct { + Config string `json:"config"` + Texlive string `json:"texlive"` + Compiler string `json:"compiler"` + Documents []initDocument `json:"documents"` + discovered bool +} + +type initDocument struct { + Name string `json:"name"` + Main string `json:"main"` + Directory string `json:"directory,omitempty"` +} + +func renderInit(ui *UI, res initResult) error { + if ui.JSON() { + return ui.WriteJSON(res) + } + if res.discovered { + ui.Success(fmt.Sprintf("Created .texops.yaml with %d document(s)", len(res.Documents))) + } else { + ui.Success("Created .texops.yaml") + } + return nil +} + +type buildJSON struct { + OK bool `json:"ok"` + DurationMS int64 `json:"duration_ms"` + Documents []buildDocJSON `json:"documents"` +} + +type buildDocJSON struct { + Name string `json:"name"` + Main string `json:"main"` + Status string `json:"status"` + Reason *string `json:"reason"` + Output *string `json:"output"` + Log *string `json:"log"` + BuildID *string `json:"build_id"` + DurationMS int64 `json:"duration_ms"` + Errors []Diagnostic `json:"errors"` + Warnings []Diagnostic `json:"warnings"` + Truncated bool `json:"truncated"` +} + +func renderBuild(ui *UI, results []docResult, elapsed time.Duration) error { + if ui.JSON() { + return ui.WriteJSON(buildResultJSON(results, elapsed)) + } + succeeded, failed := 0, 0 + for _, r := range results { + if r.Success { + succeeded++ + } else { + failed++ + } + } + ui.Gap() + ui.Result(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) + for _, r := range results { + if r.Success { + ui.Log(fmt.Sprintf(" %s => %s", r.Name, r.Output)) + } else { + ui.Log(fmt.Sprintf(" %s !! FAILED", r.Name)) + } + } + return nil +} + +func buildResultJSON(results []docResult, elapsed time.Duration) buildJSON { + out := buildJSON{OK: true, DurationMS: elapsed.Milliseconds(), Documents: make([]buildDocJSON, 0, len(results))} + for _, r := range results { + doc := buildDocJSON{ + Name: r.Name, + Main: filepath.ToSlash(r.Main), + Status: "succeeded", + Log: nullable(filepath.ToSlash(r.Log)), + BuildID: nullable(r.BuildID), + DurationMS: r.Duration.Milliseconds(), + Errors: []Diagnostic{}, + Warnings: []Diagnostic{}, + Truncated: r.Truncated, + } + for _, d := range r.Diagnostics { + if d.Severity == "error" { + doc.Errors = append(doc.Errors, d) + } else { + doc.Warnings = append(doc.Warnings, d) + } + } + if r.Success { + doc.Output = nullable(filepath.ToSlash(r.Output)) + } else { + out.OK = false + doc.Status = "failed" + reason := r.Reason + if reason == "" { + reason = failureReason(r.Err, "internal") + } + doc.Reason = &reason + } + out.Documents = append(out.Documents, doc) + } + return out +} + +// failureReason names why a document failed when the server gave no reason. +func failureReason(err error, fallback string) string { + switch AsExitError(err).Kind { + case KindNotAuthenticated: + return "auth" + case KindNetwork: + return "network" + case KindConfig: + return "config" + } + return fallback +} + +type tokenListEntry struct { + Name string `json:"name"` + Prefix string `json:"prefix"` + ExpiresAt *string `json:"expires_at"` + LastUsedAt *string `json:"last_used_at"` + CreatedAt *string `json:"created_at"` +} + +func renderTokenList(ui *UI, tokens []APITokenListItem) error { + if ui.JSON() { + entries := make([]tokenListEntry, 0, len(tokens)) + for _, tok := range tokens { + entries = append(entries, tokenListEntry{ + Name: tok.Name, + Prefix: tok.Prefix, + ExpiresAt: tok.ExpiresAt, + LastUsedAt: tok.LastUsedAt, + CreatedAt: nullable(tok.CreatedAt), + }) + } + return ui.WriteJSON(entries) + } + if len(tokens) == 0 { + ui.DimInfo("No tokens found. Create one with 'tx token create --name '.") + return nil + } + ui.Log(fmt.Sprintf("%-20s %-12s %-14s %-14s %-12s", "NAME", "PREFIX", "EXPIRES", "LAST USED", "CREATED")) + for _, tok := range tokens { + expires := formatDatePtr(tok.ExpiresAt, "never") + lastUsed := formatDatePtr(tok.LastUsedAt, "never") + created := formatDate(tok.CreatedAt) + ui.Log(fmt.Sprintf("%-20s %-12s %-14s %-14s %-12s", tok.Name, tok.Prefix, expires, lastUsed, created)) + } + return nil +} + +type tokenCreateResult struct { + Name string `json:"name"` + Token string `json:"token"` + ExpiresAt *string `json:"expires_at"` +} + +func renderTokenCreate(ui *UI, res tokenCreateResult) error { + if ui.JSON() { + return ui.WriteJSON(res) + } + ui.Gap() + ui.Result(res.Token) + ui.Gap() + ui.DimInfo("This token won't be shown again. Copy it now.") + ui.DimInfo("Expires: " + formatDatePtr(res.ExpiresAt, "never")) + return nil +} + +type tokenDeleteResult struct { + Deleted string `json:"deleted"` +} + +func renderTokenDelete(ui *UI, res tokenDeleteResult) error { + if ui.JSON() { + return ui.WriteJSON(res) + } + return nil +} + +type loginResult struct { + Authenticated bool `json:"authenticated"` +} + +func renderLogin(ui *UI, res loginResult) error { + if ui.JSON() { + return ui.WriteJSON(res) + } + ui.Success("Logged in successfully") + return nil +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 3e6072c..b77c2ca 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -31,6 +31,13 @@ func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Write parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash) parser.Name = "tx" + parser.CommandHandler = func(command flags.Commander, args []string) error { + ui.SetJSON(opts.JSON) + if command == nil { + return nil + } + return command.Execute(args) + } _, err := parser.ParseArgs(args) if err == nil { @@ -51,6 +58,25 @@ func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Write err = usageError(err) } + if opts.JSON || hasJSONFlag(args) { + ui.SetJSON(true) + } ui.Errorf("%s", err.Error()) + if ui.JSON() && !ui.wroteJSON { + writeErrorJSON(ui, err) + } return AsExitError(err).Code } + +// hasJSONFlag finds --json in arguments that failed to parse. +func hasJSONFlag(args []string) bool { + for _, arg := range args { + if arg == "--" { + return false + } + if arg == "--json" { + return true + } + } + return false +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index df83be0..51263df 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -27,6 +27,9 @@ type fakeTexOps struct { syncStatus int whoamiStatus int deviceCodeStatus int + instanceURL string + tokens []map[string]any + createdToken map[string]any done map[string]map[string]any mu sync.Mutex @@ -87,6 +90,10 @@ func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) } writeJSON(w, map[string]any{"user_id": "usr_1", "email": "user@example.com", "auth_method": "api_token"}) case r.Method == http.MethodGet && r.URL.Path == "/auth/tokens": + if f.tokens != nil { + writeJSON(w, f.tokens) + return + } writeJSON(w, []map[string]any{{"id": "tok_1", "name": "ci", "prefix": "tx_secr", "expires_at": "2027-01-01T00:00:00Z", "created_at": "2026-10-01T00:00:00Z"}}) case r.Method == http.MethodPost && r.URL.Path == "/auth/device-code": if f.deviceCodeStatus != 0 { @@ -95,8 +102,16 @@ func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) return } writeJSON(w, map[string]any{"device_code": "dev_1", "user_code": "ABCD-EFGH", "verification_url": f.srv.URL + "/verify", "expires_in": 1}) + case r.Method == http.MethodPost && r.URL.Path == "/auth/token": + writeJSON(w, map[string]any{"jwt": "header.payload.sig", "expires_at": "2027-01-01T00:00:00Z"}) + case r.Method == http.MethodDelete && r.URL.Path == "/auth/tokens/tok_1": + w.WriteHeader(http.StatusNoContent) case r.Method == http.MethodPost && r.URL.Path == "/auth/tokens": w.WriteHeader(http.StatusCreated) + if f.createdToken != nil { + writeJSON(w, f.createdToken) + return + } writeJSON(w, map[string]any{"token": "tx_secret_value", "id": "tok_1", "name": "ci", "prefix": "tx_secr", "created_at": "2026-10-01T00:00:00Z"}) case r.Method == http.MethodPost && r.URL.Path == "/api/projects": if f.projectStatus != 0 { @@ -111,7 +126,11 @@ func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) w.Write([]byte(`{"error":"session unavailable"}`)) return } - writeJSON(w, map[string]any{"instance_url": f.srv.URL, "jwt": "instance-jwt"}) + instanceURL := f.srv.URL + if f.instanceURL != "" { + instanceURL = f.instanceURL + } + writeJSON(w, map[string]any{"instance_url": instanceURL, "jwt": "instance-jwt"}) case r.Method == http.MethodPost && r.URL.Path == "/projects/prj_test/sync": if f.syncStatus != 0 { w.WriteHeader(f.syncStatus) diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 6dc9e1b..b69c756 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "encoding/json" "fmt" "io" "os" @@ -40,6 +41,8 @@ type UI struct { in io.Reader styles uiStyles errStyles uiStyles + json bool + wroteJSON bool } func newStyles(hasDarkBG bool, r *lipgloss.Renderer) uiStyles { @@ -148,6 +151,29 @@ func (ui *UI) Out() io.Writer { return ui.out } +// SetJSON switches the UI to JSON mode: stdout carries only the JSON +// document, progress goes to stderr as plain lines and nothing prompts. +func (ui *UI) SetJSON(on bool) { + ui.json = on + if on { + ui.isTTY = false + ui.errIsTTY = false + ui.stdinIsTTY = false + } +} + +func (ui *UI) JSON() bool { + return ui.json +} + +// WriteJSON writes v to stdout as the command's single JSON document. +func (ui *UI) WriteJSON(v any) error { + ui.wroteJSON = true + enc := json.NewEncoder(ui.out) + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + // Result prints a highlighted result line to stdout. func (ui *UI) Result(msg string) { ui.printOut(ui.styles.status, msg) @@ -862,6 +888,10 @@ func (ui *UI) TextInput(label string) (string, error) { } func (ui *UI) printOut(style lipgloss.Style, msg string) { + if ui.json { + fmt.Fprintln(ui.errOut, msg) + return + } if ui.isTTY { msg = style.Render(msg) } From bfb6f408e94b4d508ce610a20fba84e75a2af95f Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 20:07:38 +0100 Subject: [PATCH 04/11] Require --yes to delete tokens and validate flag values --- README.md | 6 +- internal/cli/client.go | 30 +++ internal/cli/client_test.go | 33 ++++ internal/cli/commands.go | 73 +++++-- internal/cli/commands_test.go | 80 +++++--- internal/cli/config.go | 3 +- internal/cli/json_test.go | 8 +- internal/cli/output.go | 13 +- internal/cli/run_test.go | 18 +- internal/cli/ui.go | 6 +- internal/cli/ui_test.go | 43 +++- internal/cli/validate_test.go | 356 ++++++++++++++++++++++++++++++++++ man/tx.1 | 42 +++- man/tx.1.txt | 43 ++-- site/docs/ci-cd.md | 3 +- site/docs/cli.md | 18 +- 16 files changed, 677 insertions(+), 98 deletions(-) create mode 100644 internal/cli/validate_test.go diff --git a/README.md b/README.md index 2a2dc6f..efeb05c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ 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 -tx token delete [name] # Delete an API token +tx token delete [name] [--yes] # Delete an API token ``` ### Getting started @@ -45,7 +45,7 @@ tx token delete [name] # Delete an API token Project settings are stored in `.texops.yaml`: - `project_key` — unique identifier (safe to commit) -- `texlive` — TexLive version (e.g. `texlive:2025`) +- `texlive` — TexLive version (e.g. `"2025"`) - `compiler` — LaTeX compiler: `pdflatex` (default), `xelatex`, `lualatex`, `latex`, `platex`, `uplatex` - `documents` — list of documents to build @@ -59,7 +59,7 @@ Results (the build summary, `tx status` fields, token values) go to stdout; prog |------|---------| | `0` | Success | | `1` | Service or unexpected failure (network, server error, build timeout) | -| `2` | Usage error (unknown or missing command, unknown flag or document, missing input without a terminal) | +| `2` | Usage error (unknown or missing command, unknown flag or document, unsupported `--texlive` or `--compiler`, missing input or confirmation without a terminal) | | `3` | Not authenticated, or the session or token expired or was rejected | | `4` | `.texops.yaml` missing or invalid, or unsupported TeX Live version | | `5` | A document failed to compile | diff --git a/internal/cli/client.go b/internal/cli/client.go index 42ffd10..d67c20c 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -402,6 +402,36 @@ func (c *APIClient) ListAPITokens() ([]APITokenListItem, error) { return result, nil } +// DistributionsResponse lists the TeX Live versions the API accepts, newest first. +type DistributionsResponse struct { + Versions []string `json:"versions"` + Default string `json:"default"` +} + +// Distributions fetches the supported TeX Live versions. It needs no authentication. +func (c *APIClient) Distributions(ctx context.Context) (DistributionsResponse, error) { + req, err := newRequest(ctx, "GET", c.baseURL+"/api/distributions", nil) + if err != nil { + return DistributionsResponse{}, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return DistributionsResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return DistributionsResponse{}, &APIError{Op: "list distributions", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} + } + + var result DistributionsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return DistributionsResponse{}, err + } + return result, nil +} + func (c *APIClient) DeleteAPIToken(tokenID string) error { if !validIDPattern.MatchString(tokenID) { return fmt.Errorf("invalid token ID format") diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index 04c829e..c544097 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -746,6 +746,39 @@ func TestAPIClient_Whoami(t *testing.T) { }) } +func TestAPIClient_Distributions(t *testing.T) { + t.Run("returns versions and default without authentication", func(t *testing.T) { + var method, path, authorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path, authorization = r.Method, r.URL.Path, r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"versions": ["2025", "2024", "2023"], "default": "2025"}`)) + })) + defer srv.Close() + + client := cli.NewUnauthenticatedAPIClient(srv.URL) + result, err := client.Distributions(t.Context()) + require.NoError(t, err) + assert.Equal(t, "GET", method) + assert.Equal(t, "/api/distributions", path) + assert.Empty(t, authorization) + assert.Equal(t, cli.DistributionsResponse{Versions: []string{"2025", "2024", "2023"}, Default: "2025"}, result) + }) + + t.Run("returns an API error on a non-200 response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"not found"}`)) + })) + defer srv.Close() + + client := cli.NewUnauthenticatedAPIClient(srv.URL) + _, err := client.Distributions(t.Context()) + require.Error(t, err) + assert.Equal(t, "list distributions failed (404): not found", err.Error()) + }) +} + func TestE2E_TwoClients(t *testing.T) { t.Run("full build flow with API + instance mock servers", func(t *testing.T) { pdfContent := []byte("%PDF-1.4 test output") diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 9396bac..113b4cd 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -12,6 +12,7 @@ import ( "os/signal" "path/filepath" "runtime" + "slices" "strconv" "strings" "time" @@ -66,7 +67,8 @@ type TokenListCmd struct { } type TokenDeleteCmd struct { - UI *UI `no-flag:"true"` + Yes bool `short:"y" long:"yes" description:"Delete without asking for confirmation"` + UI *UI `no-flag:"true"` } type StatusCmd struct { @@ -79,7 +81,7 @@ type LoginCmd struct { type InitCmd struct { Texlive string `long:"texlive" description:"TexLive distribution version"` - Compiler string `long:"compiler" description:"LaTeX compiler (pdflatex, xelatex, lualatex, latex, platex, uplatex)"` + Compiler string `long:"compiler" description:"LaTeX compiler" choice:"pdflatex" choice:"xelatex" choice:"lualatex" choice:"latex" choice:"platex" choice:"uplatex"` Main string `long:"main" default:"main.tex" description:"Main TeX file (fallback when no .tex files discovered)"` UI *UI `no-flag:"true"` } @@ -443,25 +445,47 @@ func (cmd *InitCmd) Execute(args []string) error { return usageErrorf("invalid compiler %q: allowed values are %s", cmd.Compiler, strings.Join(AllowedCompilers, ", ")) } - res, err := initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, ui) + dists := texliveDistributions(context.Background()) + if cmd.Texlive != "" && !slices.Contains(dists.Versions, cmd.Texlive) { + return usageErrorf("invalid --texlive %q: supported versions are %s", cmd.Texlive, strings.Join(dists.Versions, ", ")) + } + + res, err := initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, dists, ui) if err != nil { return err } return renderInit(ui, res) } -func initProject(dir, texlive, compiler, mainFallback string, ui *UI) (initResult, error) { +const distributionsTimeout = 5 * time.Second + +// texliveDistributions asks the API for the supported TeX Live versions and +// falls back to the built-in TexliveVersions when it cannot. +func texliveDistributions(ctx context.Context) DistributionsResponse { + ctx, cancel := context.WithTimeout(ctx, distributionsTimeout) + defer cancel() + dists, err := NewUnauthenticatedAPIClient(ResolveAPIURL(Config{})).Distributions(ctx) + if err != nil || len(dists.Versions) == 0 { + return DistributionsResponse{Versions: TexliveVersions, Default: TexliveVersions[0]} + } + if !slices.Contains(dists.Versions, dists.Default) { + dists.Default = dists.Versions[0] + } + return dists +} + +func initProject(dir, texlive, compiler, mainFallback string, dists DistributionsResponse, ui *UI) (initResult, error) { configPath := filepath.Join(dir, ".texops.yaml") if texlive == "" { if ui.IsInteractive() { - idx, err := ui.SelectOne("TexLive version:", TexliveVersions) + idx, err := ui.SelectOne("TexLive version:", dists.Versions) if err != nil { return initResult{}, fmt.Errorf("TexLive selection failed: %w", err) } - texlive = TexliveVersions[idx] + texlive = dists.Versions[idx] } else { - texlive = TexliveVersions[0] + texlive = dists.Default } } @@ -579,7 +603,7 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv if !os.IsNotExist(err) { return configError(err) } - if !ui.IsTTY() { + if !ui.IsInteractive() { return configError(errInitDeclined) } confirmed, confirmErr := ui.Confirm("No .texops.yaml found. Initialize project now?") @@ -589,7 +613,7 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv if !confirmed { return configError(errInitDeclined) } - res, initErr := initProject(dir, "", "", "main.tex", ui) + res, initErr := initProject(dir, "", "", "main.tex", texliveDistributions(ctx), ui) if initErr != nil { return initErr } @@ -1117,15 +1141,21 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { ui = defaultUI() } + if len(args) == 0 && !ui.IsTTY() { + return usageErrorf("specify token name as argument in non-interactive mode") + } + if !cmd.Yes && !ui.IsInteractive() { + if len(args) == 0 { + return usageErrorf("refusing to delete a token without confirmation; pass --yes") + } + return usageErrorf("refusing to delete token %q without confirmation; pass --yes", args[0]) + } + authToken, err := ResolveAuth() if err != nil { return err } - if len(args) == 0 && !ui.IsTTY() { - return usageErrorf("specify token name as argument in non-interactive mode") - } - apiURL := ResolveAPIURL(Config{}) api := NewAPIClient(apiURL, authToken) @@ -1169,14 +1199,15 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { tokenName = tokens[idx].Name } - // Confirm deletion - confirmed, err := ui.Confirm(fmt.Sprintf("Delete token %q?", tokenName)) - if err != nil { - return err - } - if !confirmed { - ui.DimInfo("Cancelled.") - return nil + if !cmd.Yes { + confirmed, err := ui.Confirm(fmt.Sprintf("Delete token %q?", tokenName)) + if err != nil { + return err + } + if !confirmed { + ui.DimInfo("Cancelled.") + return nil + } } sp = ui.Spin("Deleting token...") diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 81b832e..feae95b 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -438,20 +438,21 @@ Hello func TestInitCmd(t *testing.T) { t.Run("auto-discovers tex files in non-TTY mode", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}`), 0o600) os.WriteFile(filepath.Join(dir, "helper.tex"), []byte(`\newcommand{\foo}{bar}`), 0o600) ui, buf := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) require.NoError(t, err) content := string(data) - assert.Contains(t, content, `texlive: "texlive:2021"`) + assert.Contains(t, content, `texlive: "2021"`) assert.Contains(t, content, `compiler: "pdflatex"`) assert.Contains(t, content, "documents:") assert.Contains(t, content, `main: "paper.tex"`) @@ -463,6 +464,7 @@ func TestInitCmd(t *testing.T) { t.Run("discovers multiple tex files", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}`), 0o600) @@ -470,7 +472,7 @@ func TestInitCmd(t *testing.T) { os.WriteFile(filepath.Join(dir, "slides", "slides.tex"), []byte(`\documentclass{beamer}`), 0o600) ui, buf := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) @@ -487,17 +489,18 @@ func TestInitCmd(t *testing.T) { t.Run("falls back to --main when no tex files found", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) ui, buf := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) require.NoError(t, err) content := string(data) - assert.Contains(t, content, `texlive: "texlive:2021"`) + assert.Contains(t, content, `texlive: "2021"`) assert.Contains(t, content, `compiler: "pdflatex"`) assert.Contains(t, content, "documents:") assert.Contains(t, content, `main: "main.tex"`) @@ -507,10 +510,11 @@ func TestInitCmd(t *testing.T) { t.Run("falls back to custom --main when no tex files found", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) ui, buf := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "pdflatex", Main: "thesis.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "pdflatex", Main: "thesis.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) @@ -524,12 +528,13 @@ func TestInitCmd(t *testing.T) { t.Run("fails if config already exists", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte("existing"), 0o600) t.Chdir(dir) ui, _ := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "pdflatex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "pdflatex", UI: ui} err := cmd.Execute(nil) require.Error(t, err) assert.Contains(t, err.Error(), "already exists") @@ -537,6 +542,7 @@ func TestInitCmd(t *testing.T) { t.Run("generated config is parseable", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}`), 0o600) @@ -544,7 +550,7 @@ func TestInitCmd(t *testing.T) { os.WriteFile(filepath.Join(dir, "slides", "slides.tex"), []byte(`\documentclass{beamer}`), 0o600) ui, _ := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "pdflatex", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) @@ -553,7 +559,7 @@ func TestInitCmd(t *testing.T) { require.NoError(t, err) config, err := cli.ParseConfig(string(data)) require.NoError(t, err) - assert.Equal(t, "texlive:2021", config.Texlive) + assert.Equal(t, "2021", config.Texlive) assert.Equal(t, "pdflatex", config.Compiler) assert.Len(t, config.Documents, 2) assert.Len(t, config.ProjectKey, 22, "tx init should generate a 22-char project_key") @@ -571,12 +577,13 @@ func TestInitCmd(t *testing.T) { t.Run("--compiler xelatex writes top-level compiler field", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}`), 0o600) ui, _ := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "xelatex", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "xelatex", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) @@ -592,10 +599,11 @@ func TestInitCmd(t *testing.T) { t.Run("invalid --compiler value returns error", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) ui, _ := testUI() - cmd := &cli.InitCmd{Texlive: "texlive:2021", Compiler: "badcompiler", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2021", Compiler: "badcompiler", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid compiler") @@ -608,6 +616,7 @@ func TestInitCmd(t *testing.T) { t.Run("non-TTY with empty texlive and compiler uses defaults", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) ui, buf := testUI() @@ -626,23 +635,24 @@ func TestInitCmd(t *testing.T) { t.Run("explicit flags skip interactive selection", func(t *testing.T) { dir := t.TempDir() + unreachableAPI(t) t.Chdir(dir) buf := &bytes.Buffer{} ui := cli.NewUIWithOptions(buf, true, strings.NewReader("")) - cmd := &cli.InitCmd{Texlive: "texlive:2023", Compiler: "xelatex", Main: "main.tex", UI: ui} + cmd := &cli.InitCmd{Texlive: "2023", Compiler: "xelatex", Main: "main.tex", UI: ui} err := cmd.Execute(nil) require.NoError(t, err) data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) require.NoError(t, err) content := string(data) - assert.Contains(t, content, `texlive: "texlive:2023"`) + assert.Contains(t, content, `texlive: "2023"`) assert.Contains(t, content, `compiler: "xelatex"`) config, err := cli.ParseConfig(content) require.NoError(t, err) - assert.Equal(t, "texlive:2023", config.Texlive) + assert.Equal(t, "2023", config.Texlive) assert.Equal(t, "xelatex", config.Compiler) }) } @@ -886,24 +896,39 @@ func TestBuildCmd_AutoInit(t *testing.T) { t.Run("TTY prompts and runs init on confirm", func(t *testing.T) { dir := t.TempDir() - // No .tex files with \documentclass — init falls back to main.tex default. - // outIsTTY=true so Confirm prompt works; stdinIsTTY=false so selectors use defaults. + serveDistributions(t, `{"versions": ["2026", "2025"], "default": "2026"}`) + noCredentials(t) buf := &bytes.Buffer{} - in := strings.NewReader("y\n") - ui := cli.NewUIWithTTYOptions(buf, true, false, in) + ui := cli.NewUIWithOptions(buf, true, &enterReader{first: "y\n"}) // Build will init then fail on auth — that's fine, we just check init happened. err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) configData, readErr := os.ReadFile(filepath.Join(dir, ".texops.yaml")) - require.NoError(t, readErr) - assert.Contains(t, string(configData), "project_key:") - assert.Contains(t, string(configData), "main.tex") + require.NoError(t, readErr, buf.String()) + assert.Contains(t, string(configData), "project_key:", buf.String()) + assert.Contains(t, string(configData), `texlive: "2026"`, buf.String()) + assert.Contains(t, string(configData), "main.tex", buf.String()) // Should fail after init (no auth configured), not on missing config. - require.Error(t, err) - assert.NotContains(t, err.Error(), "tx init") + require.Error(t, err, buf.String()) + assert.NotContains(t, err.Error(), "tx init", buf.String()) + }) + + t.Run("terminal stdout with piped stdin returns friendly error without prompting", func(t *testing.T) { + dir := t.TempDir() + + buf := &bytes.Buffer{} + ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("y\n")) + + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + + require.Error(t, err, buf.String()) + assert.Equal(t, cli.ExitConfig, cli.AsExitError(err).Code, buf.String()) + assert.Contains(t, err.Error(), "run `tx init` to set up your project", buf.String()) + assert.NotContains(t, buf.String(), "[Y/n]", buf.String()) + assert.NoFileExists(t, filepath.Join(dir, ".texops.yaml"), buf.String()) }) t.Run("TTY declined returns friendly error", func(t *testing.T) { @@ -2389,13 +2414,12 @@ func TestTokenDeleteCmd(t *testing.T) { mockKeyringForAuth(t, "test-jwt-token") t.Setenv("TX_API_URL", srv.URL) - // Non-TTY auto-confirms ui, buf := testUI() - cmd := &cli.TokenDeleteCmd{UI: ui} + cmd := &cli.TokenDeleteCmd{Yes: true, UI: ui} err := cmd.Execute([]string{"CI prod"}) - require.NoError(t, err) + require.NoError(t, err, buf.String()) - assert.Equal(t, "tok_01ABC", deletedID) + assert.Equal(t, "tok_01ABC", deletedID, buf.String()) assert.Contains(t, buf.String(), "Token deleted") }) @@ -2421,7 +2445,7 @@ func TestTokenDeleteCmd(t *testing.T) { t.Setenv("TX_API_URL", srv.URL) ui, _ := testUI() - cmd := &cli.TokenDeleteCmd{UI: ui} + cmd := &cli.TokenDeleteCmd{Yes: true, UI: ui} err := cmd.Execute([]string{"nonexistent"}) require.Error(t, err) assert.Contains(t, err.Error(), "not found") diff --git a/internal/cli/config.go b/internal/cli/config.go index 613afef..4ae1af7 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -19,7 +19,8 @@ var AllowedCompilers = []string{"pdflatex", "xelatex", "lualatex", "latex", "pla const defaultCompiler = "pdflatex" -// TexliveVersions lists available TexLive distribution versions (newest first). +// TexliveVersions is the built-in list of TeX Live versions (newest first), +// used when the API's list of distributions cannot be fetched. var TexliveVersions = []string{"2025", "2024", "2023", "2022", "2021", "2020", "2019", "2018", "2017", "2016", "2015", "2014", "2013"} // generateProjectKey returns a 22-character random base62 string. diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go index a6935a1..51bb273 100644 --- a/internal/cli/json_test.go +++ b/internal/cli/json_test.go @@ -400,7 +400,7 @@ func TestRunJSONToken(t *testing.T) { t.Run("token delete", func(t *testing.T) { f := newFakeTexOps(t) - r := runTx(t, f, "token", "delete", "ci", "--json") + r := runTx(t, f, "token", "delete", "ci", "--yes", "--json") require.Equal(t, cli.ExitOK, r.code, r) assert.JSONEq(t, `{"deleted": "ci"}`, r.stdout, r) @@ -411,9 +411,10 @@ func TestRunJSONToken(t *testing.T) { func TestRunJSONInit(t *testing.T) { t.Run("discovered documents", func(t *testing.T) { + f := newFakeTexOps(t) dir := projectDir(t, "") - r := runTx(t, nil, "init", "--texlive", "2024", "--compiler", "xelatex", "--json") + r := runTx(t, f, "init", "--texlive", "2024", "--compiler", "xelatex", "--json") require.Equal(t, cli.ExitOK, r.code, r) assert.JSONEq(t, `{"config": ".texops.yaml", "texlive": "2024", "compiler": "xelatex", "documents": [{"name": "paper", "main": "paper.tex"}, {"name": "slides", "main": "slides.tex"}]}`, r.stdout, r) @@ -426,8 +427,9 @@ func TestRunJSONInit(t *testing.T) { require.NoError(t, os.Mkdir(dir+"/thesis", 0o750)) require.NoError(t, os.WriteFile(dir+"/thesis/thesis.tex", []byte("\\documentclass{article}"), 0o600)) t.Chdir(dir) + f := newFakeTexOps(t) - r := runTx(t, nil, "--json", "init") + r := runTx(t, f, "--json", "init") require.Equal(t, cli.ExitOK, r.code, r) var doc map[string]any diff --git a/internal/cli/output.go b/internal/cli/output.go index 8c1a113..9c5dafd 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -3,7 +3,9 @@ package cli import ( "fmt" "path/filepath" + "strings" "time" + "unicode/utf8" ) type errorJSON struct { @@ -197,12 +199,19 @@ func renderTokenList(ui *UI, tokens []APITokenListItem) error { ui.DimInfo("No tokens found. Create one with 'tx token create --name '.") return nil } - ui.Log(fmt.Sprintf("%-20s %-12s %-14s %-14s %-12s", "NAME", "PREFIX", "EXPIRES", "LAST USED", "CREATED")) + nameWidth := len("NAME") + for _, tok := range tokens { + nameWidth = max(nameWidth, utf8.RuneCountInString(tok.Name)) + } + padName := func(name string) string { + return name + strings.Repeat(" ", nameWidth-utf8.RuneCountInString(name)) + } + ui.Log(fmt.Sprintf("%s %-12s %-14s %-14s %-12s", padName("NAME"), "PREFIX", "EXPIRES", "LAST USED", "CREATED")) for _, tok := range tokens { expires := formatDatePtr(tok.ExpiresAt, "never") lastUsed := formatDatePtr(tok.LastUsedAt, "never") created := formatDate(tok.CreatedAt) - ui.Log(fmt.Sprintf("%-20s %-12s %-14s %-14s %-12s", tok.Name, tok.Prefix, expires, lastUsed, created)) + ui.Log(fmt.Sprintf("%s %-12s %-14s %-14s %-12s", padName(tok.Name), tok.Prefix, expires, lastUsed, created)) } return nil } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 51263df..fbd1a1a 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -30,6 +30,7 @@ type fakeTexOps struct { instanceURL string tokens []map[string]any createdToken map[string]any + distributions string done map[string]map[string]any mu sync.Mutex @@ -104,6 +105,13 @@ func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) writeJSON(w, map[string]any{"device_code": "dev_1", "user_code": "ABCD-EFGH", "verification_url": f.srv.URL + "/verify", "expires_in": 1}) case r.Method == http.MethodPost && r.URL.Path == "/auth/token": writeJSON(w, map[string]any{"jwt": "header.payload.sig", "expires_at": "2027-01-01T00:00:00Z"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/distributions": + if f.distributions == "" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(f.distributions)) case r.Method == http.MethodDelete && r.URL.Path == "/auth/tokens/tok_1": w.WriteHeader(http.StatusNoContent) case r.Method == http.MethodPost && r.URL.Path == "/auth/tokens": @@ -308,13 +316,15 @@ func TestRunExitCodes(t *testing.T) { }) t.Run("bad flag value exits 2", func(t *testing.T) { + f := newFakeTexOps(t) projectDir(t, "") - r := runTx(t, nil, "init", "--compiler", "tex") + r := runTx(t, f, "init", "--compiler", "tex") assert.Equal(t, cli.ExitUsage, r.code, r) - assert.Contains(t, r.stderr, `invalid compiler "tex"`, r) + assert.Equal(t, "Invalid value `tex' for option `--compiler'. Allowed values are: pdflatex, xelatex, lualatex, latex, platex or uplatex\n", r.stderr, r) assert.NoFileExists(t, ".texops.yaml", r) + assert.Empty(t, r.requests, r) }) t.Run("unknown document name exits 2", func(t *testing.T) { @@ -612,8 +622,8 @@ func TestRunOutputStreams(t *testing.T) { require.Equal(t, cli.ExitOK, r.code, r) assert.Equal(t, - " NAME PREFIX EXPIRES LAST USED CREATED \n"+ - " ci tx_secr 01 Jan 2027 never 01 Oct 2026 \n", + " NAME PREFIX EXPIRES LAST USED CREATED \n"+ + " ci tx_secr 01 Jan 2027 never 01 Oct 2026 \n", r.stdout, r) assert.Equal(t, "Loading tokens...\n1 token(s)\n", r.stderr, r) }) diff --git a/internal/cli/ui.go b/internal/cli/ui.go index b69c756..00da2a6 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -254,9 +254,11 @@ func (ui *UI) Select(label string, options []string) (int, error) { return -1, fmt.Errorf("no input received") } +// Confirm asks a yes/no question. Without an interactive terminal it returns +// an error instead of assuming an answer. func (ui *UI) Confirm(msg string) (bool, error) { - if !ui.isTTY { - return true, nil + if !ui.IsInteractive() { + return false, usageErrorf("confirmation requires an interactive terminal") } fmt.Fprintf(ui.out, "%s [Y/n] ", msg) diff --git a/internal/cli/ui_test.go b/internal/cli/ui_test.go index d6a8538..7a85525 100644 --- a/internal/cli/ui_test.go +++ b/internal/cli/ui_test.go @@ -91,12 +91,47 @@ func TestUI_DimInfo(t *testing.T) { } func TestUI_Confirm_NonTTY(t *testing.T) { - t.Run("returns true automatically in non-TTY", func(t *testing.T) { + t.Run("returns a usage error without a terminal", func(t *testing.T) { ui, buf := newTestUI() result, err := ui.Confirm("Upload 50 MB?") - require.NoError(t, err) - assert.True(t, result) - // Should not print prompt in non-TTY + require.Error(t, err) + assert.False(t, result) + assert.Equal(t, "confirmation requires an interactive terminal", err.Error()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) + assert.Empty(t, buf.String()) + }) + + t.Run("returns an error when stdout is a terminal but stdin is not", func(t *testing.T) { + buf := &bytes.Buffer{} + ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("y\n")) + result, err := ui.Confirm("Upload 50 MB?") + require.Error(t, err) + assert.False(t, result) + assert.Equal(t, "confirmation requires an interactive terminal", err.Error()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) + assert.Empty(t, buf.String()) + }) + + t.Run("returns an error when stdin is a terminal but stdout is not", func(t *testing.T) { + buf := &bytes.Buffer{} + ui := cli.NewUIWithTTYOptions(buf, false, true, strings.NewReader("y\n")) + result, err := ui.Confirm("Upload 50 MB?") + require.Error(t, err) + assert.False(t, result) + assert.Equal(t, "confirmation requires an interactive terminal", err.Error()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) + assert.Empty(t, buf.String()) + }) + + t.Run("returns an error in JSON mode", func(t *testing.T) { + buf := &bytes.Buffer{} + ui := cli.NewUIWithOptions(buf, true, strings.NewReader("y\n")) + ui.SetJSON(true) + result, err := ui.Confirm("Upload 50 MB?") + require.Error(t, err) + assert.False(t, result) + assert.Equal(t, "confirmation requires an interactive terminal", err.Error()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) assert.Empty(t, buf.String()) }) } diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go new file mode 100644 index 0000000..9ad2d56 --- /dev/null +++ b/internal/cli/validate_test.go @@ -0,0 +1,356 @@ +package cli_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + flags "github.com/jessevdk/go-flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +const builtinVersions = "2025, 2024, 2023, 2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014, 2013" + +const apiDistributions = `{"versions": ["2026", "2025", "2024"], "default": "2026"}` + +// serveDistributions points TX_API_URL at a server that answers only +// unauthenticated GET /api/distributions with body. +func serveDistributions(t *testing.T, body string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/distributions" && r.Header.Get("Authorization") == "" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(body)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + t.Setenv("TX_API_URL", srv.URL) +} + +// unreachableAPI points TX_API_URL at a closed server. +func unreachableAPI(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + t.Setenv("TX_API_URL", srv.URL) +} + +// enterReader returns first on the first Read and an Enter key press on every +// later Read, so each interactive prompt picks its preselected option. +type enterReader struct { + first string + sent atomic.Bool +} + +func (r *enterReader) Read(p []byte) (int, error) { + if r.first != "" && r.sent.CompareAndSwap(false, true) { + return copy(p, r.first), nil + } + return copy(p, "\r"), nil +} + +func TestTokenDeleteConfirmation(t *testing.T) { + t.Run("without a terminal and without --yes exits 2 and sends no DELETE", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "delete", "ci") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "refusing to delete token \"ci\" without confirmation; pass --yes\n", r.stderr, r) + assert.Empty(t, r.stdout, r) + assert.NotContains(t, r.requests, "DELETE", r) + }) + + t.Run("--json without --yes prints an error document", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "delete", "ci", "--json") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.JSONEq(t, `{"error": {"code": "usage", "message": "refusing to delete token \"ci\" without confirmation; pass --yes"}}`, r.stdout, r) + assert.NotContains(t, r.requests, "DELETE", r) + }) + + t.Run("--yes deletes without a terminal", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "delete", "--yes", "ci") + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, "Loading tokens...\n1 token(s)\nDeleting token...\nToken deleted\n", r.stderr, r) + assert.Contains(t, r.requests, "DELETE /auth/tokens/tok_1", r) + }) + + t.Run("-y deletes without a terminal", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "delete", "-y", "ci") + + assert.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.requests, "DELETE /auth/tokens/tok_1", r) + }) + + t.Run("unknown token with --yes exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "delete", "--yes", "nope") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, "token \"nope\" not found\n", r) + assert.NotContains(t, r.requests, "DELETE", r) + }) + + t.Run("--yes skips the prompt on a terminal", func(t *testing.T) { + f := newFakeTexOps(t) + buf := &bytes.Buffer{} + ui := cli.NewUIWithOptions(buf, true, strings.NewReader("n\n")) + + err := (&cli.TokenDeleteCmd{Yes: true, UI: ui}).Execute([]string{"ci"}) + + require.NoError(t, err, "%s\n%s", buf.String(), f.exchanges()) + assert.NotContains(t, buf.String(), "[Y/n]", f.exchanges()) + assert.Contains(t, f.exchanges(), "DELETE /auth/tokens/tok_1", buf.String()) + }) + + t.Run("a terminal without --yes prompts and deletes on yes", func(t *testing.T) { + f := newFakeTexOps(t) + buf := &bytes.Buffer{} + ui := cli.NewUIWithOptions(buf, true, strings.NewReader("y\n")) + + err := (&cli.TokenDeleteCmd{UI: ui}).Execute([]string{"ci"}) + + require.NoError(t, err, "%s\n%s", buf.String(), f.exchanges()) + assert.Contains(t, buf.String(), "Delete token \"ci\"? [Y/n]", f.exchanges()) + assert.Contains(t, f.exchanges(), "DELETE /auth/tokens/tok_1", buf.String()) + }) + + t.Run("no name with piped stdin and without --yes exits 2 and sends no request", func(t *testing.T) { + f := newFakeTexOps(t) + buf := &bytes.Buffer{} + ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("")) + + err := (&cli.TokenDeleteCmd{UI: ui}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code, buf.String()) + assert.Equal(t, "refusing to delete a token without confirmation; pass --yes", err.Error(), buf.String()) + assert.Empty(t, f.exchanges(), buf.String()) + }) + + t.Run("without credentials, a terminal or --yes reports the usage error first", func(t *testing.T) { + noCredentials(t) + + r := runTx(t, nil, "token", "delete", "ci") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "refusing to delete token \"ci\" without confirmation; pass --yes\n", r.stderr, r) + }) + + t.Run("a terminal without --yes keeps the token on no", func(t *testing.T) { + f := newFakeTexOps(t) + buf := &bytes.Buffer{} + ui := cli.NewUIWithOptions(buf, true, strings.NewReader("n\n")) + + err := (&cli.TokenDeleteCmd{UI: ui}).Execute([]string{"ci"}) + + require.NoError(t, err, "%s\n%s", buf.String(), f.exchanges()) + assert.Contains(t, buf.String(), "Cancelled.", f.exchanges()) + assert.NotContains(t, f.exchanges(), "DELETE", buf.String()) + }) +} + +func TestCompilerChoices(t *testing.T) { + t.Run("--compiler choices match AllowedCompilers", func(t *testing.T) { + parser := flags.NewParser(&cli.Options{}, flags.None) + + opt := parser.Find("init").FindOptionByLongName("compiler") + + require.NotNil(t, opt) + assert.Equal(t, cli.AllowedCompilers, opt.Choices) + }) + + t.Run("unknown compiler exits 2 without writing a config", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, "") + + r := runTx(t, f, "init", "--compiler", "pdftex") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, "Invalid value `pdftex' for option `--compiler'", r) + assert.NoFileExists(t, ".texops.yaml", r) + }) +} + +func TestInitTexlive(t *testing.T) { + t.Run("unsupported --texlive exits 2 and lists the API's versions", func(t *testing.T) { + f := newFakeTexOps(t) + f.distributions = apiDistributions + projectDir(t, "") + + r := runTx(t, f, "init", "--texlive", "2099") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "invalid --texlive \"2099\": supported versions are 2026, 2025, 2024\n", r.stderr, r) + assert.Empty(t, r.stdout, r) + assert.Contains(t, r.requests, "GET /api/distributions", r) + assert.NoFileExists(t, ".texops.yaml", r) + }) + + t.Run("unsupported --texlive with --json prints an error document", func(t *testing.T) { + f := newFakeTexOps(t) + f.distributions = apiDistributions + projectDir(t, "") + + r := runTx(t, f, "init", "--texlive", "2099", "--json") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.JSONEq(t, `{"error": {"code": "usage", "message": "invalid --texlive \"2099\": supported versions are 2026, 2025, 2024"}}`, r.stdout, r) + assert.NoFileExists(t, ".texops.yaml", r) + }) + + t.Run("unsupported --texlive lists the built-in versions when the API is unreachable", func(t *testing.T) { + unreachableAPI(t) + projectDir(t, "") + + r := runTx(t, nil, "init", "--texlive", "2099") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "invalid --texlive \"2099\": supported versions are "+builtinVersions+"\n", r.stderr, r) + assert.NoFileExists(t, ".texops.yaml", r) + }) + + t.Run("unsupported --texlive lists the built-in versions when the endpoint is missing", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, "") + + r := runTx(t, f, "init", "--texlive", "2099") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "invalid --texlive \"2099\": supported versions are "+builtinVersions+"\n", r.stderr, r) + assert.Contains(t, r.requests, "GET /api/distributions", r) + assert.NoFileExists(t, ".texops.yaml", r) + }) + + t.Run("an empty version list falls back to the built-in versions", func(t *testing.T) { + f := newFakeTexOps(t) + f.distributions = `{"versions": [], "default": ""}` + projectDir(t, "") + + r := runTx(t, f, "init", "--texlive", "2099") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "invalid --texlive \"2099\": supported versions are "+builtinVersions+"\n", r.stderr, r) + }) + + t.Run("a version only the API knows is accepted", func(t *testing.T) { + f := newFakeTexOps(t) + f.distributions = apiDistributions + dir := projectDir(t, "") + + r := runTx(t, f, "init", "--texlive", "2026") + + require.Equal(t, cli.ExitOK, r.code, r) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err, r) + assert.Contains(t, string(data), `texlive: "2026"`, r) + }) + + t.Run("the API default is used without a terminal", func(t *testing.T) { + f := newFakeTexOps(t) + f.distributions = `{"versions": ["2026", "2025"], "default": "2025"}` + dir := projectDir(t, "") + + r := runTx(t, f, "init") + + require.Equal(t, cli.ExitOK, r.code, r) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err, r) + assert.Contains(t, string(data), `texlive: "2025"`, r) + }) + + t.Run("a default missing from the versions falls back to the newest version", func(t *testing.T) { + f := newFakeTexOps(t) + f.distributions = `{"versions": ["2026", "2025"], "default": "1999"}` + dir := projectDir(t, "") + + r := runTx(t, f, "init") + + require.Equal(t, cli.ExitOK, r.code, r) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err, r) + assert.Contains(t, string(data), `texlive: "2026"`, r) + }) + + t.Run("the built-in default is used when the API is unreachable", func(t *testing.T) { + unreachableAPI(t) + dir := projectDir(t, "") + + r := runTx(t, nil, "init") + + require.Equal(t, cli.ExitOK, r.code, r) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err, r) + assert.Contains(t, string(data), `texlive: "2025"`, r) + }) + + t.Run("the interactive prompt preselects the API's first version", func(t *testing.T) { + serveDistributions(t, apiDistributions) + dir := projectDir(t, "") + buf := &bytes.Buffer{} + ui := cli.NewUIWithOptions(buf, true, &enterReader{}) + + err := (&cli.InitCmd{Main: "main.tex", UI: ui}).Execute(nil) + + require.NoError(t, err, buf.String()) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err, buf.String()) + assert.Contains(t, string(data), `texlive: "2026"`, buf.String()) + assert.Contains(t, buf.String(), "TexLive version:", buf.String()) + }) +} + +func TestTokenListNameColumn(t *testing.T) { + t.Run("NAME column fits the longest name", func(t *testing.T) { + f := newFakeTexOps(t) + f.tokens = []map[string]any{ + {"id": "tok_1", "name": "github-actions-deploy-prod", "prefix": "tx_aaaa", "expires_at": "2027-01-01T00:00:00Z", "created_at": "2026-10-01T00:00:00Z"}, + {"id": "tok_2", "name": "dev", "prefix": "tx_bbbb", "last_used_at": "2026-10-05T00:00:00Z", "created_at": "2026-10-02T00:00:00Z"}, + } + + r := runTx(t, f, "token", "list") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, + " NAME PREFIX EXPIRES LAST USED CREATED \n"+ + " github-actions-deploy-prod tx_aaaa 01 Jan 2027 never 01 Oct 2026 \n"+ + " dev tx_bbbb never 05 Oct 2026 02 Oct 2026 \n", + r.stdout, r) + }) + + t.Run("NAME column counts characters, not bytes", func(t *testing.T) { + f := newFakeTexOps(t) + f.tokens = []map[string]any{ + {"id": "tok_1", "name": "Déploiement", "prefix": "tx_aaaa", "last_used_at": "2026-10-03T00:00:00Z", "created_at": "2026-10-01T00:00:00Z"}, + {"id": "tok_2", "name": "dev", "prefix": "tx_bbbb", "expires_at": "2027-01-01T00:00:00Z", "created_at": "2026-10-02T00:00:00Z"}, + } + + r := runTx(t, f, "token", "list") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, + " NAME PREFIX EXPIRES LAST USED CREATED \n"+ + " Déploiement tx_aaaa never 03 Oct 2026 01 Oct 2026 \n"+ + " dev tx_bbbb 01 Jan 2027 never 02 Oct 2026 \n", + r.stdout, r) + }) +} diff --git a/man/tx.1 b/man/tx.1 index 3522d93..c4a003d 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -28,6 +28,7 @@ .Cm token list .Nm .Cm token delete +.Op Fl y | -yes .Op Ar name .Nm .Cm version @@ -83,10 +84,12 @@ files containing In interactive mode, discovered documents are presented for selection. In non-interactive mode, all discovered documents are included automatically. .Pp +The supported TeX Live versions are fetched from the service; when it cannot +be reached, a built-in list is used. The interactive prompts for TeX Live version, compiler, and documents require both stdin and stdout to be a TTY. When either is not a TTY, the defaults are used: -.Cm 2025 , +the newest TeX Live version, .Cm pdflatex as the compiler, and all discovered documents. .Pp @@ -96,6 +99,9 @@ Flags: TeX Live distribution version .Pq e.g.\& Qq 2025 . Skips the interactive version prompt. +Must be a version the service supports; otherwise +.Nm +exits 2, lists the supported versions and writes nothing. .It Fl -compiler Ar name LaTeX compiler. Must be one of: @@ -119,10 +125,11 @@ On success, each built PDF is downloaded to the local output path defined in .Pp If .Pa .texops.yaml -does not exist and stdout is a TTY, an interactive prompt offers to run +does not exist and both stdin and stdout are a TTY, an interactive prompt +offers to run .Cm init first. -When stdout is not a TTY, the build fails with an error. +Otherwise the build fails with exit status 4. .Pp Flags: .Bl -tag -width Ds @@ -187,10 +194,22 @@ Delete an API token. When a .Ar name argument is given, the matching token is deleted after confirmation. -When stdout is not a TTY, this confirmation defaults to yes. -When no argument is given and stdout is a TTY, an interactive selection list -is presented; if there are no tokens, a message is shown instead. +Confirmation requires both stdin and stdout to be a TTY; without one, or with +.Fl -json , +the command exits 2 and deletes nothing unless +.Fl -yes +is given. +When no argument is given, stdout is a TTY, and either stdin is a TTY or +.Fl -yes +is given, an interactive selection list is presented; if there are no tokens, +a message is shown instead. In non-interactive mode, a token name argument is required. +.Pp +Flags: +.Bl -tag -width Ds +.It Fl y , Fl -yes +Delete without asking for confirmation, also on a terminal. +.El .Ss version Print the .Nm @@ -429,8 +448,15 @@ Service or unexpected failure: network errors, server errors, sync or upload failures, or a build that hit the time limit or failed inside the service. .It 2 Usage error: unknown command, missing subcommand or unknown flag, bad flag -value, unknown document name, -or missing required input when there is no terminal to prompt on. +value +.Pq including an unsupported Fl -texlive No or Fl -compiler , +unknown document name, +missing required input when there is no terminal to prompt on, +or +.Cm token delete +without +.Fl -yes +when there is no terminal to confirm on. .It 3 Not authenticated, or the session or API token has expired or was rejected. .It 4 diff --git a/man/tx.1.txt b/man/tx.1.txt index db3b46b..ad8109f 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -10,7 +10,7 @@ SYNOPSIS tx status tx token create [--name name] [--expires-in duration] [--no-expiry] tx token list - tx token delete [name] + tx token delete [-y | --yes] [name] tx version tx --version @@ -52,16 +52,19 @@ COMMANDS non-interactive mode, all discovered documents are included automatically. - The interactive prompts for TeX Live version, compiler, and documents - require both stdin and stdout to be a TTY. When either is not a TTY, the - defaults are used: 2025, pdflatex as the compiler, and all discovered - documents. + The supported TeX Live versions are fetched from the service; when it + cannot be reached, a built-in list is used. The interactive prompts for + TeX Live version, compiler, and documents require both stdin and stdout + to be a TTY. When either is not a TTY, the defaults are used: the newest + TeX Live version, pdflatex as the compiler, and all discovered documents. Flags: --texlive version TeX Live distribution version (e.g. "2025"). Skips the - interactive version prompt. + interactive version prompt. Must be a version the service + supports; otherwise tx exits 2, lists the supported versions and + writes nothing. --compiler name LaTeX compiler. Must be one of: pdflatex, xelatex, lualatex, @@ -79,9 +82,9 @@ COMMANDS are built. On success, each built PDF is downloaded to the local output path defined in .texops.yaml. - If .texops.yaml does not exist and stdout is a TTY, an interactive prompt - offers to run init first. When stdout is not a TTY, the build fails with - an error. + If .texops.yaml does not exist and both stdin and stdout are a TTY, an + interactive prompt offers to run init first. Otherwise the build fails + with exit status 4. Flags: @@ -130,11 +133,17 @@ COMMANDS token delete Delete an API token. When a name argument is given, the matching token - is deleted after confirmation. When stdout is not a TTY, this - confirmation defaults to yes. When no argument is given and stdout is a - TTY, an interactive selection list is presented; if there are no tokens, - a message is shown instead. In non-interactive mode, a token name - argument is required. + is deleted after confirmation. Confirmation requires both stdin and + stdout to be a TTY; without one, or with --json, the command exits 2 and + deletes nothing unless --yes is given. When no argument is given, stdout + is a TTY, and either stdin is a TTY or --yes is given, an interactive + selection list is presented; if there are no tokens, a message is shown + instead. In non-interactive mode, a token name argument is required. + + Flags: + + -y, --yes + Delete without asking for confirmation, also on a terminal. version Print the tx version and exit. The same output is available via tx @@ -294,8 +303,10 @@ EXIT STATUS failed inside the service. 2 Usage error: unknown command, missing subcommand or unknown flag, - bad flag value, unknown document name, or missing required input - when there is no terminal to prompt on. + bad flag value (including an unsupported --texlive or + --compiler), unknown document name, missing required input when + there is no terminal to prompt on, or token delete without --yes + when there is no terminal to confirm on. 3 Not authenticated, or the session or API token has expired or was rejected. diff --git a/site/docs/ci-cd.md b/site/docs/ci-cd.md index 803c279..3717021 100644 --- a/site/docs/ci-cd.md +++ b/site/docs/ci-cd.md @@ -56,8 +56,9 @@ List all tokens with their prefix, expiry, and last-used date: tx token list ``` -Delete a token by name: +Delete a token by name. On a terminal `tx` asks for confirmation; in a script, pass `--yes`: ```bash tx token delete "GitHub Actions" +tx token delete --yes "GitHub Actions" ``` diff --git a/site/docs/cli.md b/site/docs/cli.md index c3ced31..3d23ced 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -12,17 +12,19 @@ Authenticate with the TexOps service using the device code flow. A one-time code Create a `.texops.yaml` configuration file in the current directory. Recursively discovers `.tex` files containing `\documentclass` and presents them for selection. +The supported TeX Live versions come from the service (`GET /api/distributions`); when it cannot be reached, `tx` uses its built-in list. Without a terminal (stdin and stdout), `tx init` does not prompt: it uses the newest version, `pdflatex` and every discovered document. + | Flag | Description | |------|-------------| -| `--texlive ` | TeX Live distribution version, e.g. `"2025"`. Skips the interactive prompt. | -| `--compiler ` | LaTeX compiler (`pdflatex`, `xelatex`, `lualatex`, `latex`, `platex`, `uplatex`). Skips the interactive prompt. | +| `--texlive ` | TeX Live distribution version, e.g. `"2025"`. Skips the interactive prompt. Must be a version the service supports; otherwise `tx init` exits `2`, lists the supported versions and writes nothing. | +| `--compiler ` | LaTeX compiler (`pdflatex`, `xelatex`, `lualatex`, `latex`, `platex`, `uplatex`). Skips the interactive prompt. Any other value exits `2`. | | `--main ` | Fallback main `.tex` file, used only when recursive discovery finds no `.tex` files containing `\documentclass`. Does not override discovered documents. Defaults to `main.tex`. | ## `tx build` Build one or more documents defined in `.texops.yaml`. Positional arguments select documents by name; when none are given, all documents are built. On success, each PDF is downloaded to the output path defined in the config. -If `.texops.yaml` does not exist and stdout is a TTY, an interactive prompt offers to run `tx init` first. When stdout is not a TTY, the build fails with an error. +If `.texops.yaml` does not exist and both stdin and stdout are a TTY, an interactive prompt offers to run `tx init` first. Otherwise the build fails with exit code `4`. | Flag | Description | |------|-------------| @@ -49,7 +51,13 @@ List all API tokens with their name, prefix, expiry, last-used date, and creatio ## `tx token delete [name]` -Delete an API token. With a name argument, deletes the matching token after confirmation. Without a name argument in interactive mode, presents a selection list. +Delete an API token. With a name argument, deletes the matching token after confirmation. Without a name argument, when stdout is a TTY and either stdin is a TTY or `--yes` is given, presents a selection list. + +Without a terminal (stdin and stdout), or with `--json`, `tx` cannot ask for confirmation: it exits `2` without deleting anything unless `--yes` is given. + +| Flag | Description | +|------|-------------| +| `-y`, `--yes` | Delete without asking for confirmation, also on a terminal. | ## `tx version` @@ -90,7 +98,7 @@ Results go to stdout: the build summary, the `tx status` fields, the `tx token l |------|---------| | `0` | Success, including `--help` and `tx version`. | | `1` | Service or unexpected failure: network errors, server errors (5xx), sync or upload failures, a build that hit the time limit or failed inside the service. | -| `2` | Usage error: unknown command, missing subcommand or unknown flag, bad flag value, unknown document name, missing required input when there is no terminal to prompt on. | +| `2` | Usage error: unknown command, missing subcommand or unknown flag, bad flag value (including an unsupported `--texlive` or `--compiler`), unknown document name, missing required input when there is no terminal to prompt on, `tx token delete` without `--yes` when there is no terminal to confirm on. | | `3` | Not authenticated, or the session or API token has expired or was rejected. | | `4` | Project configuration error: `.texops.yaml` is missing or invalid, or its TeX Live version is not supported. | | `5` | A document failed to compile (LaTeX errors, or no PDF was produced). | From fd9d713d053c45ef4f39ad09a520d3f0827f7e3e Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 20:13:55 +0100 Subject: [PATCH 05/11] Make login usable without a browser tx status without credentials now reports ResolveAuth's not-authenticated or session-expired message (C5) instead of the old fixed text (C1); exit code 3 and the JSON shape are unchanged. --- README.md | 18 +-- internal/cli/commands.go | 65 ++++++---- internal/cli/commands_test.go | 197 ++++++++++++++++++++++++++++- internal/cli/global_config.go | 44 ++++--- internal/cli/global_config_test.go | 69 +++++++++- internal/cli/json_test.go | 4 +- internal/cli/run_test.go | 71 ++++++++++- man/tx.1 | 22 +++- man/tx.1.txt | 19 ++- site/docs/cli.md | 9 +- 10 files changed, 446 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index efeb05c..98d8d4a 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,15 @@ go install github.com/texops/tx/cmd/tx@latest ## Usage ``` -tx login # Authenticate with TexOps -tx init # Initialize a project in the current directory -tx build # Build all documents -tx build # 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 -tx token delete [name] [--yes] # Delete an API token +tx login [--no-browser] [--timeout ] # Authenticate with TexOps +tx init # Initialize a project in the current directory +tx build # Build all documents +tx build # 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 +tx token delete [name] [--yes] # Delete an API token ``` ### Getting started diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 113b4cd..283c079 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -76,7 +76,9 @@ type StatusCmd struct { } type LoginCmd struct { - UI *UI `no-flag:"true"` + NoBrowser bool `long:"no-browser" description:"Print the login URL and code without opening a browser"` + Timeout time.Duration `long:"timeout" description:"How long to wait for authorization, as a Go duration (e.g. 2m); defaults to the login code's lifetime"` + UI *UI `no-flag:"true"` } type InitCmd struct { @@ -171,7 +173,7 @@ func (cmd *StatusCmd) Execute(args []string) error { func checkStatus(ui *UI) (statusResult, error) { token, source, err := resolveAuthWithSource() if err != nil { - return statusResult{}, authError(errors.New("Not authenticated. Run 'tx login' to log in to TexOps.")) + return statusResult{}, err } apiURL := ResolveAPIURL(Config{}) @@ -202,11 +204,13 @@ func (cmd *LoginCmd) Execute(args []string) error { if ui == nil { ui = defaultUI() } + if cmd.Timeout < 0 { + return usageErrorf("invalid --timeout %q: must not be negative", cmd.Timeout) + } apiURL := ResolveAPIURL(Config{}) api := NewUnauthenticatedAPIClient(apiURL) - // Step 1: Request device code sp := ui.Spin("Requesting login code...") dcResp, err := api.RequestDeviceCode() if err != nil { @@ -215,44 +219,46 @@ func (cmd *LoginCmd) Execute(args []string) error { } sp.Stop("Login code received") - // Step 2: Show the user code and open the verification page, which the - // server has already stamped with that code. - ui.Status(fmt.Sprintf("Your login code: %s", dcResp.UserCode)) + ui.Status(fmt.Sprintf("Open %s and enter code %s", dcResp.VerificationURL, dcResp.UserCode)) + if !cmd.NoBrowser { + if err := OpenBrowser(dcResp.VerificationURL); err != nil { + ui.DimInfo("Could not open a browser automatically") + } + } - if err := OpenBrowser(dcResp.VerificationURL); err != nil { - ui.DimInfo(fmt.Sprintf("Could not open browser automatically. Open %s", dcResp.VerificationURL)) + lifetime := time.Duration(dcResp.ExpiresIn) * time.Second + expiredErr := &ExitError{Code: ExitFailure, Kind: KindTimeout, Err: errors.New("login code expired; run 'tx login' again")} + wait, waitLabel, waitErr := lifetime, "Login code expired", error(expiredErr) + if cmd.Timeout > 0 && (lifetime <= 0 || cmd.Timeout < lifetime) { + wait, waitLabel = cmd.Timeout, "Timed out waiting for authorization" + waitErr = &ExitError{Code: ExitFailure, Kind: KindTimeout, Err: fmt.Errorf("timed out after %s waiting for authorization; run 'tx login' again", shortDuration(cmd.Timeout))} } + deadline := time.Now().Add(wait) - // Step 3: Poll for authorization sp = ui.Spin("Waiting for authorization...") - deadline := time.Now().Add(time.Duration(dcResp.ExpiresIn) * time.Second) - var tokenResp TokenResponse for { - if time.Now().After(deadline) { - sp.Fail("Login code expired") - return fmt.Errorf("login code expired; please run 'tx login' again") - } - - time.Sleep(PollInterval) + time.Sleep(max(min(PollInterval, time.Until(deadline)), 0)) tokenResp, err = api.PollToken(dcResp.DeviceCode) if err == nil { break } - if errors.Is(err, ErrAuthorizationPending) { - continue - } if errors.Is(err, ErrDeviceCodeExpired) { sp.Fail("Login code expired") - return fmt.Errorf("login code expired; please run 'tx login' again") + return expiredErr + } + if !errors.Is(err, ErrAuthorizationPending) { + sp.Fail("Token polling failed") + return err + } + if !time.Now().Before(deadline) { + sp.Fail(waitLabel) + return waitErr } - sp.Fail("Token polling failed") - return err } sp.Stop("Authorized") - // Step 4: Store JWT if err := storeJWT(tokenResp.JWT); err != nil { return err } @@ -1223,3 +1229,14 @@ func (cmd *TokenDeleteCmd) Execute(args []string) error { return renderTokenDelete(ui, tokenDeleteResult{Deleted: tokenName}) } + +func shortDuration(d time.Duration) string { + s := d.String() + if strings.HasSuffix(s, "m0s") { + s = strings.TrimSuffix(s, "0s") + } + if strings.HasSuffix(s, "h0m") { + s = strings.TrimSuffix(s, "0m") + } + return s +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index feae95b..5880157 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -3,6 +3,7 @@ package cli_test import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -41,6 +42,58 @@ func mockKeyringForAuth(t *testing.T, token string) { t.Cleanup(func() { cli.KeyringGet = origGet }) } +// stubLogin replaces the browser, keyring and poll interval for login tests +// and returns the URLs the browser was asked to open. +func stubLogin(t *testing.T) *[]string { + t.Helper() + var opened []string + origBrowser := cli.OpenBrowser + cli.OpenBrowser = func(url string) error { + opened = append(opened, url) + return nil + } + t.Cleanup(func() { cli.OpenBrowser = origBrowser }) + origSet := cli.KeyringSet + cli.KeyringSet = func(service, user, key string) error { return nil } + t.Cleanup(func() { cli.KeyringSet = origSet }) + origInterval := cli.PollInterval + cli.PollInterval = time.Millisecond + t.Cleanup(func() { cli.PollInterval = origInterval }) + return &opened +} + +// loginServer serves the device code flow with a code valid for expiresIn +// seconds; token polls succeed when authorized and stay pending otherwise. +func loginServer(t *testing.T, expiresIn int, authorized bool) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/auth/device-code": + json.NewEncoder(w).Encode(map[string]any{ + "device_code": "dc_wxyz", + "user_code": "WXYZ-1234", + "verification_url": srv.URL + "/verify?code=WXYZ-1234", + "expires_in": expiresIn, + "interval": 1, + }) + case "/auth/token": + if !authorized { + w.WriteHeader(http.StatusPreconditionRequired) + json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"}) + return + } + json.NewEncoder(w).Encode(map[string]any{"jwt": "header.payload.sig", "expires_at": "2027-01-01T00:00:00Z"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + t.Setenv("TX_API_URL", srv.URL) + return srv +} + func TestLoginCmd(t *testing.T) { t.Run("successful device code flow", func(t *testing.T) { // Track what was stored in keyring @@ -148,11 +201,147 @@ func TestLoginCmd(t *testing.T) { t.Setenv("TX_API_URL", srv.URL) - ui, _ := testUI() + ui, buf := testUI() cmd := &cli.LoginCmd{UI: ui} err := cmd.Execute(nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "expired") + require.Error(t, err, buf.String()) + assert.Equal(t, "login code expired; run 'tx login' again", err.Error(), buf.String()) + assert.Equal(t, cli.ExitFailure, cli.AsExitError(err).Code, buf.String()) + assert.Equal(t, cli.KindTimeout, cli.AsExitError(err).Kind, buf.String()) + assert.Contains(t, buf.String(), "Login code expired\n") + }) + + t.Run("prints the URL and code before opening the browser", func(t *testing.T) { + stubLogin(t) + srv := loginServer(t, 900, true) + ui, buf := testUI() + var outputAtOpen string + cli.OpenBrowser = func(url string) error { + outputAtOpen = buf.String() + return nil + } + + err := (&cli.LoginCmd{UI: ui}).Execute(nil) + + require.NoError(t, err, buf.String()) + assert.Equal(t, "Requesting login code...\nLogin code received\nOpen "+srv.URL+"/verify?code=WXYZ-1234 and enter code WXYZ-1234\n", outputAtOpen, buf.String()) + }) + + t.Run("--no-browser prints the URL and code without opening the browser", func(t *testing.T) { + opened := stubLogin(t) + srv := loginServer(t, 900, true) + ui, buf := testUI() + + err := (&cli.LoginCmd{UI: ui, NoBrowser: true}).Execute(nil) + + require.NoError(t, err, buf.String()) + assert.Empty(t, *opened, buf.String()) + assert.Equal(t, + "Requesting login code...\nLogin code received\n"+ + "Open "+srv.URL+"/verify?code=WXYZ-1234 and enter code WXYZ-1234\n"+ + "Waiting for authorization...\nAuthorized\nLogged in successfully\n", + buf.String()) + }) + + t.Run("browser failure keeps the printed URL and code", func(t *testing.T) { + stubLogin(t) + srv := loginServer(t, 900, true) + cli.OpenBrowser = func(string) error { return errors.New("no display") } + ui, buf := testUI() + + err := (&cli.LoginCmd{UI: ui}).Execute(nil) + + require.NoError(t, err, buf.String()) + assert.Contains(t, buf.String(), "Open "+srv.URL+"/verify?code=WXYZ-1234 and enter code WXYZ-1234\nCould not open a browser automatically\n") + }) + + t.Run("--timeout stops waiting before the code expires", func(t *testing.T) { + stubLogin(t) + loginServer(t, 900, false) + ui, buf := testUI() + + start := time.Now() + err := (&cli.LoginCmd{UI: ui, Timeout: 50 * time.Millisecond}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.Less(t, time.Since(start), 5*time.Second, buf.String()) + assert.Equal(t, "timed out after 50ms waiting for authorization; run 'tx login' again", err.Error(), buf.String()) + assert.Equal(t, cli.ExitFailure, cli.AsExitError(err).Code, buf.String()) + assert.Equal(t, cli.KindTimeout, cli.AsExitError(err).Kind, buf.String()) + assert.True(t, strings.HasSuffix(buf.String(), "Waiting for authorization...\nTimed out waiting for authorization\n"), buf.String()) + }) + + t.Run("waits for the code lifetime by default", func(t *testing.T) { + stubLogin(t) + loginServer(t, 1, false) + ui, buf := testUI() + + start := time.Now() + err := (&cli.LoginCmd{UI: ui}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.GreaterOrEqual(t, time.Since(start), time.Second, buf.String()) + assert.Equal(t, "login code expired; run 'tx login' again", err.Error(), buf.String()) + assert.Equal(t, cli.KindTimeout, cli.AsExitError(err).Kind, buf.String()) + assert.True(t, strings.HasSuffix(buf.String(), "Waiting for authorization...\nLogin code expired\n"), buf.String()) + }) + + t.Run("--timeout longer than the code lifetime ends when the code expires", func(t *testing.T) { + stubLogin(t) + loginServer(t, 1, false) + ui, buf := testUI() + + start := time.Now() + err := (&cli.LoginCmd{UI: ui, Timeout: time.Hour}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.Less(t, time.Since(start), 5*time.Second, buf.String()) + assert.Equal(t, "login code expired; run 'tx login' again", err.Error(), buf.String()) + }) + + t.Run("--timeout is honoured when the server sends no code lifetime", func(t *testing.T) { + stubLogin(t) + loginServer(t, 0, false) + ui, buf := testUI() + + start := time.Now() + err := (&cli.LoginCmd{UI: ui, Timeout: 200 * time.Millisecond}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.GreaterOrEqual(t, time.Since(start), 200*time.Millisecond, buf.String()) + assert.Equal(t, "timed out after 200ms waiting for authorization; run 'tx login' again", err.Error(), buf.String()) + }) + + t.Run("--timeout shorter than the poll interval cuts the sleep short", func(t *testing.T) { + stubLogin(t) + cli.PollInterval = 5 * time.Second + loginServer(t, 900, false) + ui, buf := testUI() + + start := time.Now() + err := (&cli.LoginCmd{UI: ui, Timeout: 50 * time.Millisecond}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.Less(t, time.Since(start), 2*time.Second, buf.String()) + assert.Equal(t, "timed out after 50ms waiting for authorization; run 'tx login' again", err.Error(), buf.String()) + }) + + t.Run("negative --timeout is a usage error", func(t *testing.T) { + stubLogin(t) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + })) + defer srv.Close() + t.Setenv("TX_API_URL", srv.URL) + ui, buf := testUI() + + err := (&cli.LoginCmd{UI: ui, Timeout: -time.Second}).Execute(nil) + + require.Error(t, err, buf.String()) + assert.Equal(t, `invalid --timeout "-1s": must not be negative`, err.Error(), buf.String()) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code, buf.String()) + assert.Zero(t, requests.Load(), buf.String()) }) t.Run("device code request fails", func(t *testing.T) { @@ -1968,7 +2157,7 @@ func TestStatusCmd(t *testing.T) { err := cmd.Execute(nil) require.Error(t, err, buf.String()) - assert.Equal(t, "Not authenticated. Run 'tx login' to log in to TexOps.", err.Error()) + assert.Equal(t, "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')", err.Error()) assert.Equal(t, cli.ExitAuth, cli.AsExitError(err).Code) }) diff --git a/internal/cli/global_config.go b/internal/cli/global_config.go index e78f6ef..a60480b 100644 --- a/internal/cli/global_config.go +++ b/internal/cli/global_config.go @@ -78,8 +78,8 @@ func jwtExpiry(token string) time.Time { // ResolveAuth returns a Bearer token for API authentication. // It checks in order: TX_API_TOKEN env var, JWT from credentials file, -// JWT from keyring. Each source can override the ones below it. -// If a JWT is found but expired, it falls through to the next source. +// JWT from keyring. An expired JWT falls through to the next source; when +// nothing is left, the error names the expiry of the newest expired JWT. func ResolveAuth() (string, error) { token, _, err := resolveAuthWithSource() return token, err @@ -91,31 +91,39 @@ const ( authSourceKeyring = "keyring" ) +const notAuthenticatedMessage = "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')" + func resolveAuthWithSource() (string, string, error) { - // 1. API token from environment variable (highest priority override) if token := os.Getenv("TX_API_TOKEN"); token != "" { return token, authSourceEnv, nil } - // 2. JWT from credentials file (explicit file override) - if jwt, err := readJWTCredential(); err == nil && jwt != "" { - if exp := jwtExpiry(jwt); !exp.IsZero() && exp.Before(time.Now()) { - // JWT expired, fall through - } else { - return jwt, authSourceFile, nil - } + var newestExpired time.Time + candidates := []struct { + source string + read func() (string, error) + }{ + {authSourceFile, readJWTCredential}, + {authSourceKeyring, func() (string, error) { return KeyringGet(keyringService, keyringJWTUser) }}, } - - // 3. JWT from keyring (default storage) - if jwt, err := KeyringGet(keyringService, keyringJWTUser); err == nil && jwt != "" { - if exp := jwtExpiry(jwt); !exp.IsZero() && exp.Before(time.Now()) { - // JWT expired, fall through - } else { - return jwt, authSourceKeyring, nil + for _, c := range candidates { + jwt, err := c.read() + if err != nil || jwt == "" { + continue + } + exp := jwtExpiry(jwt) + if exp.IsZero() || !exp.Before(time.Now()) { + return jwt, c.source, nil + } + if exp.After(newestExpired) { + newestExpired = exp } } - return "", "", authError(errors.New("not authenticated: run 'tx login' or set TX_API_TOKEN")) + if !newestExpired.IsZero() { + return "", "", authError(fmt.Errorf("session expired on %s; run 'tx login'", newestExpired.UTC().Format(time.DateOnly))) + } + return "", "", authError(errors.New(notAuthenticatedMessage)) } func storeJWT(jwt string) error { diff --git a/internal/cli/global_config_test.go b/internal/cli/global_config_test.go index 6279cf8..ea05402 100644 --- a/internal/cli/global_config_test.go +++ b/internal/cli/global_config_test.go @@ -178,9 +178,64 @@ func TestResolveAuth(t *testing.T) { _, err := cli.ResolveAuth() require.Error(t, err) - assert.Contains(t, err.Error(), "not authenticated") - assert.Contains(t, err.Error(), "tx login") - assert.Contains(t, err.Error(), "TX_API_TOKEN") + assert.Equal(t, "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')", err.Error()) + assert.Equal(t, cli.ExitAuth, cli.AsExitError(err).Code) + }) + + t.Run("expired JWTs report the newest expiry when the keyring is newer", func(t *testing.T) { + credPath := withTempCredentialsDir(t) + require.NoError(t, os.MkdirAll(filepath.Dir(credPath), 0o700)) + fileJWT := makeTestJWT(time.Date(2026, 1, 10, 8, 0, 0, 0, time.UTC)) + require.NoError(t, os.WriteFile(credPath, []byte("jwt: "+fileJWT+"\n"), 0o600)) + + origGet := cli.KeyringGet + defer func() { cli.KeyringGet = origGet }() + cli.KeyringGet = func(service, user string) (string, error) { + return makeTestJWT(time.Date(2026, 2, 20, 8, 0, 0, 0, time.UTC)), nil + } + t.Setenv("TX_API_TOKEN", "") + + _, err := cli.ResolveAuth() + require.Error(t, err) + assert.Equal(t, "session expired on 2026-02-20; run 'tx login'", err.Error()) + assert.Equal(t, cli.ExitAuth, cli.AsExitError(err).Code) + }) + + t.Run("expired JWTs report the newest expiry when the file is newer", func(t *testing.T) { + credPath := withTempCredentialsDir(t) + require.NoError(t, os.MkdirAll(filepath.Dir(credPath), 0o700)) + fileJWT := makeTestJWT(time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC)) + require.NoError(t, os.WriteFile(credPath, []byte("jwt: "+fileJWT+"\n"), 0o600)) + + origGet := cli.KeyringGet + defer func() { cli.KeyringGet = origGet }() + cli.KeyringGet = func(service, user string) (string, error) { + return makeTestJWT(time.Date(2026, 2, 20, 8, 0, 0, 0, time.UTC)), nil + } + t.Setenv("TX_API_TOKEN", "") + + _, err := cli.ResolveAuth() + require.Error(t, err) + assert.Equal(t, "session expired on 2026-05-01; run 'tx login'", err.Error()) + }) + + t.Run("expired file JWT falls back to a valid keyring JWT", func(t *testing.T) { + credPath := withTempCredentialsDir(t) + require.NoError(t, os.MkdirAll(filepath.Dir(credPath), 0o700)) + fileJWT := makeTestJWT(time.Now().Add(-time.Hour)) + require.NoError(t, os.WriteFile(credPath, []byte("jwt: "+fileJWT+"\n"), 0o600)) + + validJWT := makeTestJWT(time.Now().Add(time.Hour)) + origGet := cli.KeyringGet + defer func() { cli.KeyringGet = origGet }() + cli.KeyringGet = func(service, user string) (string, error) { + return validJWT, nil + } + t.Setenv("TX_API_TOKEN", "") + + token, err := cli.ResolveAuth() + require.NoError(t, err) + assert.Equal(t, validJWT, token) }) t.Run("expired JWT falls back to TX_API_TOKEN", func(t *testing.T) { @@ -202,9 +257,9 @@ func TestResolveAuth(t *testing.T) { assert.Equal(t, "texops_token_fallback-token", token) }) - t.Run("expired JWT with no token returns not authenticated error", func(t *testing.T) { + t.Run("expired JWT with no token reports the expiry date", func(t *testing.T) { _ = withTempCredentialsDir(t) - expiredJWT := makeTestJWT(time.Now().Add(-1 * time.Hour)) + expiredJWT := makeTestJWT(time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC)) origGet := cli.KeyringGet defer func() { cli.KeyringGet = origGet }() @@ -218,8 +273,8 @@ func TestResolveAuth(t *testing.T) { _, err := cli.ResolveAuth() require.Error(t, err) - assert.Contains(t, err.Error(), "not authenticated") - assert.Contains(t, err.Error(), "tx login") + assert.Equal(t, "session expired on 2026-03-30; run 'tx login'", err.Error()) + assert.Equal(t, cli.ExitAuth, cli.AsExitError(err).Code) }) t.Run("valid JWT is returned even when near expiry", func(t *testing.T) { diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go index 51bb273..711c136 100644 --- a/internal/cli/json_test.go +++ b/internal/cli/json_test.go @@ -331,7 +331,7 @@ func TestRunJSONStatus(t *testing.T) { require.Equal(t, cli.ExitAuth, r.code, r) assert.JSONEq(t, `{"authenticated": false}`, r.stdout, r) - assert.Equal(t, "Not authenticated. Run 'tx login' to log in to TexOps.\n", r.stderr, r) + assert.Equal(t, "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')\n", r.stderr, r) }) t.Run("rejected token exits 3", func(t *testing.T) { @@ -470,7 +470,7 @@ func TestRunJSONLogin(t *testing.T) { require.Equal(t, cli.ExitOK, r.code, r) assert.JSONEq(t, `{"authenticated": true}`, r.stdout, r) assert.Equal(t, "header.payload.sig", stored, r) - assert.Contains(t, r.stderr, "Your login code: ABCD-EFGH\n", r) + assert.Contains(t, r.stderr, "Open "+f.srv.URL+"/verify and enter code ABCD-EFGH\n", r) }) t.Run("failed login prints an error document", func(t *testing.T) { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index fbd1a1a..c85f71f 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -364,7 +365,7 @@ func TestRunExitCodes(t *testing.T) { r := runTx(t, nil, "status") assert.Equal(t, cli.ExitAuth, r.code, r) - assert.Equal(t, "Not authenticated. Run 'tx login' to log in to TexOps.\n", r.stderr, r) + assert.Equal(t, "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')\n", r.stderr, r) assert.Empty(t, r.stdout, r) }) @@ -702,3 +703,71 @@ func TestRunBuildErrorKind(t *testing.T) { assert.Equal(t, cli.KindConfig, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) }) } + +func TestRunLogin(t *testing.T) { + t.Run("login --no-browser prints the code on stderr and the result on stdout", func(t *testing.T) { + f := newFakeTexOps(t) + opened := stubLogin(t) + + r := runTx(t, f, "login", "--no-browser", "--timeout", "30s") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Empty(t, *opened, r) + assert.Equal(t, "Logged in successfully\n", r.stdout, r) + assert.Equal(t, + "Requesting login code...\nLogin code received\n"+ + "Open "+f.srv.URL+"/verify and enter code ABCD-EFGH\n"+ + "Waiting for authorization...\nAuthorized\n", + r.stderr, r) + }) + + t.Run("invalid --timeout exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "login", "--timeout", "soon") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "invalid argument for flag `--timeout' (expected time.Duration): time: invalid duration \"soon\"\n", r.stderr, r) + assert.Empty(t, r.requests, r) + }) + + t.Run("status with expired credentials exits 3 with the expiry date", func(t *testing.T) { + noCredentials(t) + cli.KeyringGet = func(service, user string) (string, error) { + return makeTestJWT(time.Date(2026, 4, 2, 9, 0, 0, 0, time.UTC)), nil + } + + r := runTx(t, nil, "status") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Equal(t, "session expired on 2026-04-02; run 'tx login'\n", r.stderr, r) + assert.Empty(t, r.stdout, r) + }) + + t.Run("build with expired credentials exits 3 with the expiry date", func(t *testing.T) { + f := newFakeTexOps(t) + noCredentials(t) + cli.KeyringGet = func(service, user string) (string, error) { + return makeTestJWT(time.Date(2026, 4, 2, 9, 0, 0, 0, time.UTC)), nil + } + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Equal(t, 1, strings.Count(r.stderr, "session expired on 2026-04-02; run 'tx login'\n"), r) + assert.Empty(t, r.requests, r) + }) + + t.Run("build without credentials asks for the user or a token", func(t *testing.T) { + f := newFakeTexOps(t) + noCredentials(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build") + + assert.Equal(t, cli.ExitAuth, r.code, r) + assert.Equal(t, 1, strings.Count(r.stderr, "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')\n"), r) + assert.Empty(t, r.requests, r) + }) +} diff --git a/man/tx.1 b/man/tx.1 index c4a003d..b120df9 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -7,6 +7,8 @@ .Sh SYNOPSIS .Nm .Cm login +.Op Fl -no-browser +.Op Fl -timeout Ar duration .Nm .Cm init .Op Fl -texlive Ar version @@ -69,10 +71,26 @@ environment variable instead of running .Sh COMMANDS .Ss login Authenticate with the TexOps service using the device code flow. -A one-time code is displayed and the verification URL is opened in a browser -automatically when possible, with the code already filled in. +Prints +.Ql Open and enter code +to stderr, then opens the URL in a browser when possible, with the code +already filled in. After authorization completes in the browser, the session JWT is stored locally. +.Bl -tag -width Ds +.It Fl -no-browser +Print the URL and code without opening a browser. +.It Fl -timeout Ar duration +How long to wait for authorization, as a Go duration such as +.Ql 2m . +Defaults to the lifetime of the login code. +When it runs out, or the code expires first, +.Nm +exits 1. +.El +.Pp +Commands that need credentials exit 3 when there are none. +When the stored session has expired, the message names the date it expired. .Ss init Create a .Pa .texops.yaml diff --git a/man/tx.1.txt b/man/tx.1.txt index ad8109f..b6da609 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -4,7 +4,7 @@ NAME tx – TexOps CLI to build LaTeX documents in the cloud SYNOPSIS - tx login + tx login [--no-browser] [--timeout duration] tx init [--texlive version] [--compiler name] [--main file] tx build [--live] [--no-cache] [name ...] tx status @@ -39,12 +39,23 @@ GETTING STARTED COMMANDS login - Authenticate with the TexOps service using the device code flow. A one- - time code is displayed and the verification URL is opened in a browser - automatically when possible, with the code already filled in. After + Authenticate with the TexOps service using the device code flow. Prints + ‘Open and enter code ’ to stderr, then opens the + URL in a browser when possible, with the code already filled in. After authorization completes in the browser, the session JWT is stored locally. + --no-browser + Print the URL and code without opening a browser. + + --timeout duration + How long to wait for authorization, as a Go duration such as + ‘2m’. Defaults to the lifetime of the login code. When it runs + out, or the code expires first, tx exits 1. + + Commands that need credentials exit 3 when there are none. When the + stored session has expired, the message names the date it expired. + init Create a .texops.yaml configuration file in the current directory. Recursively discovers .tex files containing ‘\documentclass’. In diff --git a/site/docs/cli.md b/site/docs/cli.md index 3d23ced..2dedbfc 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -6,7 +6,14 @@ Complete reference for all `tx` commands, flags, environment variables, and file ## `tx login` -Authenticate with the TexOps service using the device code flow. A one-time code is displayed and the verification URL is opened in a browser automatically when possible, with the code already filled in. After authorization completes, the session JWT is stored in the system keyring (or the credentials file as a fallback). +Authenticate with the TexOps service using the device code flow. `tx login` prints `Open and enter code ` to stderr, then opens the URL in a browser when possible, with the code already filled in. After authorization completes, the session JWT is stored in the system keyring (or the credentials file as a fallback). + +| Flag | Description | +|------|-------------| +| `--no-browser` | Print the URL and code without opening a browser. | +| `--timeout ` | How long to wait for authorization, as a Go duration such as `2m` or `90s`. Defaults to the lifetime of the login code. When it runs out (or the code expires first), `tx login` exits `1`. | + +Commands that need credentials exit `3` when there are none. When the stored session has expired, the message names the date, for example `session expired on 2026-03-30; run 'tx login'`. ## `tx init` From 24cee196829dc77ec907db74de4111caf3edbfb5 Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 20:29:03 +0100 Subject: [PATCH 06/11] Show LaTeX diagnostics and save build logs --- internal/cli/buildlog.go | 183 ++++++++ internal/cli/client.go | 115 ++--- internal/cli/client_download_test.go | 54 +++ internal/cli/client_test.go | 61 ++- internal/cli/commands.go | 87 ++-- internal/cli/commands_test.go | 48 +-- internal/cli/diagnostics_test.go | 612 +++++++++++++++++++++++++++ internal/cli/hasher.go | 2 +- internal/cli/hasher_test.go | 18 + internal/cli/main_test.go | 13 + internal/cli/output.go | 83 +++- internal/cli/run_test.go | 33 +- internal/cli/watcher.go | 8 + internal/cli/watcher_test.go | 100 +++++ site/docs/configuration.md | 2 +- 15 files changed, 1295 insertions(+), 124 deletions(-) create mode 100644 internal/cli/buildlog.go create mode 100644 internal/cli/diagnostics_test.go create mode 100644 internal/cli/main_test.go diff --git a/internal/cli/buildlog.go b/internal/cli/buildlog.go new file mode 100644 index 0000000..fd5eb3b --- /dev/null +++ b/internal/cli/buildlog.go @@ -0,0 +1,183 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +const ( + LogStdout = "stdout" + LogFile = "file" +) + +const logTailLines = 20 + +// resolveLogMode returns the --log value, defaulting to file under a coding +// agent or with --json and to stdout otherwise. +func resolveLogMode(flag string, jsonMode bool, getenv func(string) string) string { + if flag != "" { + return flag + } + if jsonMode || DetectAgent(getenv) != "" { + return LogFile + } + return LogStdout +} + +// logFilePaths maps each document name to the project-relative path of its +// saved build log. Names that clash after sanitizing, ignoring case, get a +// hash suffix so that every document keeps its own file. +func logFilePaths(docs []Document) map[string]string { + bases := make(map[string]string, len(docs)) + clashes := map[string]int{} + for _, doc := range docs { + if _, ok := bases[doc.Name]; ok { + continue + } + base := logFileName(doc.Name) + bases[doc.Name] = base + clashes[strings.ToLower(base)]++ + } + paths := make(map[string]string, len(bases)) + for name, base := range bases { + if clashes[strings.ToLower(base)] > 1 { + sum := sha256.Sum256([]byte(name)) + base += "-" + hex.EncodeToString(sum[:4]) + } + paths[name] = filepath.Join(".texops", "logs", base+".log") + } + return paths +} + +func logFileName(docName string) string { + name := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', strings.ContainsRune("._-", r): + return r + default: + return '_' + } + }, docName) + if strings.Trim(name, ".") == "" { + return "document" + } + return name +} + +func removeBuildLog(dir, rel string) error { + err := os.Remove(filepath.Join(dir, rel)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// saveBuildLog downloads the build log to rel, creating .texops/.gitignore +// on first use. +func saveBuildLog(ctx context.Context, inst *InstanceClient, dir, rel, logURL string) error { + if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(rel)), 0o750); err != nil { + return err + } + if err := ensureTexopsGitignore(dir); err != nil { + return err + } + return inst.DownloadLog(ctx, logURL, filepath.Join(dir, rel)) +} + +func ensureTexopsGitignore(dir string) error { + path := filepath.Join(dir, ".texops", ".gitignore") + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + return nil + } + if err != nil { + return err + } + if _, err := f.WriteString("*\n"); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +const logTailReadBytes = 64 * 1024 + +func tailFile(path string, n int) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, err + } + offset := max(info.Size()-logTailReadBytes, 0) + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return nil, err + } + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimRight(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n"), "\n") + if offset > 0 && len(lines) > 1 { + lines = lines[1:] + } + return lastLines(lines, n), nil +} + +func lastLines(lines []string, n int) []string { + if len(lines) > n { + return lines[len(lines)-n:] + } + return lines +} + +type lineTail struct { + n int + lines []string +} + +func (t *lineTail) add(line string) { + t.lines = append(t.lines, line) + if len(t.lines) > 2*t.n { + t.lines = append(t.lines[:0], t.lines[len(t.lines)-t.n:]...) + } +} + +func (t *lineTail) last() []string { + return lastLines(t.lines, t.n) +} + +// printLogTail shows the end of a failed build's log on stderr: the saved +// log when there is one, otherwise the streamed output. +func printLogTail(ui *UI, dir, logPath string, streamed *lineTail) { + lines := streamed.last() + source := "build output" + if logPath != "" { + if fileLines, err := tailFile(filepath.Join(dir, logPath), logTailLines); err == nil && hasText(fileLines) { + lines, source = fileLines, filepath.ToSlash(logPath) + } + } + if !hasText(lines) { + return + } + ui.DimInfo(fmt.Sprintf("Last %s of %s:", plural(len(lines), "line"), source)) + for _, line := range lines { + ui.StreamLog(line) + } +} + +func hasText(lines []string) bool { + return slices.ContainsFunc(lines, func(line string) bool { return line != "" }) +} diff --git a/internal/cli/client.go b/internal/cli/client.go index d67c20c..1e8b4b2 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -19,6 +19,8 @@ import ( var validIDPattern = regexp.MustCompile(`^[a-z]{3}_[0-9A-Za-z]+$`) +var validLogURLPattern = regexp.MustCompile(`^/projects/[a-z]{3}_[0-9A-Za-z]+/builds/[a-z]{3}_[0-9A-Za-z]+/log$`) + // APIError represents an HTTP error response from the API. type APIError struct { Op string @@ -58,11 +60,14 @@ type SyncResult struct { } type BuildDoneEvent struct { - Status string `json:"status"` - PdfURL string `json:"pdfUrl,omitempty"` - Message string `json:"message,omitempty"` - BuildID string `json:"build_id,omitempty"` - Reason string `json:"reason,omitempty"` + Status string `json:"status"` + PdfURL string `json:"pdfUrl,omitempty"` + Message string `json:"message,omitempty"` + BuildID string `json:"build_id,omitempty"` + Reason string `json:"reason,omitempty"` + LogURL string `json:"log_url,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + Truncated bool `json:"truncated,omitempty"` } // Diagnostic is one LaTeX error or warning reported for a build. @@ -653,47 +658,67 @@ func (c *InstanceClient) UploadRaw(ctx context.Context, projectID string, tarDat } 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, - } - if directory != "" { - payload["directory"] = directory + return c.build(ctx, projectID, main, directory, distVersion, compiler, args, buildOptions, onLog, onLog) +} + +// Build runs a build, calling onLog with each line of build output and +// onQueued with each queue status message. +func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, distVersion, compiler string, buildOptions map[string]string, onLog, onQueued func(string)) (BuildDoneEvent, error) { + return c.build(ctx, projectID, main, directory, distVersion, compiler, nil, buildOptions, onLog, onQueued) +} + +func (c *InstanceClient) DownloadPDF(ctx context.Context, projectID, buildID, outputPath string) error { + if !validIDPattern.MatchString(buildID) { + return fmt.Errorf("invalid build ID format") } - if compiler != "" { - payload["compiler"] = compiler + u := fmt.Sprintf("%s/projects/%s/builds/%s/output", c.baseURL, projectID, buildID) + + req, err := newRequest(ctx, "GET", u, nil) + if err != nil { + return err } - if len(args) > 0 { - payload["args"] = args + req.Header.Set("Authorization", "Bearer "+c.jwt) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err } - if len(buildOptions) > 0 { - payload["build_options"] = buildOptions + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &APIError{Op: "PDF download", StatusCode: resp.StatusCode} } - body, _ := json.Marshal(payload) - u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID) + return writeFilePreserveInode(resp.Body, outputPath) +} - req, err := newRequest(ctx, "POST", u, bytes.NewReader(body)) +// DownloadLog saves the TeX log at logURL, a path relative to the instance +// from the build's done event, to outputPath. +func (c *InstanceClient) DownloadLog(ctx context.Context, logURL, outputPath string) error { + if !validLogURLPattern.MatchString(logURL) { + return fmt.Errorf("invalid log URL %q", logURL) + } + + req, err := newRequest(ctx, "GET", c.baseURL+logURL, nil) if err != nil { - return BuildDoneEvent{}, err + return err } req.Header.Set("Authorization", "Bearer "+c.jwt) - req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { - return BuildDoneEvent{}, err + return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return BuildDoneEvent{}, &APIError{Op: "build request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} + return &APIError{Op: "log download", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } - return ParseSSEStream(resp.Body, onLog) + return writeFilePreserveInode(resp.Body, outputPath) } -func (c *InstanceClient) Build(ctx context.Context, 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, args []string, buildOptions map[string]string, onLog, onQueued func(string)) (BuildDoneEvent, error) { payload := map[string]any{ "main": main, "distribution_version": distVersion, @@ -704,6 +729,9 @@ func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, if compiler != "" { payload["compiler"] = compiler } + if len(args) > 0 { + payload["args"] = args + } if len(buildOptions) > 0 { payload["build_options"] = buildOptions } @@ -728,32 +756,7 @@ func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, return BuildDoneEvent{}, &APIError{Op: "build request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } - return ParseSSEStream(resp.Body, onLog) -} - -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 := newRequest(ctx, "GET", u, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.jwt) - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return &APIError{Op: "PDF download", StatusCode: resp.StatusCode} - } - - return writeFilePreserveInode(resp.Body, outputPath) + return ParseSSEEvents(resp.Body, onLog, onQueued) } func (c *InstanceClient) uploadTar(ctx context.Context, projectID string, tarData []byte, onSent func(int64)) error { @@ -831,6 +834,10 @@ func writeFilePreserveInode(r io.Reader, outputPath string) error { } func ParseSSEStream(reader io.Reader, onLog func(string)) (BuildDoneEvent, error) { + return ParseSSEEvents(reader, onLog, onLog) +} + +func ParseSSEEvents(reader io.Reader, onLog, onQueued func(string)) (BuildDoneEvent, error) { result := BuildDoneEvent{ Status: "error", Message: "Stream ended unexpectedly", @@ -850,8 +857,8 @@ func ParseSSEStream(reader io.Reader, onLog func(string)) (BuildDoneEvent, error onLog(extractSSEMessage(eventData)) } case "queued": - if onLog != nil { - onLog(extractSSEMessage(eventData)) + if onQueued != nil { + onQueued(extractSSEMessage(eventData)) } case "done": if err := json.Unmarshal([]byte(eventData), &result); err != nil { diff --git a/internal/cli/client_download_test.go b/internal/cli/client_download_test.go index 4fabdbc..3b1a693 100644 --- a/internal/cli/client_download_test.go +++ b/internal/cli/client_download_test.go @@ -130,3 +130,57 @@ func TestDownloadPDF_FailureLeavesOriginalIntact(t *testing.T) { } }) } + +func TestDownloadLog(t *testing.T) { + t.Run("saves the log with the instance JWT", func(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte("This is pdfTeX\n! Undefined control sequence.\n")) + })) + defer srv.Close() + outputPath := filepath.Join(t.TempDir(), "paper.log") + + client := cli.NewInstanceClient(srv.URL+"/sandbox/2025", "instance-jwt") + err := client.DownloadLog(t.Context(), "/projects/prj_abc/builds/bld_abc/log", outputPath) + + require.NoError(t, err, "GET %s", gotPath) + assert.Equal(t, "/sandbox/2025/projects/prj_abc/builds/bld_abc/log", gotPath) + assert.Equal(t, "Bearer instance-jwt", gotAuth) + got, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, "This is pdfTeX\n! Undefined control sequence.\n", string(got)) + }) + + t.Run("expired log is an API error with the server message", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusGone) + w.Write([]byte(`{"error":"build not found or expired"}`)) + })) + defer srv.Close() + outputPath := filepath.Join(t.TempDir(), "paper.log") + + client := cli.NewInstanceClient(srv.URL, "instance-jwt") + err := client.DownloadLog(t.Context(), "/projects/prj_abc/builds/bld_abc/log", outputPath) + + require.Error(t, err) + assert.Equal(t, "log download failed (410): build not found or expired", err.Error()) + assert.NoFileExists(t, outputPath) + }) + + t.Run("log URL outside the instance is refused without a request", func(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + })) + defer srv.Close() + + client := cli.NewInstanceClient(srv.URL, "instance-jwt") + err := client.DownloadLog(t.Context(), "/../../projects/prj_abc/builds/bld_abc/log", filepath.Join(t.TempDir(), "paper.log")) + + require.Error(t, err) + assert.Equal(t, `invalid log URL "/../../projects/prj_abc/builds/bld_abc/log"`, err.Error()) + assert.Zero(t, requests) + }) +} diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index c544097..20f6078 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -397,7 +397,7 @@ func TestInstanceClient_Build(t *testing.T) { var logs []string result, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, func(line string) { logs = append(logs, line) - }) + }, nil) require.NoError(t, err) assert.Equal(t, []string{"Compiling...", "Done."}, logs) assert.Equal(t, "success", result.Status) @@ -418,7 +418,7 @@ func TestInstanceClient_Build(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - result, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, nil) + result, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, nil, nil) require.NoError(t, err) assert.Equal(t, "error", result.Status) assert.Equal(t, "Build failed", result.Message) @@ -433,7 +433,7 @@ func TestInstanceClient_Build(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - _, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, nil) + _, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "build request failed (500)") }) @@ -538,6 +538,35 @@ func TestParseSSEStream(t *testing.T) { assert.Equal(t, "failed", result.Message) }) + t.Run("parses the reason, log URL and diagnostics of a done event", func(t *testing.T) { + input := "event: done\ndata: {\"status\":\"error\",\"reason\":\"latex_error\",\"message\":\"1 LaTeX error in sec/intro.tex\",\"build_id\":\"bld_1\",\"log_url\":\"/projects/prj_1/builds/bld_1/log\",\"diagnostics\":[{\"severity\":\"error\",\"file\":\"sec/intro.tex\",\"line\":3,\"message\":\"Undefined control sequence.\",\"context\":\"\\\\badmacro\"},{\"severity\":\"warning\",\"kind\":\"undefined_citation\",\"file\":\"sec/intro.tex\",\"line\":1,\"message\":\"Citation `missing' undefined\"}],\"truncated\":true}\n\n" + + result, err := cli.ParseSSEStream(strings.NewReader(input), nil) + + require.NoError(t, err, input) + assert.Equal(t, cli.BuildDoneEvent{ + Status: "error", + Reason: "latex_error", + Message: "1 LaTeX error in sec/intro.tex", + BuildID: "bld_1", + LogURL: "/projects/prj_1/builds/bld_1/log", + Diagnostics: []cli.Diagnostic{ + {Severity: "error", File: "sec/intro.tex", Line: 3, Message: "Undefined control sequence.", Context: "\\badmacro"}, + {Severity: "warning", Kind: "undefined_citation", File: "sec/intro.tex", Line: 1, Message: "Citation `missing' undefined"}, + }, + Truncated: true, + }, result, input) + }) + + t.Run("done event from an old server has no diagnostics", func(t *testing.T) { + input := "event: done\ndata: {\"status\":\"error\",\"message\":\"build failed with exit code 12\",\"build_id\":\"bld_1\"}\n\n" + + result, err := cli.ParseSSEStream(strings.NewReader(input), nil) + + require.NoError(t, err, input) + assert.Equal(t, cli.BuildDoneEvent{Status: "error", Message: "build failed with exit code 12", BuildID: "bld_1"}, result, input) + }) + t.Run("handles queued event", func(t *testing.T) { input := "event: queued\ndata: {\"message\":\"build queued, waiting for previous build to finish\"}\n\nevent: log\ndata: {\"message\":\"Starting build\"}\n\nevent: done\ndata: {\"status\":\"success\"}\n\n" reader := strings.NewReader(input) @@ -550,6 +579,22 @@ func TestParseSSEStream(t *testing.T) { assert.Equal(t, []string{"build queued, waiting for previous build to finish", "Starting build"}, logs) assert.Equal(t, "success", result.Status) }) + + t.Run("separates queued events from build output", func(t *testing.T) { + input := "event: queued\ndata: {\"message\":\"build queued, waiting for previous build to finish\"}\n\nevent: log\ndata: {\"message\":\"Starting build\"}\n\nevent: done\ndata: {\"status\":\"success\"}\n\n" + + var logs, queued []string + result, err := cli.ParseSSEEvents(strings.NewReader(input), func(line string) { + logs = append(logs, line) + }, func(msg string) { + queued = append(queued, msg) + }) + + require.NoError(t, err, input) + assert.Equal(t, []string{"Starting build"}, logs, input) + assert.Equal(t, []string{"build queued, waiting for previous build to finish"}, queued, input) + assert.Equal(t, "success", result.Status, input) + }) } func TestAPIClient_RequestDeviceCode(t *testing.T) { @@ -872,7 +917,7 @@ func TestE2E_TwoClients(t *testing.T) { var logs []string result, err := inst.Build(t.Context(), project.ID, "paper.tex", "", "texlive:2021", "", nil, func(line string) { logs = append(logs, line) - }) + }, nil) require.NoError(t, err) assert.Equal(t, "success", result.Status) assert.Equal(t, []string{"Running latexmk..."}, logs) @@ -909,7 +954,7 @@ func TestInstanceClient_Build_DirectoryInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "chapters/paper", "texlive:2021", "", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "chapters/paper", "texlive:2021", "", nil, nil, nil) require.NoError(t, err) assert.Equal(t, "paper.tex", receivedPayload["main"]) @@ -938,7 +983,7 @@ func TestInstanceClient_Build_DirectoryInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "", nil, nil, nil) require.NoError(t, err) assert.NotContains(t, string(receivedBody), "directory") @@ -967,7 +1012,7 @@ func TestInstanceClient_Build_CompilerInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "xelatex", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "xelatex", nil, nil, nil) require.NoError(t, err) assert.Equal(t, "paper.tex", receivedPayload["main"]) @@ -996,7 +1041,7 @@ func TestInstanceClient_Build_CompilerInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "", nil, nil, nil) require.NoError(t, err) assert.NotContains(t, string(receivedBody), "compiler") diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 283c079..5862afc 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -92,6 +92,7 @@ type BuildCmd struct { Args struct{ Names []string } `positional-args:"true"` NoCache bool `long:"no-cache" description:"Clear build cache and rebuild from scratch"` Live bool `long:"live" description:"Watch for changes and rebuild automatically"` + Log string `long:"log" choice:"stdout" choice:"file" description:"Where the LaTeX log goes: stdout streams it, file saves it to .texops/logs/.log (default: file under a coding agent or with --json, otherwise stdout)"` UI *UI `no-flag:"true"` } @@ -129,6 +130,8 @@ type buildParams struct { docs []Document api *APIClient noCache bool + logMode string + logPaths map[string]string ui *UI projectID string } @@ -591,7 +594,8 @@ func (cmd *BuildCmd) Execute(args []string) error { } else { ctx = context.Background() } - err = RunBuild(ctx, dir, cmd.Args.Names, cmd.NoCache, cmd.Live, ui) + logMode := resolveLogMode(cmd.Log, ui.JSON(), os.Getenv) + err = RunBuild(ctx, dir, cmd.Args.Names, cmd.NoCache, cmd.Live, logMode, ui) if cmd.Live && ctx.Err() != nil { return nil } @@ -600,7 +604,7 @@ func (cmd *BuildCmd) Execute(args []string) error { var errInitDeclined = errors.New("no project config found; run `tx init` to set up your project") -func runBuild(ctx context.Context, dir string, names []string, noCache bool, live bool, ui *UI) error { +func runBuild(ctx context.Context, dir string, names []string, noCache, live bool, logMode string, ui *UI) error { buildStart := time.Now() configPath := filepath.Join(dir, ".texops.yaml") @@ -694,6 +698,8 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv docs: docs, api: api, noCache: noCache, + logMode: logMode, + logPaths: logFilePaths(config.Documents), ui: ui, projectID: projectID, } @@ -826,7 +832,7 @@ func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { } for _, doc := range group.docs { - r := buildDocument(ctx, p.ui, inst, p.projectID, p.dir, doc, p.noCache) + r := buildDocument(ctx, p, inst, doc) if ctx.Err() != nil { return nil, ctx.Err() } @@ -920,30 +926,57 @@ func handleUpload(ctx context.Context, ui *UI, inst *InstanceClient, projectID, } // buildDocument builds a single document and returns the result. -func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, dir string, doc Document, noCache bool) docResult { +func buildDocument(ctx context.Context, p buildParams, inst *InstanceClient, doc Document) docResult { + ui := p.ui ui.Status(fmt.Sprintf("Building %q (%s)...", doc.Name, docMainPath(doc))) var buildOptions map[string]string - if noCache { + if p.noCache { buildOptions = map[string]string{"no_cache": "true"} } compileStart := time.Now() - fail := func(err error, reason string) docResult { - r := failedDoc(doc, err, reason) - r.Duration = time.Since(compileStart) - return r + streamed := &lineTail{n: logTailLines} + logRel := p.logPaths[doc.Name] + if p.logMode == LogFile { + if err := removeBuildLog(p.dir, logRel); err != nil { + ui.DimInfo(fmt.Sprintf("Could not remove the previous build log: %s", err)) + } } - result, err := inst.Build(ctx, projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { + result, err := inst.Build(ctx, p.projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { + if p.logMode == LogFile { + streamed.add(line) + return + } ui.StreamLog(line) - }) + }, ui.Status) if err != nil { ui.Errorf("Build request failed: %s", err) - return fail(err, failureReason(err, "internal")) + r := failedDoc(doc, err, failureReason(err, "internal")) + r.Duration = time.Since(compileStart) + return r + } + + var logPath string + if p.logMode == LogFile && result.LogURL != "" { + if err := saveBuildLog(ctx, inst, p.dir, logRel, result.LogURL); err == nil { + logPath = logRel + } else if ctx.Err() == nil { + ui.DimInfo(fmt.Sprintf("Could not save the build log: %s", err)) + } + } + fail := func(err error, reason string) docResult { + r := failedDoc(doc, err, reason) + r.BuildID = result.BuildID + r.Log = logPath + r.Diagnostics = result.Diagnostics + r.Truncated = result.Truncated + r.Duration = time.Since(compileStart) + return r } if result.Status == "success" && result.PdfURL != "" { compileElapsed := time.Since(compileStart) ui.StepSuccess(fmt.Sprintf("Build complete (%.1fs)", compileElapsed.Seconds())) - outputPath := filepath.Join(dir, doc.Output) + outputPath := filepath.Join(p.dir, doc.Output) if err := os.MkdirAll(filepath.Dir(outputPath), 0o750); err != nil { return fail(fmt.Errorf("cannot create output directory: %w", err), "internal") } @@ -952,11 +985,9 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, } sp := ui.Spin(fmt.Sprintf("Downloading %s...", doc.Output)) - if err := inst.DownloadPDF(ctx, projectID, result.BuildID, outputPath); err != nil { + if err := inst.DownloadPDF(ctx, p.projectID, result.BuildID, outputPath); err != nil { sp.Fail(fmt.Sprintf("Download failed: %s", err)) - r := fail(err, failureReason(err, "internal")) - r.BuildID = result.BuildID - return r + return fail(err, failureReason(err, "internal")) } info, _ := os.Stat(outputPath) @@ -968,23 +999,27 @@ func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, } sp.Stop(sizeInfo) return docResult{ - Name: doc.Name, - Main: docMainPath(doc), - Output: doc.Output, - Success: true, - BuildID: result.BuildID, - Duration: time.Since(compileStart), + Name: doc.Name, + Main: docMainPath(doc), + Output: doc.Output, + Success: true, + BuildID: result.BuildID, + Log: logPath, + Diagnostics: result.Diagnostics, + Truncated: result.Truncated, + Duration: time.Since(compileStart), } } + if p.logMode == LogFile { + printLogTail(ui, p.dir, logPath, streamed) + } msg := result.Message if msg == "" { msg = "unknown error" } ui.Errorf("Build failed: %s", msg) - r := fail(buildFailureError(result, fmt.Errorf("build failed: %s", msg)), buildFailureReason(result)) - r.BuildID = result.BuildID - return r + return fail(buildFailureError(result, fmt.Errorf("build failed: %s", msg)), buildFailureReason(result)) } // docNames returns a comma-separated list of document names. diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 5880157..0214ee4 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -924,7 +924,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) written, err := os.ReadFile(filepath.Join(dir, "paper.pdf")) @@ -1014,7 +1014,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) assert.Equal(t, "prj_auto123", createdProjectID) @@ -1057,7 +1057,7 @@ documents: t.Setenv("TX_API_URL", apiSrv.URL) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, 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) @@ -1078,7 +1078,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(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") }) @@ -1092,7 +1092,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { ui := cli.NewUIWithOptions(buf, true, &enterReader{first: "y\n"}) // Build will init then fail on auth — that's fine, we just check init happened. - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) configData, readErr := os.ReadFile(filepath.Join(dir, ".texops.yaml")) require.NoError(t, readErr, buf.String()) @@ -1111,7 +1111,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { buf := &bytes.Buffer{} ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("y\n")) - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err, buf.String()) assert.Equal(t, cli.ExitConfig, cli.AsExitError(err).Code, buf.String()) @@ -1128,7 +1128,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { in := strings.NewReader("n\n") ui := cli.NewUIWithOptions(buf, true, in) - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") @@ -1220,7 +1220,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, true, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, true, false, cli.LogStdout, ui) require.NoError(t, err) require.NotNil(t, receivedBuildOptions, "build_options should be sent in request") assert.Equal(t, "true", receivedBuildOptions["no_cache"]) @@ -1304,7 +1304,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, 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") @@ -1412,7 +1412,7 @@ documents: )) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) output := buf.String() @@ -1431,7 +1431,7 @@ documents: dir, requests := startSetup(t, sse(`event: done`+"\n"+`data: {"status":"success"}`)) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) assert.NotContains(t, buf.String(), "Sandbox ready") @@ -1443,7 +1443,7 @@ documents: dir, requests := startSetup(t, nil) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) assert.NotContains(t, buf.String(), "Sandbox ready") @@ -1459,7 +1459,7 @@ documents: )) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err) assert.Contains(t, buf.String(), "Failed to start sandbox: failed to set up TeX Live 2023") @@ -1605,7 +1605,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) // Should get exactly one session and one sync for same-version docs @@ -1640,7 +1640,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) // Should get two sessions (one per version) and two syncs @@ -1665,7 +1665,7 @@ documents: s := multiDocSetup(t, config, "") ui, _ := testUI() - err := cli.RunBuild(t.Context(), s.dir, []string{"paper"}, false, false, ui) + err := cli.RunBuild(t.Context(), s.dir, []string{"paper"}, false, false, cli.LogStdout, ui) require.NoError(t, err) // Only one document should be built @@ -1684,7 +1684,7 @@ documents: os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte(config), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, []string{"nonexistent"}, false, false, ui) + err := cli.RunBuild(t.Context(), dir, []string{"nonexistent"}, false, false, cli.LogStdout, ui) require.Error(t, err) assert.Contains(t, err.Error(), "unknown document") assert.Contains(t, err.Error(), "nonexistent") @@ -1702,7 +1702,7 @@ documents: s := multiDocSetup(t, config, "slides.tex") // slides will fail ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err) assert.Contains(t, err.Error(), "one or more documents failed to build") @@ -1716,7 +1716,7 @@ documents: output := buf.String() assert.Contains(t, output, "1 succeeded, 1 failed") assert.Contains(t, output, "paper => paper.pdf") - assert.Contains(t, output, "slides !! FAILED") + assert.Contains(t, output, "slides: FAILED (internal)\n") }) t.Run("single document prints summary", func(t *testing.T) { @@ -1728,7 +1728,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) output := buf.String() @@ -1751,7 +1751,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(t.Context(), s.dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) assert.Len(t, *s.buildRequests, 2) @@ -1851,7 +1851,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) assert.Equal(t, "xelatex", receivedCompiler, "compiler from config should be sent in build request") @@ -1945,7 +1945,7 @@ documents: os.WriteFile(filepath.Join(dir, "slides.tex"), []byte(`\documentclass{beamer}\begin{document}Slides\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) require.Len(t, receivedCompilers, 2) @@ -2032,7 +2032,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.NoError(t, err) assert.Equal(t, "pdflatex", receivedCompiler, "default compiler should be pdflatex") diff --git a/internal/cli/diagnostics_test.go b/internal/cli/diagnostics_test.go new file mode 100644 index 0000000..6d4a8ea --- /dev/null +++ b/internal/cli/diagnostics_test.go @@ -0,0 +1,612 @@ +package cli_test + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +const summaryLine = `^Build complete: \d+ succeeded, \d+ failed \(\d+\.\ds\)\n` + +func latexErrorDone() map[string]any { + return map[string]any{ + "status": "error", + "reason": "latex_error", + "message": "1 LaTeX error in sec/intro.tex", + "build_id": "bld_1", + "log_url": "/projects/prj_test/builds/bld_1/log", + "diagnostics": []map[string]any{ + {"severity": "error", "file": "sec/intro.tex", "line": 3, "message": "Undefined control sequence.", "context": "\\badmacro"}, + {"severity": "warning", "kind": "undefined_citation", "file": "sec/intro.tex", "line": 1, "message": "Citation `missing' undefined"}, + {"severity": "warning", "kind": "rerun", "message": "Label(s) may have changed. Rerun to get cross-references right."}, + }, + "truncated": false, + } +} + +func successWithWarningDone() map[string]any { + return map[string]any{ + "status": "success", + "pdfUrl": "/projects/prj_test/builds/bld_ok/output", + "build_id": "bld_ok", + "log_url": "/projects/prj_test/builds/bld_ok/log", + "diagnostics": []map[string]any{ + {"severity": "warning", "kind": "undefined_reference", "file": "paper.tex", "line": 7, "message": "Reference `fig:x' undefined"}, + }, + "truncated": false, + } +} + +func numberedLog(n int) string { + var b strings.Builder + for i := 1; i <= n; i++ { + fmt.Fprintf(&b, "log line %d\n", i) + } + return b.String() +} + +func successDone(buildID string) map[string]any { + return map[string]any{ + "status": "success", + "pdfUrl": "/projects/prj_test/builds/" + buildID + "/output", + "build_id": buildID, + "log_url": "/projects/prj_test/builds/" + buildID + "/log", + } +} + +func twoDocConfig(paper, slides string) string { + return fmt.Sprintf("project_key: \"k7Gx9mR2pL4wN8qY5vBt3a\"\ntexlive: \"2025\"\ndocuments:\n - name: %q\n main: paper.tex\n - name: %q\n main: slides.tex\n", paper, slides) +} + +var logPathPattern = regexp.MustCompile(`\(log: (\.texops/logs/[^)]+)\)`) + +func summaryLogPaths(t *testing.T, r txRun) []string { + t.Helper() + matches := logPathPattern.FindAllStringSubmatch(r.stdout, -1) + paths := make([]string, 0, len(matches)) + for _, m := range matches { + paths = append(paths, m[1]) + } + require.Len(t, paths, 2, r) + return paths +} + +func readFile(t *testing.T, path string, r txRun) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err, r) + return string(data) +} + +func TestBuildDiagnostics(t *testing.T) { + t.Run("failed build prints a diagnostics block", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Regexp(t, summaryLine+regexp.QuoteMeta( + " paper: FAILED, 1 error, 2 warnings\n"+ + " sec/intro.tex:3: error: Undefined control sequence.\n"+ + " sec/intro.tex:1: warning: Citation `missing' undefined\n"+ + " warning: Label(s) may have changed. Rerun to get cross-references right.\n")+`$`, + r.stdout, r) + assert.Contains(t, r.stderr, "Build failed: 1 LaTeX error in sec/intro.tex\n", r) + }) + + t.Run("successful build with warnings shows them", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = successWithWarningDone() + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Regexp(t, summaryLine+regexp.QuoteMeta( + " paper => paper.pdf, 0 errors, 1 warning\n"+ + " paper.tex:7: warning: Reference `fig:x' undefined\n")+`$`, + r.stdout, r) + }) + + t.Run("truncated diagnostics are noted", func(t *testing.T) { + f := newFakeTexOps(t) + done := latexErrorDone() + done["truncated"] = true + f.done["paper.tex"] = done + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.True(t, strings.HasSuffix(r.stdout, " (more diagnostics were found; see the log)\n"), r) + }) + + t.Run("diagnostic without a line keeps the file", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = map[string]any{ + "status": "error", + "reason": "latex_error", + "build_id": "bld_1", + "diagnostics": []map[string]any{ + {"severity": "error", "file": "paper.tex", "message": "Emergency stop.\nl.12 \\end"}, + }, + } + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stdout, " paper: FAILED, 1 error, 0 warnings\n paper.tex: error: Emergency stop. l.12 \\end\n", r) + }) + + t.Run("diagnostic without a file renders severity and message", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = map[string]any{ + "status": "error", + "reason": "latex_error", + "build_id": "bld_1", + "diagnostics": []map[string]any{ + {"severity": "error", "line": 12, "message": "Missing $ inserted."}, + }, + } + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stdout, " paper: FAILED, 1 error, 0 warnings\n error: Missing $ inserted.\n", r) + }) + + t.Run("service failure without diagnostics shows the reason", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("timeout", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitFailure, r.code, r) + assert.Regexp(t, summaryLine+regexp.QuoteMeta(" paper: FAILED (timeout)\n")+`$`, r.stdout, r) + }) + + t.Run("old server without diagnostics still reports the failure", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("", "bld_1") + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Regexp(t, summaryLine+regexp.QuoteMeta(" paper: FAILED (latex_error)\n")+`$`, r.stdout, r) + assert.Contains(t, r.stderr, "Last 1 line of build output:\n latexmk output for paper.tex\nBuild failed: build failed with \n", r) + assert.NoDirExists(t, ".texops", r) + }) +} + +func TestBuildLogMode(t *testing.T) { + t.Run("stdout mode streams the log and writes no file", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(3) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stderr, " latexmk output for paper.tex\n", r) + assert.NoDirExists(t, ".texops", r) + assert.NotContains(t, r.requests, "GET /projects/prj_test/builds/bld_1/log", r) + assert.NotContains(t, r.stdout, "(log:", r) + }) + + t.Run("file mode saves the log and .texops/.gitignore and streams nothing", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = successWithWarningDone() + f.logs["bld_ok"] = numberedLog(3) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log=file") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, numberedLog(3), readFile(t, filepath.Join(".texops", "logs", "paper.log"), r), r) + assert.Equal(t, "*\n", readFile(t, filepath.Join(".texops", ".gitignore"), r), r) + assert.NotContains(t, r.stderr, "latexmk output", r) + assert.NotContains(t, r.stderr, "log line", r) + assert.Contains(t, r.stdout, " paper => paper.pdf, 0 errors, 1 warning (log: .texops/logs/paper.log)\n", r) + }) + + t.Run("file mode prints the last 20 lines of the log on failure", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(25) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + var want strings.Builder + want.WriteString("Last 20 lines of .texops/logs/paper.log:\n") + for i := 6; i <= 25; i++ { + fmt.Fprintf(&want, " log line %d\n", i) + } + want.WriteString("Build failed: 1 LaTeX error in sec/intro.tex\n") + assert.Contains(t, r.stderr, want.String(), r) + assert.NotContains(t, r.stderr, "log line 5\n", r) + assert.Contains(t, r.stdout, " paper: FAILED, 1 error, 2 warnings (log: .texops/logs/paper.log)\n", r) + }) + + t.Run("file mode overwrites the log and keeps an existing .texops/.gitignore", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(2) + dir := projectDir(t, projectConfig) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".texops", "logs"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", ".gitignore"), []byte("logs/\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", "logs", "paper.log"), []byte(numberedLog(40)), 0o600)) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Equal(t, numberedLog(2), readFile(t, filepath.Join(dir, ".texops", "logs", "paper.log"), r), r) + assert.Equal(t, "logs/\n", readFile(t, filepath.Join(dir, ".texops", ".gitignore"), r), r) + }) + + t.Run("file mode keeps the build result when the log is gone", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stderr, "Could not save the build log: log download failed (410): build not found or expired\n", r) + assert.Contains(t, r.stderr, "Last 1 line of build output:\n latexmk output for paper.tex\n", r) + assert.NoFileExists(t, filepath.Join(".texops", "logs", "paper.log"), r) + assert.Contains(t, r.stdout, " paper: FAILED, 1 error, 2 warnings\n", r) + }) + + t.Run("file mode does not request a log URL outside the build", func(t *testing.T) { + f := newFakeTexOps(t) + done := latexErrorDone() + done["log_url"] = "https://example.com/projects/prj_test/builds/bld_1/log" + f.done["paper.tex"] = done + f.logs["bld_1"] = numberedLog(2) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stderr, `Could not save the build log: invalid log URL "https://example.com/projects/prj_test/builds/bld_1/log"`, r) + assert.NotContains(t, r.requests, "GET /projects/prj_test/builds/bld_1/log", r) + }) + + t.Run("file mode names the log after a sanitized document name", func(t *testing.T) { + f := newFakeTexOps(t) + f.logs["bld_ok"] = numberedLog(1) + f.done["paper.tex"] = map[string]any{"status": "success", "pdfUrl": "/projects/prj_test/builds/bld_ok/output", "build_id": "bld_ok", "log_url": "/projects/prj_test/builds/bld_ok/log"} + projectDir(t, "project_key: \"k7Gx9mR2pL4wN8qY5vBt3a\"\ntexlive: \"2025\"\ndocuments:\n - name: \"../my paper\"\n main: paper.tex\n") + + r := runTx(t, f, "build", "--log", "file") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, numberedLog(1), readFile(t, filepath.Join(".texops", "logs", ".._my_paper.log"), r), r) + assert.Contains(t, r.stdout, "(log: .texops/logs/.._my_paper.log)\n", r) + }) + + t.Run("file mode removes the previous log when the new one is gone", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + dir := projectDir(t, projectConfig) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".texops", "logs"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", "logs", "paper.log"), []byte(numberedLog(5)), 0o600)) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.requests, "GET /projects/prj_test/builds/bld_1/log", r) + assert.NoFileExists(t, filepath.Join(dir, ".texops", "logs", "paper.log"), r) + assert.NotContains(t, r.stdout, "(log:", r) + }) + + t.Run("file mode removes the previous log when the server sends no log URL", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = failedBuild("latex_error", "bld_1") + dir := projectDir(t, projectConfig) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".texops", "logs"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", "logs", "paper.log"), []byte(numberedLog(5)), 0o600)) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.NoFileExists(t, filepath.Join(dir, ".texops", "logs", "paper.log"), r) + assert.NotContains(t, r.stderr, "log line", r) + }) + + t.Run("file mode prints the last 20 whole lines of a log larger than 64 KiB", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(10000) + require.Greater(t, len(f.logs["bld_1"]), 64*1024) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + var want strings.Builder + want.WriteString("Last 20 lines of .texops/logs/paper.log:\n") + for i := 9981; i <= 10000; i++ { + fmt.Fprintf(&want, " log line %d\n", i) + } + want.WriteString("Build failed: ") + assert.Contains(t, r.stderr, want.String(), r) + }) + + t.Run("file mode falls back to the streamed output when the log is empty", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = "" + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stderr, "Last 1 line of build output:\n latexmk output for paper.tex\n", r) + assert.Contains(t, r.stdout, "(log: .texops/logs/paper.log)\n", r) + }) + + t.Run("file mode still shows that the build is queued", func(t *testing.T) { + f := newFakeTexOps(t) + f.queued = true + f.done["paper.tex"] = successDone("bld_ok") + f.logs["bld_ok"] = numberedLog(1) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stderr, "build queued, waiting for previous build to finish\n", r) + assert.NotContains(t, r.stderr, "latexmk output", r) + }) + + t.Run("file mode names the log of a document made of dots document.log", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = successDone("bld_ok") + f.logs["bld_ok"] = numberedLog(1) + projectDir(t, "project_key: \"k7Gx9mR2pL4wN8qY5vBt3a\"\ntexlive: \"2025\"\ndocuments:\n - name: \"..\"\n main: paper.tex\n") + + r := runTx(t, f, "build", "--log", "file") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, numberedLog(1), readFile(t, filepath.Join(".texops", "logs", "document.log"), r), r) + assert.Contains(t, r.stdout, "(log: .texops/logs/document.log)\n", r) + }) + + t.Run("file mode keeps separate logs for names that clash after sanitizing", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = successDone("bld_1") + f.done["slides.tex"] = successDone("bld_2") + f.logs["bld_1"] = "first document\n" + f.logs["bld_2"] = "second document\n" + projectDir(t, twoDocConfig("my paper", "my_paper")) + + r := runTx(t, f, "build", "--log", "file") + + require.Equal(t, cli.ExitOK, r.code, r) + paths := summaryLogPaths(t, r) + assert.Regexp(t, `^\.texops/logs/my_paper-[0-9a-f]{8}\.log$`, paths[0], r) + assert.Regexp(t, `^\.texops/logs/my_paper-[0-9a-f]{8}\.log$`, paths[1], r) + assert.NotEqual(t, paths[0], paths[1], r) + assert.Equal(t, "first document\n", readFile(t, paths[0], r), r) + assert.Equal(t, "second document\n", readFile(t, paths[1], r), r) + }) + + t.Run("file mode keeps separate logs for names that differ only in case", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = successDone("bld_1") + f.done["slides.tex"] = successDone("bld_2") + f.logs["bld_1"] = "first document\n" + f.logs["bld_2"] = "second document\n" + projectDir(t, twoDocConfig("Paper", "paper")) + + r := runTx(t, f, "build", "--log", "file") + + require.Equal(t, cli.ExitOK, r.code, r) + paths := summaryLogPaths(t, r) + assert.Regexp(t, `^\.texops/logs/Paper-[0-9a-f]{8}\.log$`, paths[0], r) + assert.Regexp(t, `^\.texops/logs/paper-[0-9a-f]{8}\.log$`, paths[1], r) + assert.NotEqual(t, strings.ToLower(paths[0]), strings.ToLower(paths[1]), r) + assert.Equal(t, "first document\n", readFile(t, paths[0], r), r) + assert.Equal(t, "second document\n", readFile(t, paths[1], r), r) + }) + + t.Run("invalid --log value exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--log", "stderr") + + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "Invalid value `stderr' for option `--log'. Allowed values are: stdout or file\n", r.stderr, r) + assert.Empty(t, r.requests, r) + }) + + t.Run("a coding agent switches the default to file", func(t *testing.T) { + t.Setenv("CLAUDECODE", "1") + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(3) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Equal(t, numberedLog(3), readFile(t, filepath.Join(".texops", "logs", "paper.log"), r), r) + assert.NotContains(t, r.stderr, "latexmk output", r) + assert.Contains(t, r.stderr, "Last 3 lines of .texops/logs/paper.log:\n", r) + }) + + t.Run("--log=stdout overrides agent detection", func(t *testing.T) { + t.Setenv("CLAUDECODE", "1") + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(3) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--log", "stdout") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stderr, " latexmk output for paper.tex\n", r) + assert.NoDirExists(t, ".texops", r) + }) + + t.Run("TX_AGENT=none keeps the stdout default", func(t *testing.T) { + t.Setenv("CLAUDECODE", "1") + t.Setenv("TX_AGENT", "none") + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(3) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.Contains(t, r.stderr, " latexmk output for paper.tex\n", r) + assert.NoDirExists(t, ".texops", r) + }) + + t.Run("collected files skip .texops", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(3) + projectDir(t, projectConfig) + + first := runTx(t, f, "build", "paper", "--log", "file") + require.Equal(t, cli.ExitBuildFailed, first.code, first) + require.FileExists(t, filepath.Join(".texops", "logs", "paper.log"), first) + + r := runTx(t, f, "build", "paper", "--log", "file") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.NotContains(t, r.requests, ".texops", r) + }) +} + +func TestBuildDiagnosticsJSON(t *testing.T) { + t.Run("--json defaults to file mode and fills errors, warnings and log", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.done["slides.tex"] = map[string]any{ + "status": "success", + "pdfUrl": "/projects/prj_test/builds/bld_2/output", + "build_id": "bld_2", + "log_url": "/projects/prj_test/builds/bld_2/log", + "diagnostics": []map[string]any{ + {"severity": "warning", "kind": "duplicate_label", "file": "slides.tex", "line": 9, "message": "Label `a' multiply defined"}, + }, + "truncated": true, + } + f.logs["bld_1"] = numberedLog(3) + f.logs["bld_2"] = numberedLog(1) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "--json") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + assert.JSONEq(t, `{ + "ok": false, + "documents": [ + { + "name": "paper", "main": "paper.tex", "status": "failed", "reason": "latex_error", + "output": null, "log": ".texops/logs/paper.log", "build_id": "bld_1", + "errors": [ + {"severity": "error", "file": "sec/intro.tex", "line": 3, "message": "Undefined control sequence.", "context": "\\badmacro"} + ], + "warnings": [ + {"severity": "warning", "kind": "undefined_citation", "file": "sec/intro.tex", "line": 1, "message": "Citation `+"`"+`missing' undefined"}, + {"severity": "warning", "kind": "rerun", "message": "Label(s) may have changed. Rerun to get cross-references right."} + ], + "truncated": false + }, + { + "name": "slides", "main": "slides.tex", "status": "succeeded", "reason": null, + "output": "slides.pdf", "log": ".texops/logs/slides.log", "build_id": "bld_2", + "errors": [], + "warnings": [ + {"severity": "warning", "kind": "duplicate_label", "file": "slides.tex", "line": 9, "message": "Label `+"`"+`a' multiply defined"} + ], + "truncated": true + } + ] + }`, stdoutWithoutDurations(t, r), r) + assert.Equal(t, numberedLog(3), readFile(t, filepath.Join(".texops", "logs", "paper.log"), r), r) + assert.NotContains(t, r.stderr, "latexmk output", r) + assert.Contains(t, r.stderr, " paper: FAILED, 1 error, 2 warnings (log: .texops/logs/paper.log)\n sec/intro.tex:3: error: Undefined control sequence.\n", r) + }) + + t.Run("--json keeps diagnostics and the log when the PDF download fails", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = successWithWarningDone() + f.logs["bld_ok"] = numberedLog(2) + f.outputStatus = http.StatusInternalServerError + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--json") + + require.Equal(t, cli.ExitFailure, r.code, r) + var doc struct { + Documents []struct { + Status string `json:"status"` + Reason string `json:"reason"` + Log *string `json:"log"` + BuildID string `json:"build_id"` + Errors []map[string]any `json:"errors"` + Warnings []map[string]any `json:"warnings"` + } `json:"documents"` + } + decodeStdout(t, r, &doc) + require.Len(t, doc.Documents, 1, r) + d := doc.Documents[0] + assert.Equal(t, "failed", d.Status, r) + assert.Equal(t, "internal", d.Reason, r) + assert.Equal(t, "bld_ok", d.BuildID, r) + require.NotNil(t, d.Log, r) + assert.Equal(t, ".texops/logs/paper.log", *d.Log, r) + assert.Empty(t, d.Errors, r) + require.Len(t, d.Warnings, 1, r) + assert.Equal(t, "Reference `fig:x' undefined", d.Warnings[0]["message"], r) + assert.Equal(t, numberedLog(2), readFile(t, filepath.Join(".texops", "logs", "paper.log"), r), r) + }) + + t.Run("--json with --log=stdout writes no log file", func(t *testing.T) { + f := newFakeTexOps(t) + f.done["paper.tex"] = latexErrorDone() + f.logs["bld_1"] = numberedLog(3) + projectDir(t, projectConfig) + + r := runTx(t, f, "build", "paper", "--json", "--log=stdout") + + require.Equal(t, cli.ExitBuildFailed, r.code, r) + var doc struct { + Documents []struct { + Log *string `json:"log"` + Errors []any `json:"errors"` + } `json:"documents"` + } + decodeStdout(t, r, &doc) + require.Len(t, doc.Documents, 1, r) + assert.Nil(t, doc.Documents[0].Log, r) + assert.Len(t, doc.Documents[0].Errors, 1, r) + assert.Contains(t, r.stderr, " latexmk output for paper.tex\n", r) + assert.NoDirExists(t, ".texops", r) + }) +} diff --git a/internal/cli/hasher.go b/internal/cli/hasher.go index b81ece3..4e66415 100644 --- a/internal/cli/hasher.go +++ b/internal/cli/hasher.go @@ -149,7 +149,7 @@ func FormatSize(bytes int64) string { } func loadGitignore(dir string) *ignore.GitIgnore { - patterns := []string{".git", ".texops.yaml", ".txignore"} + patterns := []string{".git", ".texops", ".texops.yaml", ".txignore"} gitignorePath := filepath.Join(dir, ".gitignore") data, err := os.ReadFile(gitignorePath) diff --git a/internal/cli/hasher_test.go b/internal/cli/hasher_test.go index 8080a0f..b45becd 100644 --- a/internal/cli/hasher_test.go +++ b/internal/cli/hasher_test.go @@ -69,6 +69,24 @@ func TestCollectFiles(t *testing.T) { assert.Equal(t, int64(len(refsContent)), files[1].Size) }) + t.Run("skips the .texops directory", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte("texlive: \"2025\"\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".texops", "logs"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", ".gitignore"), []byte("*\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", "logs", "paper.log"), []byte("log"), 0o600)) + + files, err := cli.CollectFiles(t.Context(), dir) + + require.NoError(t, err) + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + assert.Equal(t, []string{"paper.tex"}, paths) + }) + t.Run("respects .gitignore patterns", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.aux\nbuild/\n"), 0o600)) diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go new file mode 100644 index 0000000..19ea599 --- /dev/null +++ b/internal/cli/main_test.go @@ -0,0 +1,13 @@ +package cli_test + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + for _, name := range agentEnvVars { + os.Unsetenv(name) + } + os.Exit(m.Run()) +} diff --git a/internal/cli/output.go b/internal/cli/output.go index 9c5dafd..5a05c29 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -101,6 +101,11 @@ type buildDocJSON struct { func renderBuild(ui *UI, results []docResult, elapsed time.Duration) error { if ui.JSON() { + for _, r := range results { + for _, line := range docSummaryLines(r) { + ui.Log(line) + } + } return ui.WriteJSON(buildResultJSON(results, elapsed)) } succeeded, failed := 0, 0 @@ -114,15 +119,85 @@ func renderBuild(ui *UI, results []docResult, elapsed time.Duration) error { ui.Gap() ui.Result(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) for _, r := range results { - if r.Success { - ui.Log(fmt.Sprintf(" %s => %s", r.Name, r.Output)) - } else { - ui.Log(fmt.Sprintf(" %s !! FAILED", r.Name)) + for _, line := range docSummaryLines(r) { + ui.Log(line) } } return nil } +// docSummaryLines is a document's line in the build summary followed by one +// indented line per diagnostic. +func docSummaryLines(r docResult) []string { + var head string + switch { + case r.Success: + head = fmt.Sprintf(" %s => %s", r.Name, filepath.ToSlash(r.Output)) + if len(r.Diagnostics) > 0 { + head += ", " + diagnosticCounts(r.Diagnostics) + } + case len(r.Diagnostics) > 0: + head = fmt.Sprintf(" %s: FAILED, %s", r.Name, diagnosticCounts(r.Diagnostics)) + case r.Reason != "": + head = fmt.Sprintf(" %s: FAILED (%s)", r.Name, r.Reason) + default: + head = fmt.Sprintf(" %s: FAILED", r.Name) + } + if r.Log != "" { + head += fmt.Sprintf(" (log: %s)", filepath.ToSlash(r.Log)) + } + lines := []string{head} + for _, d := range r.Diagnostics { + lines = append(lines, " "+formatDiagnostic(d)) + } + if r.Truncated { + lines = append(lines, " (more diagnostics were found; see the log)") + } + return lines +} + +func diagnosticCounts(diags []Diagnostic) string { + errs := 0 + for _, d := range diags { + if d.Severity == "error" { + errs++ + } + } + return plural(errs, "error") + ", " + plural(len(diags)-errs, "warning") +} + +func plural(n int, noun string) string { + if n == 1 { + return "1 " + noun + } + return fmt.Sprintf("%d %ss", n, noun) +} + +// formatDiagnostic renders a diagnostic as "file:line: severity: message", +// leaving out the parts the server did not send. +func formatDiagnostic(d Diagnostic) string { + var b strings.Builder + switch { + case d.File != "" && d.Line > 0: + fmt.Fprintf(&b, "%s:%d: ", d.File, d.Line) + case d.File != "": + b.WriteString(d.File + ": ") + } + if d.Severity != "" { + b.WriteString(d.Severity + ": ") + } + b.WriteString(d.Message) + return strings.Map(func(r rune) rune { + switch { + case r == '\n' || r == '\r' || r == '\t': + return ' ' + case r < 0x20 || r == 0x7f: + return -1 + } + return r + }, b.String()) +} + func buildResultJSON(results []docResult, elapsed time.Duration) buildJSON { out := buildJSON{OK: true, DurationMS: elapsed.Milliseconds(), Documents: make([]buildDocJSON, 0, len(results))} for _, r := range results { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index c85f71f..5ca7838 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -33,6 +33,9 @@ type fakeTexOps struct { createdToken map[string]any distributions string done map[string]map[string]any + logs map[string]string + queued bool + outputStatus int mu sync.Mutex exchange []string @@ -46,7 +49,7 @@ type requestUA struct { func newFakeTexOps(t *testing.T) *fakeTexOps { t.Helper() - f := &fakeTexOps{done: map[string]map[string]any{}} + f := &fakeTexOps{done: map[string]map[string]any{}, logs: map[string]string{}} f.srv = httptest.NewServer(http.HandlerFunc(f.serve)) t.Cleanup(f.srv.Close) t.Setenv("TX_API_URL", f.srv.URL) @@ -158,9 +161,27 @@ func (f *fakeTexOps) route(w http.ResponseWriter, r *http.Request, body []byte) } doneData, _ := json.Marshal(done) w.Header().Set("Content-Type", "text/event-stream") + if f.queued { + fmt.Fprint(w, "event: queued\ndata: {\"message\":\"build queued, waiting for previous build to finish\"}\n\n") + } fmt.Fprintf(w, "event: log\ndata: {\"message\":\"latexmk output for %s\"}\n\nevent: done\ndata: %s\n\n", req.Main, doneData) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/projects/prj_test/builds/") && strings.HasSuffix(r.URL.Path, "/output"): + if f.outputStatus != 0 { + w.WriteHeader(f.outputStatus) + w.Write([]byte(`{"error":"storage unavailable"}`)) + return + } w.Write([]byte("%PDF-1.4 test")) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/projects/prj_test/builds/") && strings.HasSuffix(r.URL.Path, "/log"): + buildID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/projects/prj_test/builds/"), "/log") + log, ok := f.logs[buildID] + if !ok { + w.WriteHeader(http.StatusGone) + w.Write([]byte(`{"error":"build not found or expired"}`)) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(log)) default: w.WriteHeader(http.StatusNotFound) } @@ -601,7 +622,7 @@ func TestRunOutputStreams(t *testing.T) { r := runTx(t, f, "build", "paper") require.Equal(t, cli.ExitBuildFailed, r.code, r) - assert.Contains(t, r.stdout, " paper !! FAILED\n", r) + assert.Contains(t, r.stdout, " paper: FAILED (latex_error)\n", r) assert.Equal(t, 1, strings.Count(r.stderr, "one or more documents failed to build"), r) assert.Equal(t, 1, strings.Count(r.stderr, "build failed with latex_error"), r) }) @@ -658,7 +679,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, ui) + err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, cli.LogStdout, ui) require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) assert.Equal(t, cli.KindBuildFailed, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) @@ -670,7 +691,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, ui) + err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, cli.LogStdout, ui) require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) assert.Equal(t, cli.KindTimeout, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) @@ -684,7 +705,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err, buf.String()) assert.Equal(t, cli.KindNetwork, cli.AsExitError(err).Kind, buf.String()) @@ -697,7 +718,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) assert.Equal(t, cli.KindConfig, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) diff --git a/internal/cli/watcher.go b/internal/cli/watcher.go index 3b51b14..17087d8 100644 --- a/internal/cli/watcher.go +++ b/internal/cli/watcher.go @@ -366,4 +366,12 @@ func printBuildResult(ui *UI, results []docResult) { if len(succeeded) > 0 { ui.Status(fmt.Sprintf("[%s] Built %s", now, strings.Join(succeeded, ", "))) } + for _, r := range results { + if r.Success && len(r.Diagnostics) == 0 { + continue + } + for _, line := range docSummaryLines(r) { + ui.DimInfo(line) + } + } } diff --git a/internal/cli/watcher_test.go b/internal/cli/watcher_test.go index 59a7144..70432a2 100644 --- a/internal/cli/watcher_test.go +++ b/internal/cli/watcher_test.go @@ -114,6 +114,31 @@ func TestFileWatcher(t *testing.T) { assert.Equal(t, "main.tex", ev) }) + t.Run("ignores saved build logs under .texops", func(t *testing.T) { + dir := t.TempDir() + + w, err := cli.NewFileWatcher(dir, nil) + require.NoError(t, err) + defer w.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go w.Run(ctx) + + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".texops", "logs"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", ".gitignore"), []byte("*\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", "logs", "paper.log"), []byte("log"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".texops", "logs", "paper.log"), []byte("log again"), 0o600)) + + noEvent(t, w.Events, 500*time.Millisecond) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tex"), []byte("content"), 0o600)) + + ev, ok := waitForEvent(t, w.Events, 2*time.Second) + assert.True(t, ok, "expected event for main.tex") + assert.Equal(t, "main.tex", ev) + }) + t.Run("watches new subdirectories", func(t *testing.T) { dir := t.TempDir() @@ -292,4 +317,79 @@ func TestWatchAndBuild(t *testing.T) { cancel() <-done }) + + t.Run("live rebuild prints the diagnostics of failed documents", func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + ui := cli.NewUIWithOptions(&out, false, nil) + + buildStarted := make(chan struct{}, 5) + build := func(ctx context.Context) ([]cli.DocResult, error) { + buildStarted <- struct{}{} + return []cli.DocResult{ + {Name: "paper", Output: "paper.pdf", Log: ".texops/logs/paper.log", Diagnostics: []cli.Diagnostic{ + {Severity: "error", File: "paper.tex", Line: 3, Message: "Undefined control sequence."}, + }}, + {Name: "slides", Output: "slides.pdf", Success: true}, + }, nil + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- cli.WatchAndBuildWith(ctx, dir, ui, build) + }() + time.Sleep(100 * time.Millisecond) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.tex"), []byte("v1"), 0o600)) + + select { + case <-buildStarted: + case <-time.After(3 * time.Second): + t.Fatal("build did not start") + } + cancel() + <-done + + output := out.String() + assert.Contains(t, output, " paper: FAILED, 1 error, 0 warnings (log: .texops/logs/paper.log)\n paper.tex:3: error: Undefined control sequence.\n", output) + assert.NotContains(t, output, "slides =>", output) + }) + + t.Run("live rebuild prints the warnings of successful documents", func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + ui := cli.NewUIWithOptions(&out, false, nil) + + buildStarted := make(chan struct{}, 5) + build := func(ctx context.Context) ([]cli.DocResult, error) { + buildStarted <- struct{}{} + return []cli.DocResult{ + {Name: "paper", Output: "paper.pdf", Success: true, Diagnostics: []cli.Diagnostic{ + {Severity: "warning", Kind: "undefined_reference", File: "paper.tex", Line: 7, Message: "Reference `fig:x' undefined"}, + }}, + }, nil + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- cli.WatchAndBuildWith(ctx, dir, ui, build) + }() + time.Sleep(100 * time.Millisecond) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.tex"), []byte("v1"), 0o600)) + + select { + case <-buildStarted: + case <-time.After(3 * time.Second): + t.Fatal("build did not start") + } + cancel() + <-done + + output := out.String() + assert.Contains(t, output, "Built paper.pdf\n", output) + assert.Contains(t, output, " paper => paper.pdf, 0 errors, 1 warning\n paper.tex:7: warning: Reference `fig:x' undefined\n", output) + }) } diff --git a/site/docs/configuration.md b/site/docs/configuration.md index 09bf5c8..9382783 100644 --- a/site/docs/configuration.md +++ b/site/docs/configuration.md @@ -80,7 +80,7 @@ When you run `tx build`, the CLI syncs your project files to the server. Only fi ### Excluded by default -`.git/`, `.texops.yaml`, and `.txignore` are always excluded from uploads. +`.git/`, `.texops/`, `.texops.yaml`, and `.txignore` are always excluded from uploads. `.texops/logs/` holds the build logs saved by `tx build --log=file`. ### `.gitignore` From 13d6d271461334c6510870dfaef114cf22e6725c Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Fri, 9 Oct 2026 20:44:31 +0100 Subject: [PATCH 07/11] Document non-interactive use in help, man page and docs --- README.md | 15 +- internal/cli/commands.go | 38 +-- internal/cli/commands_test.go | 26 +++ internal/cli/help.go | 168 ++++++++++++++ internal/cli/help_test.go | 113 +++++++++ internal/cli/run.go | 7 +- internal/cli/testdata/help-build.golden | 38 +++ internal/cli/testdata/help-root.golden | 32 +++ man/tx.1 | 295 +++++++++++++++++++++++- man/tx.1.txt | 178 ++++++++++++-- site/docs/agents.md | 106 +++++++++ site/docs/cli.md | 222 ++++++++++++++++-- site/docs/quickstart.md | 1 + site/sidebars.ts | 1 + 14 files changed, 1177 insertions(+), 63 deletions(-) create mode 100644 internal/cli/help.go create mode 100644 internal/cli/help_test.go create mode 100644 internal/cli/testdata/help-build.golden create mode 100644 internal/cli/testdata/help-root.golden create mode 100644 site/docs/agents.md diff --git a/README.md b/README.md index 98d8d4a..e29a3d5 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,17 @@ go install github.com/texops/tx/cmd/tx@latest ``` tx login [--no-browser] [--timeout ] # Authenticate with TexOps -tx init # Initialize a project in the current directory +tx init [--texlive ] [--compiler ] # Initialize a project in the current directory tx build # Build all documents tx build # Build a specific document -tx build --live # Watch for changes and rebuild automatically -tx status # Show project status +tx build --log=file # Save the LaTeX log to .texops/logs/ instead of streaming it +tx build --live # Watch for changes and rebuild (until Ctrl+C) +tx status # Show authentication status tx token create [--name "CI"] # Create an API token tx token list # List API tokens tx token delete [name] [--yes] # Delete an API token +tx --json # Print one JSON document to stdout +tx --help # Describe a command, with examples ``` ### Getting started @@ -51,6 +54,10 @@ Project settings are stored in `.texops.yaml`: The API URL defaults to `https://api.texops.dev` and can be overridden with `TX_API_URL` or `api_url` in `.texops.yaml`. +### Scripts and coding agents + +`tx` never prompts without a terminal: it uses a default or fails with a non-zero exit code. With `--json`, stdout carries exactly one JSON document, also on failure. Authenticate with `TX_API_TOKEN` (create one with `tx token create`). Under a coding agent (detected from `AI_AGENT`, `CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT` or `GEMINI_CLI`; turn off with `TX_AGENT=none`), `tx build` saves the LaTeX log to `.texops/logs/.log` instead of streaming it. See [Using tx from coding agents](https://texops.dev/docs/agents) and the [CLI reference](https://texops.dev/docs/cli). + ### Output and exit codes Results (the build summary, `tx status` fields, token values) go to stdout; progress, the build log and errors go to stderr. @@ -59,7 +66,7 @@ Results (the build summary, `tx status` fields, token values) go to stdout; prog |------|---------| | `0` | Success | | `1` | Service or unexpected failure (network, server error, build timeout) | -| `2` | Usage error (unknown or missing command, unknown flag or document, unsupported `--texlive` or `--compiler`, missing input or confirmation without a terminal) | +| `2` | Usage error (unknown or missing command, unknown flag or document, unsupported `--texlive` or `--compiler`, `--json` with `--live`, missing input or confirmation without a terminal) | | `3` | Not authenticated, or the session or token expired or was rejected | | `4` | `.texops.yaml` missing or invalid, or unsupported TeX Live version | | `5` | A document failed to compile | diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 5862afc..18ca45f 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -41,7 +41,7 @@ func openBrowser(url string) error { var PollInterval = 5 * time.Second type Options struct { - JSON bool `long:"json" description:"Print one JSON document to stdout (progress goes to stderr)"` + JSON bool `long:"json" description:"Print exactly one JSON document to stdout, also on failure; progress and errors go to stderr"` Login LoginCmd `command:"login" description:"Log in to TexOps"` Init InitCmd `command:"init" description:"Initialize a new TexOps project"` Build BuildCmd `command:"build" description:"Build the LaTeX project"` @@ -56,9 +56,9 @@ type TokenCmd struct { } type TokenCreateCmd struct { - Name string `long:"name" description:"Name for the token"` - ExpiresIn string `long:"expires-in" description:"Token expiry duration (e.g. 30d, 90d, 1y)"` - NoExpiry bool `long:"no-expiry" description:"Create token with no expiry"` + Name string `long:"name" value-name:"name" description:"Name for the token (required without a terminal)"` + ExpiresIn string `long:"expires-in" value-name:"duration" description:"Expiry as a whole number of days or years, e.g. 30d, 90d, 1y (max 10y); without a terminal, this or --no-expiry is required"` + NoExpiry bool `long:"no-expiry" description:"Create a token that never expires; cannot be combined with --expires-in"` UI *UI `no-flag:"true"` } @@ -67,33 +67,39 @@ type TokenListCmd struct { } type TokenDeleteCmd struct { - Yes bool `short:"y" long:"yes" description:"Delete without asking for confirmation"` + Yes bool `short:"y" long:"yes" description:"Delete without asking for confirmation (required without a terminal or with --json)"` UI *UI `no-flag:"true"` } +func (cmd *TokenDeleteCmd) Usage() string { + return "[delete-OPTIONS] [name]" +} + type StatusCmd struct { UI *UI `no-flag:"true"` } type LoginCmd struct { NoBrowser bool `long:"no-browser" description:"Print the login URL and code without opening a browser"` - Timeout time.Duration `long:"timeout" description:"How long to wait for authorization, as a Go duration (e.g. 2m); defaults to the login code's lifetime"` + Timeout time.Duration `long:"timeout" value-name:"duration" description:"How long to wait for the login to be approved, as a Go duration such as 2m or 90s (default: the login code's lifetime)"` UI *UI `no-flag:"true"` } type InitCmd struct { - Texlive string `long:"texlive" description:"TexLive distribution version"` - Compiler string `long:"compiler" description:"LaTeX compiler" choice:"pdflatex" choice:"xelatex" choice:"lualatex" choice:"latex" choice:"platex" choice:"uplatex"` - Main string `long:"main" default:"main.tex" description:"Main TeX file (fallback when no .tex files discovered)"` + Texlive string `long:"texlive" value-name:"version" description:"TeX Live version"` + Compiler string `long:"compiler" value-name:"name" description:"LaTeX compiler (default: pdflatex)" choice:"pdflatex" choice:"xelatex" choice:"lualatex" choice:"latex" choice:"platex" choice:"uplatex"` + Main string `long:"main" value-name:"file" default:"main.tex" description:"Main .tex file, used only when no .tex file with \\documentclass is found"` UI *UI `no-flag:"true"` } type BuildCmd struct { - Args struct{ Names []string } `positional-args:"true"` - NoCache bool `long:"no-cache" description:"Clear build cache and rebuild from scratch"` - Live bool `long:"live" description:"Watch for changes and rebuild automatically"` - Log string `long:"log" choice:"stdout" choice:"file" description:"Where the LaTeX log goes: stdout streams it, file saves it to .texops/logs/.log (default: file under a coding agent or with --json, otherwise stdout)"` - UI *UI `no-flag:"true"` + Args struct { + Names []string `positional-arg-name:"name" description:"Documents to build, by their name in .texops.yaml (default: all)"` + } `positional-args:"true"` + NoCache bool `long:"no-cache" description:"Clear the remote build cache and rebuild from scratch"` + Live bool `long:"live" description:"Watch for changes and rebuild until interrupted with Ctrl+C; for people, not for scripts or agents; cannot be combined with --json"` + Log string `long:"log" choice:"stdout" choice:"file" description:"Where the LaTeX log goes: stdout streams it, file saves it to .texops/logs/.log (default: file under a coding agent or with --json, otherwise stdout)"` + UI *UI `no-flag:"true"` } var NewInstanceClientFn = func(instanceURL, jwt string) *InstanceClient { @@ -1082,7 +1088,7 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { // Determine name cmd.Name = strings.TrimSpace(cmd.Name) if cmd.Name == "" { - if !ui.IsTTY() { + if !ui.IsInteractive() { return usageErrorf("specify --name in non-interactive mode") } name, err := ui.TextInput("Token name:") @@ -1106,7 +1112,7 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { } expiresIn = &seconds } else { - if !ui.IsTTY() { + if !ui.IsInteractive() { return usageErrorf("specify --expires-in or --no-expiry in non-interactive mode") } // Interactive selection diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 0214ee4..ecd4b37 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -2452,6 +2452,32 @@ func TestTokenCreateCmd(t *testing.T) { assert.Contains(t, err.Error(), "specify --name in non-interactive mode") }) + t.Run("stdout TTY with non-TTY stdin does not prompt for a name", func(t *testing.T) { + buf := &bytes.Buffer{} + ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("my-ci-token\r")) + cmd := &cli.TokenCreateCmd{ + ExpiresIn: "30d", + UI: ui, + } + err := cmd.Execute(nil) + require.Error(t, err) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) + assert.Contains(t, err.Error(), "specify --name in non-interactive mode") + }) + + t.Run("stdout TTY with non-TTY stdin does not prompt for the expiry", func(t *testing.T) { + buf := &bytes.Buffer{} + ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("\r")) + cmd := &cli.TokenCreateCmd{ + Name: "ci", + UI: ui, + } + err := cmd.Execute(nil) + require.Error(t, err) + assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) + assert.Contains(t, err.Error(), "specify --expires-in or --no-expiry in non-interactive mode") + }) + t.Run("interactive name prompt via TTY", func(t *testing.T) { var receivedName string diff --git a/internal/cli/help.go b/internal/cli/help.go new file mode 100644 index 0000000..e50a465 --- /dev/null +++ b/internal/cli/help.go @@ -0,0 +1,168 @@ +package cli + +import ( + "bytes" + "fmt" + "io" + "strings" + + flags "github.com/jessevdk/go-flags" +) + +const rootHelp = `Compile LaTeX projects remotely with TexOps. + +Typical use: + tx init --texlive 2025 --compiler pdflatex # writes .texops.yaml + tx build # builds every document + tx build paper --json # one document, machine-readable + +Authentication: 'tx login' (interactive) or TX_API_TOKEN (CI, agents). +Without a terminal, tx never prompts: it uses defaults or fails (exit 2, or 4 +without .texops.yaml). +On failure the diagnostics name file:line; with --log=file the full log goes +to .texops/logs/. + +Exit codes: 0 ok, 1 service error, 2 usage, 3 auth, 4 config, 5 LaTeX errors. +Run 'tx --help' for details on a command.` + +var commandHelp = []struct{ path, text string }{ + {"login", `Log in with a one-time code: tx prints a URL and a code, opens the URL in a +browser and waits until the login is approved there. The session is stored in +the system keyring, or in the credentials file when there is no keyring. + +A person has to approve the login in a browser. CI and coding agents should +set TX_API_TOKEN instead, or ask the user to run 'tx login' in a terminal. +Exits 1 when the code expires or --timeout runs out. + +Examples: + tx login + tx login --no-browser --timeout 2m`}, + + {"init", `Write .texops.yaml in the current directory. Every .tex file that contains +\documentclass becomes a document. + +On a terminal, tx asks for the TeX Live version, the compiler and the +documents. Without one it does not prompt: it uses the newest TeX Live +version, pdflatex and every document it found. Exits 4 when .texops.yaml +already exists. + +Examples: + tx init + tx init --texlive 2025 --compiler xelatex`}, + + {"build", `Upload the project, compile the documents on TexOps and download the PDFs +to the paths in .texops.yaml. Without names, every document is built. + +Results go to stdout, progress to stderr. Each LaTeX error and warning is +printed as file:line: severity: message. With --log=file, the default under a +coding agent or with --json, the full LaTeX log is saved to +.texops/logs/.log instead of being streamed. Without a terminal, a missing +.texops.yaml is an error (exit 4). Exits 5 when a document fails to compile. + +Examples: + tx build + tx build paper --json + tx build --live`}, + + {"status", `Show whether tx is logged in, as whom, with a session or an API token, and +when the credentials expire. + +Exits 3 when there are no credentials or they have expired or were rejected, +so scripts can use it as a login check. + +Examples: + tx status + tx status --json`}, + + {"token", `Create, list and delete API tokens. Set a token as TX_API_TOKEN to use tx in +CI or from a coding agent without 'tx login'. + +Examples: + tx token create --name ci --expires-in 90d + tx token list + tx token delete ci --yes`}, + + {"token create", `Create an API token and print it to stdout. The value is shown only once. + +On a terminal, tx asks for a missing name or expiry. Without one, --name and +either --expires-in or --no-expiry are required (exit 2 otherwise). + +Examples: + tx token create --name ci --expires-in 90d + tx token create --name laptop --no-expiry --json`}, + + {"token list", `List API tokens with their name, prefix, expiry, last use and creation date. + +Examples: + tx token list + tx token list --json`}, + + {"token delete", `Delete an API token by name. Without a name, tx offers a list to pick from +(terminal only). + +On a terminal tx asks for confirmation. Without one, or with --json, it +refuses (exit 2) and deletes nothing unless --yes is given. + +Examples: + tx token delete ci + tx token delete ci --yes`}, +} + +func setupHelp(parser *flags.Parser) { + parser.LongDescription = rootHelp + for _, h := range commandHelp { + cmd := parser.Command + for name := range strings.FieldsSeq(h.path) { + cmd = cmd.Find(name) + } + cmd.LongDescription = h.text + } + parser.Find("init").FindOptionByLongName("texlive").Description = fmt.Sprintf( + "TeX Live version, e.g. %s (default: the newest the service supports; an unsupported version lists the valid ones)", + TexliveVersions[0]) +} + +// writeHelp prints the help of the active command. go-flags would trim the +// indentation of the long description and rewrap it, so it is inserted as is. +func writeHelp(parser *flags.Parser, w io.Writer) { + cmd := parser.Command + for cmd.Active != nil { + cmd = cmd.Active + } + desc := cmd.LongDescription + cmd.LongDescription = "" + moveChoicesToDescription(parser.Command) + var buf bytes.Buffer + parser.WriteHelp(&buf) + + help := buf.String() + if desc == "" { + _, _ = io.WriteString(w, help) + return + } + _, afterUsage, _ := strings.Cut(help, "Usage:\n") + usage, rest, _ := strings.Cut(afterUsage, "\n") + _, _ = fmt.Fprintf(w, "Usage:\n%s\n\n%s\n%s", usage, desc, rest) +} + +// moveChoicesToDescription lists long choice lists in the option description +// instead of after the flag, where go-flags would widen the whole flag column. +// It changes the parser for good, so it is only called right before exiting. +func moveChoicesToDescription(root *flags.Command) { + var visit func(g *flags.Group) + visit = func(g *flags.Group) { + for _, opt := range g.Options() { + if len(opt.Choices) <= 2 { + continue + } + opt.Description = fmt.Sprintf("%s; one of: %s", opt.Description, strings.Join(opt.Choices, ", ")) + opt.Choices = nil + } + for _, sub := range g.Groups() { + visit(sub) + } + } + for c := root; c != nil; c = c.Active { + visit(c.Group) + } +} diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go new file mode 100644 index 0000000..41d35cd --- /dev/null +++ b/internal/cli/help_test.go @@ -0,0 +1,113 @@ +package cli_test + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +var updateGolden = flag.Bool("update", false, "rewrite the golden files in testdata") + +func assertGolden(t *testing.T, name string, r txRun) { + t.Helper() + path := filepath.Join("testdata", name) + if *updateGolden { + require.NoError(t, os.MkdirAll("testdata", 0o750)) + require.NoError(t, os.WriteFile(path, []byte(r.stdout), 0o600)) + } + want, err := os.ReadFile(path) + require.NoError(t, err, "run 'go test ./internal/cli -run TestHelp -update' to create %s", path) + assert.Equal(t, string(want), r.stdout, + "%s\nthe golden files assume an 80-column help: go-flags reads the width from fd 0, which must not be a terminal (plain 'go test' ensures that)", r) +} + +func TestHelp(t *testing.T) { + t.Run("tx --help matches the golden file", func(t *testing.T) { + r := runTx(t, nil, "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Empty(t, r.stderr, r) + assertGolden(t, "help-root.golden", r) + }) + + t.Run("tx build --help matches the golden file", func(t *testing.T) { + r := runTx(t, nil, "build", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Empty(t, r.stderr, r) + assertGolden(t, "help-build.golden", r) + }) + + t.Run("bare tx prints the root help", func(t *testing.T) { + help := runTx(t, nil, "--help") + r := runTx(t, nil) + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, help.stdout, r.stdout, r) + }) + + t.Run("missing subcommand prints the command help to stderr", func(t *testing.T) { + r := runTx(t, nil, "token") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.Empty(t, r.stdout, r) + assert.Contains(t, r.stderr, "Create, list and delete API tokens.", r) + assert.Contains(t, r.stderr, "\n tx token delete ci --yes\n", r) + }) + + t.Run("login help names the non-interactive alternative", func(t *testing.T) { + r := runTx(t, nil, "login", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "set TX_API_TOKEN instead", r) + assert.Contains(t, r.stdout, "Examples:\n tx login\n tx login --no-browser --timeout 2m\n", r) + }) + + t.Run("init help describes the TeX Live version and lists the compilers", func(t *testing.T) { + r := runTx(t, nil, "init", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Examples:\n tx init\n tx init --texlive 2025 --compiler xelatex\n", r) + assert.Contains(t, r.stdout, "--texlive=version TeX Live version, e.g. 2025 (default: the newest", r) + assert.Contains(t, r.stdout, "--compiler=name LaTeX compiler (default: pdflatex); one of:", r) + }) + + t.Run("status help explains exit 3", func(t *testing.T) { + r := runTx(t, nil, "status", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Exits 3 when there are no credentials", r) + assert.Contains(t, r.stdout, "Examples:\n tx status\n tx status --json\n", r) + }) + + t.Run("token create help names the required flags", func(t *testing.T) { + r := runTx(t, nil, "token", "create", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Without one, --name and\neither --expires-in or --no-expiry are required", r) + assert.Contains(t, r.stdout, "(max 10y)", r) + assert.Contains(t, r.stdout, "Examples:\n tx token create --name ci --expires-in 90d\n", r) + }) + + t.Run("token list help has examples", func(t *testing.T) { + r := runTx(t, nil, "token", "list", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "Examples:\n tx token list\n tx token list --json\n", r) + }) + + t.Run("token delete help explains --yes", func(t *testing.T) { + r := runTx(t, nil, "token", "delete", "--help") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Contains(t, r.stdout, "tx [OPTIONS] token delete [delete-OPTIONS] [name]\n", r) + assert.Contains(t, r.stdout, "refuses (exit 2) and deletes nothing unless --yes is given", r) + assert.Contains(t, r.stdout, "Examples:\n tx token delete ci\n tx token delete ci --yes\n", r) + }) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index b77c2ca..4d4777b 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -31,6 +31,7 @@ func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Write parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash) parser.Name = "tx" + setupHelp(parser) parser.CommandHandler = func(command flags.Commander, args []string) error { ui.SetJSON(opts.JSON) if command == nil { @@ -47,13 +48,13 @@ func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Write if flagsErr, ok := errors.AsType[*flags.Error](err); ok { switch { case errors.Is(flagsErr.Type, flags.ErrHelp): - fmt.Fprintln(stdout, flagsErr.Message) + writeHelp(parser, stdout) return ExitOK case errors.Is(flagsErr.Type, flags.ErrCommandRequired) && len(args) == 0: - parser.WriteHelp(stdout) + writeHelp(parser, stdout) return ExitOK case errors.Is(flagsErr.Type, flags.ErrCommandRequired): - parser.WriteHelp(stderr) + writeHelp(parser, stderr) } err = usageError(err) } diff --git a/internal/cli/testdata/help-build.golden b/internal/cli/testdata/help-build.golden new file mode 100644 index 0000000..19075f3 --- /dev/null +++ b/internal/cli/testdata/help-build.golden @@ -0,0 +1,38 @@ +Usage: + tx [OPTIONS] build [build-OPTIONS] [name...] + +Upload the project, compile the documents on TexOps and download the PDFs +to the paths in .texops.yaml. Without names, every document is built. + +Results go to stdout, progress to stderr. Each LaTeX error and warning is +printed as file:line: severity: message. With --log=file, the default under a +coding agent or with --json, the full LaTeX log is saved to +.texops/logs/.log instead of being streamed. Without a terminal, a missing +.texops.yaml is an error (exit 4). Exits 5 when a document fails to compile. + +Examples: + tx build + tx build paper --json + tx build --live + +Application Options: + --json Print exactly one JSON document to stdout, also + on failure; progress and errors go to stderr + +Help Options: + -h, --help Show this help message + +[build command options] + --no-cache Clear the remote build cache and rebuild from + scratch + --live Watch for changes and rebuild until interrupted + with Ctrl+C; for people, not for scripts or + agents; cannot be combined with --json + --log=[stdout|file] Where the LaTeX log goes: stdout streams it, file + saves it to .texops/logs/.log (default: file + under a coding agent or with --json, otherwise + stdout) + +[build command arguments] + name: Documents to build, by their name in + .texops.yaml (default: all) diff --git a/internal/cli/testdata/help-root.golden b/internal/cli/testdata/help-root.golden new file mode 100644 index 0000000..cf640aa --- /dev/null +++ b/internal/cli/testdata/help-root.golden @@ -0,0 +1,32 @@ +Usage: + tx [OPTIONS] + +Compile LaTeX projects remotely with TexOps. + +Typical use: + tx init --texlive 2025 --compiler pdflatex # writes .texops.yaml + tx build # builds every document + tx build paper --json # one document, machine-readable + +Authentication: 'tx login' (interactive) or TX_API_TOKEN (CI, agents). +Without a terminal, tx never prompts: it uses defaults or fails (exit 2, or 4 +without .texops.yaml). +On failure the diagnostics name file:line; with --log=file the full log goes +to .texops/logs/. + +Exit codes: 0 ok, 1 service error, 2 usage, 3 auth, 4 config, 5 LaTeX errors. +Run 'tx --help' for details on a command. + +Application Options: + --json Print exactly one JSON document to stdout, also on failure; + progress and errors go to stderr + +Help Options: + -h, --help Show this help message + +Available commands: + build Build the LaTeX project + init Initialize a new TexOps project + login Log in to TexOps + status Show authentication status + token Manage API tokens diff --git a/man/tx.1 b/man/tx.1 index b120df9..672c221 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -18,6 +18,7 @@ .Cm build .Op Fl -live .Op Fl -no-cache +.Op Fl -log Ns = Ns Cm stdout | file .Op Ar name ... .Nm .Cm status @@ -43,7 +44,37 @@ documents. It uploads project files, triggers remote builds, and downloads the resulting PDFs. Both interactive use on a developer workstation and headless use in CI -pipelines are supported. +pipelines and coding agents are supported. +.Pp +.Nm +never prompts unless both stdin and stdout are a TTY. +Without a terminal it uses a default, or fails when there is no safe +default: usually with exit 2 for missing input, or 4 for +.Nm Cm build +without +.Pa .texops.yaml . +The one exception is the selection list of +.Nm Cm token delete Fl -yes +without a name, which needs only stdout to be a TTY. +.Nm Fl -help +gives an overview, and +.Nm Ar command Fl -help +describes a command with examples. +.Sh OPTIONS +These options work with every command, before or after the command name. +.Bl -tag -width Ds +.It Fl -json +Print exactly one JSON document to standard output, also on failure, and +nothing else. +Progress, the build log and error messages go to standard error as plain +lines. +See +.Sx JSON OUTPUT . +.It Fl h , Fl -help +Show help for +.Nm +or for the command and exit 0. +.El .Sh GETTING STARTED .Bl -enum .It @@ -77,6 +108,10 @@ to stderr, then opens the URL in a browser when possible, with the code already filled in. After authorization completes in the browser, the session JWT is stored locally. +A person has to approve the login, so CI pipelines and coding agents should +set +.Ev TX_API_TOKEN +instead. .Bl -tag -width Ds .It Fl -no-browser Print the URL and code without opening a browser. @@ -151,6 +186,23 @@ Otherwise the build fails with exit status 4. .Pp Flags: .Bl -tag -width Ds +.It Fl -log Ns = Ns Cm stdout | file +Where the LaTeX log goes. +.Cm stdout +streams it to standard error while the document compiles and saves nothing. +.Cm file +streams nothing and saves the full log to +.Pa .texops/logs/.log +after each build; a failed build also prints the last 20 lines of the log. +Defaults to +.Cm file +under a coding agent +.Pq see Ev TX_AGENT +or with +.Fl -json , +and to +.Cm stdout +otherwise. .It Fl -live Watch for file changes and rebuild automatically. After the initial build, the command monitors the project directory for @@ -159,9 +211,32 @@ Rapid successive saves are coalesced into a single rebuild. Output PDFs are rewritten in place (preserving the file inode) so that PDF viewers such as Preview.app and Skim auto-refresh without reopening the file. Press Ctrl+C to stop watching and exit. +Because it runs until interrupted, +.Fl -live +is meant for people, not for scripts or coding agents. +It cannot be combined with +.Fl -json +.Pq exit 2 . .It Fl -no-cache Rebuild without using the remote build cache. .El +.Pp +At the end of every build, +.Nm +prints a block per document with the errors and warnings the service found, +one per line as +.Ql file:line: severity: message , +for example: +.Bd -literal -offset indent +paper: FAILED, 1 error, 1 warning (log: .texops/logs/paper.log) + sec/intro.tex:3: error: Undefined control sequence. + sec/intro.tex:1: warning: Citation `missing' undefined +.Ed +.Pp +File paths are relative to the project root. +Parts the service could not determine are left out, and the log part +appears only when the log was saved. +Successful documents list their warnings too. .Ss status Show authentication status including email .Pq when available , @@ -234,6 +309,170 @@ Print the version and exit. The same output is available via .Nm Fl -version . +.Sh JSON OUTPUT +With +.Fl -json , +standard output carries exactly one JSON document followed by a newline, +whatever the outcome; the exit status is the same as without it. +When a command fails before it has a result, the document is an error: +.Bd -literal -offset indent +{"error": {"code": "usage", "message": "..."}} +.Ed +.Pp +.Ql code +is one of +.Ql internal , +.Ql network , +.Ql timeout , +.Ql usage , +.Ql not_authenticated , +.Ql config +or +.Ql build_failed +.Pq see Sx EXIT STATUS ; +.Ql message +is the message printed to standard error. +.Pp +A build that ran prints a build document, also when documents failed: +.Bd -literal -offset indent +{ + "ok": false, + "duration_ms": 4210, + "documents": [{ + "name": "paper", "main": "paper.tex", + "status": "failed", "reason": "latex_error", + "output": null, "log": ".texops/logs/paper.log", + "build_id": "bld_8f3k2", "duration_ms": 3120, + "errors": [{"severity": "error", "file": "sec/intro.tex", + "line": 3, "message": "Undefined control sequence.", + "context": "\e\ebadmacro"}], + "warnings": [], + "truncated": false + }] +} +.Ed +.Bl -tag -width Ds +.It Cm ok +True when every document built. +.It Cm status +.Ql succeeded +or +.Ql failed . +.It Cm reason +Why the document failed, null on success: +.Ql latex_error +or +.Ql no_pdf +.Pq the document did not compile , +.Ql timeout , +.Ql internal , +.Ql network , +.Ql auth , +.Ql sync +or +.Ql config . +.It Cm output +PDF path relative to the project root, null on failure. +.It Cm log +Path of the saved log, or null. +.It Cm errors , warnings +Diagnostics with +.Cm severity , +.Cm message +and, when known, +.Cm file , line , +.Cm kind +.Po +warnings: +.Ql undefined_citation , +.Ql undefined_reference , +.Ql duplicate_label , +.Ql rerun +.Pc +and +.Cm context +.Pq errors: the source text TeX showed . +.It Cm truncated +True when the service found more than 50 diagnostics. +.El +.Pp +The other commands print: +.Bl -tag -width Ds +.It Cm status +.Cm authenticated , +.Cm email , +.Cm method +.Po +.Ql jwt +or +.Ql api_token +.Pc , +.Cm source +.Po +.Ql env , +.Ql file +or +.Ql keyring +.Pc +and +.Cm expires_at +.Pq RFC 3339 or null . +When not authenticated, only +.Cm authenticated +is printed, as false, and the exit status is 3. +.It Cm init +.Cm config , +.Cm texlive , +.Cm compiler +and +.Cm documents +.Po +each with +.Cm name , main +and, when set, +.Cm directory +.Pc . +.It Cm login +.Cm authenticated . +.It Cm token list +An array of objects with +.Cm name , prefix , expires_at , last_used_at +and +.Cm created_at +.Pq RFC 3339 or null . +.It Cm token create +.Cm name , token +and +.Cm expires_at . +.It Cm token delete +.Cm deleted , +the name of the deleted token. +.El +.Sh CODING AGENTS +Coding agents should authenticate with +.Ev TX_API_TOKEN , +or ask the user to run +.Cm tx login +in a terminal, and then loop: +run +.Cm tx build --json , +read the +.Cm errors +of each failed document, fix the sources and build again. +Exit status 5 means LaTeX errors; 1 is a service failure that editing +the sources will not fix. +Agents should not use +.Fl -live , +and should delete tokens only with +.Fl -yes +when asked to. +.Pp +.Nm +detects a coding agent from the environment +.Pq see Sx ENVIRONMENT +and then saves build logs to +.Pa .texops/logs/ +instead of streaming them. .Sh CONFIGURATION The project configuration file .Pa .texops.yaml @@ -397,6 +636,7 @@ and set it as .Bl -bullet .It .Pa .git/ , +.Pa .texops/ , .Pa .texops.yaml , and .Pa .txignore @@ -428,6 +668,20 @@ Override the API endpoint URL. Takes priority over the .Cm api_url configuration key. +.It Ev TX_AGENT +Set to +.Ql none +to turn off coding agent detection. +.It Ev AI_AGENT , CLAUDECODE , CODEX_THREAD_ID , CURSOR_AGENT , GEMINI_CLI +Set by coding agents. +When one is present and +.Ev TX_AGENT +is not +.Ql none , +.Cm build +defaults to +.Fl -log Ns = Ns Cm file , +and requests name the agent in the User-Agent header. .It Ev XDG_CONFIG_HOME When set to an absolute path, the credentials file is stored at .Pa $XDG_CONFIG_HOME/texops/credentials.yaml . @@ -441,6 +695,14 @@ Project configuration file in the current directory. File exclusion patterns, similar to .Pa .gitignore . Can appear in any project subdirectory. +.It Pa .texops/logs/.log +Full LaTeX log of the last build of a document, saved with +.Fl -log Ns = Ns Cm file . +.It Pa .texops/.gitignore +Created with the first saved log; contains +.Ql * +so that Git ignores +.Pa .texops/ . .It Pa $XDG_CONFIG_HOME/texops/credentials.yaml Credentials file when .Ev XDG_CONFIG_HOME @@ -458,31 +720,48 @@ table and the token printed by are written to standard output. Progress, the streamed build log and error messages are written to standard error; each error is printed once. +With +.Fl -json , +standard output carries only the JSON document, whose +.Ql error.code +for each status is given in parentheses. .Bl -tag -width Ds .It 0 Success, including help and version output. .It 1 Service or unexpected failure: network errors, server errors, sync or upload -failures, or a build that hit the time limit or failed inside the service. +failures, or a build that hit the time limit or failed inside the service +.Po +.Ql internal , +.Ql network , +.Ql timeout +.Pc . .It 2 Usage error: unknown command, missing subcommand or unknown flag, bad flag value .Pq including an unsupported Fl -texlive No or Fl -compiler , unknown document name, +.Fl -json +with +.Fl -live , missing required input when there is no terminal to prompt on, or .Cm token delete without .Fl -yes -when there is no terminal to confirm on. +when there is no terminal to confirm on +.Pq Ql usage . .It 3 -Not authenticated, or the session or API token has expired or was rejected. +Not authenticated, or the session or API token has expired or was rejected +.Pq Ql not_authenticated . .It 4 Project configuration error: .Pa .texops.yaml -is missing or invalid, or its TeX Live version is not supported. +is missing or invalid, or its TeX Live version is not supported +.Pq Ql config . .It 5 -A document failed to compile (LaTeX errors, or no PDF was produced). +A document failed to compile: LaTeX errors, or no PDF was produced +.Pq Ql build_failed . .El .Pp When several documents fail for different reasons, @@ -495,6 +774,10 @@ Build a specific document: .Pp .Dl $ tx build paper .Pp +Build every document and print the result as JSON: +.Pp +.Dl $ tx build --json +.Pp Watch for changes and rebuild automatically: .Pp .Dl $ tx build --live diff --git a/man/tx.1.txt b/man/tx.1.txt index b6da609..478a718 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -6,7 +6,7 @@ NAME SYNOPSIS tx login [--no-browser] [--timeout duration] tx init [--texlive version] [--compiler name] [--main file] - tx build [--live] [--no-cache] [name ...] + tx build [--live] [--no-cache] [--log=stdout | file] [name ...] tx status tx token create [--name name] [--expires-in duration] [--no-expiry] tx token list @@ -18,7 +18,26 @@ DESCRIPTION tx is the command-line client for TexOps, a cloud service that compiles LaTeX documents. It uploads project files, triggers remote builds, and downloads the resulting PDFs. Both interactive use on a developer - workstation and headless use in CI pipelines are supported. + workstation and headless use in CI pipelines and coding agents are + supported. + + tx never prompts unless both stdin and stdout are a TTY. Without a + terminal it uses a default, or fails when there is no safe default: + usually with exit 2 for missing input, or 4 for tx build without + .texops.yaml. The one exception is the selection list of tx token delete + --yes without a name, which needs only stdout to be a TTY. tx --help + gives an overview, and tx command --help describes a command with + examples. + +OPTIONS + These options work with every command, before or after the command name. + + --json Print exactly one JSON document to standard output, also on + failure, and nothing else. Progress, the build log and error + messages go to standard error as plain lines. See JSON OUTPUT. + + -h, --help + Show help for tx or for the command and exit 0. GETTING STARTED 1. Authenticate with the TexOps service: @@ -43,7 +62,8 @@ COMMANDS ‘Open and enter code ’ to stderr, then opens the URL in a browser when possible, with the code already filled in. After authorization completes in the browser, the session JWT is stored - locally. + locally. A person has to approve the login, so CI pipelines and coding + agents should set TX_API_TOKEN instead. --no-browser Print the URL and code without opening a browser. @@ -99,6 +119,14 @@ COMMANDS Flags: + --log=stdout | file + Where the LaTeX log goes. stdout streams it to standard error + while the document compiles and saves nothing. file streams + nothing and saves the full log to .texops/logs/.log after + each build; a failed build also prints the last 20 lines of the + log. Defaults to file under a coding agent (see TX_AGENT) or + with --json, and to stdout otherwise. + --live Watch for file changes and rebuild automatically. After the initial build, the command monitors the project directory for modifications and triggers a rebuild approximately 500ms after @@ -106,11 +134,25 @@ COMMANDS single rebuild. Output PDFs are rewritten in place (preserving the file inode) so that PDF viewers such as Preview.app and Skim auto-refresh without reopening the file. Press Ctrl+C to stop - watching and exit. + watching and exit. Because it runs until interrupted, --live is + meant for people, not for scripts or coding agents. It cannot be + combined with --json (exit 2). --no-cache Rebuild without using the remote build cache. + At the end of every build, tx prints a block per document with the errors + and warnings the service found, one per line as ‘file:line: severity: + message’, for example: + + paper: FAILED, 1 error, 1 warning (log: .texops/logs/paper.log) + sec/intro.tex:3: error: Undefined control sequence. + sec/intro.tex:1: warning: Citation `missing' undefined + + File paths are relative to the project root. Parts the service could not + determine are left out, and the log part appears only when the log was + saved. Successful documents list their warnings too. + status Show authentication status including email (when available), authentication method, and token expiry. Exits 3 when not authenticated @@ -160,6 +202,90 @@ COMMANDS Print the tx version and exit. The same output is available via tx --version. +JSON OUTPUT + With --json, standard output carries exactly one JSON document followed + by a newline, whatever the outcome; the exit status is the same as + without it. When a command fails before it has a result, the document is + an error: + + {"error": {"code": "usage", "message": "..."}} + + ‘code’ is one of ‘internal’, ‘network’, ‘timeout’, ‘usage’, + ‘not_authenticated’, ‘config’ or ‘build_failed’ (see EXIT STATUS); + ‘message’ is the message printed to standard error. + + A build that ran prints a build document, also when documents failed: + + { + "ok": false, + "duration_ms": 4210, + "documents": [{ + "name": "paper", "main": "paper.tex", + "status": "failed", "reason": "latex_error", + "output": null, "log": ".texops/logs/paper.log", + "build_id": "bld_8f3k2", "duration_ms": 3120, + "errors": [{"severity": "error", "file": "sec/intro.tex", + "line": 3, "message": "Undefined control sequence.", + "context": "\\badmacro"}], + "warnings": [], + "truncated": false + }] + } + + ok True when every document built. + + status ‘succeeded’ or ‘failed’. + + reason Why the document failed, null on success: ‘latex_error’ or + ‘no_pdf’ (the document did not compile), ‘timeout’, ‘internal’, + ‘network’, ‘auth’, ‘sync’ or ‘config’. + + output PDF path relative to the project root, null on failure. + + log Path of the saved log, or null. + + errors, warnings + Diagnostics with severity, message and, when known, file, line, + kind (warnings: ‘undefined_citation’, ‘undefined_reference’, + ‘duplicate_label’, ‘rerun’) and context (errors: the source text + TeX showed). + + truncated + True when the service found more than 50 diagnostics. + + The other commands print: + + status authenticated, email, method (‘jwt’ or ‘api_token’), source + (‘env’, ‘file’ or ‘keyring’) and expires_at (RFC 3339 or null). + When not authenticated, only authenticated is printed, as false, + and the exit status is 3. + + init config, texlive, compiler and documents (each with name, main + and, when set, directory). + + login authenticated. + + token list + An array of objects with name, prefix, expires_at, last_used_at + and created_at (RFC 3339 or null). + + token create + name, token and expires_at. + + token delete + deleted, the name of the deleted token. + +CODING AGENTS + Coding agents should authenticate with TX_API_TOKEN, or ask the user to + run tx login in a terminal, and then loop: run tx build --json, read the + errors of each failed document, fix the sources and build again. Exit + status 5 means LaTeX errors; 1 is a service failure that editing the + sources will not fix. Agents should not use --live, and should delete + tokens only with --yes when asked to. + + tx detects a coding agent from the environment (see ENVIRONMENT) and then + saves build logs to .texops/logs/ instead of streaming them. + CONFIGURATION The project configuration file .texops.yaml is created by tx init with a minimal set of keys: project_key, texlive, compiler, and a documents list @@ -259,7 +385,8 @@ AUTHENTICATION TX_API_TOKEN. FILE UPLOADS - • .git/, .texops.yaml, and .txignore are always excluded from uploads. + • .git/, .texops/, .texops.yaml, and .txignore are always excluded from + uploads. • Only the project-root .gitignore is loaded; nested .gitignore files are not used. @@ -282,6 +409,14 @@ ENVIRONMENT Override the API endpoint URL. Takes priority over the api_url configuration key. + TX_AGENT + Set to ‘none’ to turn off coding agent detection. + + AI_AGENT, CLAUDECODE, CODEX_THREAD_ID, CURSOR_AGENT, GEMINI_CLI + Set by coding agents. When one is present and TX_AGENT is not + ‘none’, build defaults to --log=file, and requests name the agent + in the User-Agent header. + XDG_CONFIG_HOME When set to an absolute path, the credentials file is stored at $XDG_CONFIG_HOME/texops/credentials.yaml. Relative values are @@ -295,6 +430,14 @@ FILES File exclusion patterns, similar to .gitignore. Can appear in any project subdirectory. + .texops/logs/.log + Full LaTeX log of the last build of a document, saved with + --log=file. + + .texops/.gitignore + Created with the first saved log; contains ‘*’ so that Git + ignores .texops/. + $XDG_CONFIG_HOME/texops/credentials.yaml Credentials file when XDG_CONFIG_HOME is set to an absolute path. @@ -305,28 +448,31 @@ EXIT STATUS Results (the build summary, status fields, the token list table and the token printed by token create) are written to standard output. Progress, the streamed build log and error messages are written to standard error; - each error is printed once. + each error is printed once. With --json, standard output carries only + the JSON document, whose ‘error.code’ for each status is given in + parentheses. 0 Success, including help and version output. 1 Service or unexpected failure: network errors, server errors, sync or upload failures, or a build that hit the time limit or - failed inside the service. + failed inside the service (‘internal’, ‘network’, ‘timeout’). 2 Usage error: unknown command, missing subcommand or unknown flag, bad flag value (including an unsupported --texlive or - --compiler), unknown document name, missing required input when - there is no terminal to prompt on, or token delete without --yes - when there is no terminal to confirm on. + --compiler), unknown document name, --json with --live, missing + required input when there is no terminal to prompt on, or token + delete without --yes when there is no terminal to confirm on + (‘usage’). 3 Not authenticated, or the session or API token has expired or was - rejected. + rejected (‘not_authenticated’). 4 Project configuration error: .texops.yaml is missing or invalid, - or its TeX Live version is not supported. + or its TeX Live version is not supported (‘config’). - 5 A document failed to compile (LaTeX errors, or no PDF was - produced). + 5 A document failed to compile: LaTeX errors, or no PDF was + produced (‘build_failed’). When several documents fail for different reasons, build exits with the most severe code: 1 if any failure was a service failure, otherwise 3 for @@ -338,6 +484,10 @@ EXAMPLES $ tx build paper + Build every document and print the result as JSON: + + $ tx build --json + Watch for changes and rebuild automatically: $ tx build --live diff --git a/site/docs/agents.md b/site/docs/agents.md new file mode 100644 index 0000000..6a16bb9 --- /dev/null +++ b/site/docs/agents.md @@ -0,0 +1,106 @@ +--- +title: Using tx from coding agents +--- + +Coding agents such as Claude Code, Codex, Cursor and Gemini CLI can build a LaTeX project with `tx`, read the errors, fix the sources and build again. `tx` never prompts without a terminal, reports results as JSON with `--json`, and uses a distinct exit code for each kind of failure. + +## Authentication + +`tx login` needs a person to approve the login in a browser, so an agent should not run it. Instead, either: + +- set `TX_API_TOKEN` to an API token (create one with `tx token create --name --expires-in 90d`), or +- ask the user to run `tx login` in their own terminal. The session is stored in the system keyring, and later `tx` commands from the agent use it. + +`tx status --json` checks the credentials. It exits `3` when there are none or they have expired, and the message says what to do: + +```text +not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create') +``` + +## The build loop + +1. Run `tx build --json` (or `tx build --json` for one document). +2. Parse stdout. It is always exactly one JSON document; progress and messages go to stderr. +3. For each document with `"status": "failed"`, read `errors`: each one names the `file` and `line` to fix. +4. Edit the sources and go back to step 1. + +A failed build prints a document like this: + +```json +{ + "ok": false, + "duration_ms": 4210, + "documents": [ + { + "name": "paper", + "main": "paper.tex", + "status": "failed", + "reason": "latex_error", + "output": null, + "log": ".texops/logs/paper.log", + "build_id": "bld_8f3k2", + "duration_ms": 3120, + "errors": [ + {"severity": "error", "file": "sec/intro.tex", "line": 3, "message": "Undefined control sequence.", "context": "\\badmacro"} + ], + "warnings": [], + "truncated": false + } + ] +} +``` + +| Field | What to do with it | +|-------|--------------------| +| `ok` | `true`: every PDF was built and downloaded. | +| `documents[].reason` | `latex_error` or `no_pdf`: fix the sources. Anything else is not a LaTeX problem (see the exit codes below). | +| `documents[].errors` | The LaTeX errors with `file`, `line`, `message` and, when TeX showed it, the source `context`. | +| `documents[].warnings` | Undefined citations and references, duplicate labels, rerun hints. Worth fixing after the errors. | +| `documents[].log` | The full LaTeX log, for errors the diagnostics do not explain. | +| `documents[].output` | The PDF path when the document built. | + +When the command fails before building (no credentials, no `.texops.yaml`, a bad flag), stdout carries an error document instead, and `error.code` says why: + +```json +{"error": {"code": "config", "message": "no project config found; run `tx init` to set up your project"}} +``` + +Every field is described in the [CLI reference](cli.md#json-output). + +## Logs + +When `tx` detects a coding agent, `tx build` saves the full LaTeX log to `.texops/logs/.log` instead of streaming it, and prints only the diagnostics and, on failure, the last 20 lines of the log. `--json` does the same. `.texops/` ignores itself in Git and is never uploaded. + +`tx` recognizes an agent by `AI_AGENT`, `CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT` or `GEMINI_CLI` in the environment. Set `TX_AGENT=none` to turn detection off, or pass `--log=stdout` or `--log=file` to choose explicitly. + +## Exit codes + +| Code | Meaning | What to do | +|------|---------|------------| +| `0` | Success | Done. | +| `1` | Service or network failure, or the build hit the time limit | Retry later; do not edit the sources because of it. | +| `2` | Usage error | Fix the command line. | +| `3` | Not authenticated | Ask the user to run `tx login`, or set `TX_API_TOKEN`. | +| `4` | `.texops.yaml` missing or invalid, or unsupported TeX Live version | Run `tx init`, or fix `.texops.yaml`. | +| `5` | LaTeX errors | Read `errors`, fix the sources, rebuild. | + +## Things to avoid + +- `tx build --live` runs until it is interrupted. It is for people watching a PDF viewer, not for agents, and it cannot be combined with `--json`. +- `tx token delete` asks for confirmation on a terminal and refuses without one. Pass `--yes` only when the user asked for that token to be deleted. +- `tx init` without flags picks the newest TeX Live version and `pdflatex`. Pass `--texlive` and `--compiler` when the project needs something else. + +## Snippet for your repository + +Add this to the `AGENTS.md` or `CLAUDE.md` of a LaTeX repository that uses TexOps: + +```markdown +## Building the PDF + +- Build with `tx build --json`. Stdout is one JSON document; for each failed + document, fix the `errors` (each has `file` and `line`) and build again. +- Exit codes: 0 ok, 1 service error (retry, don't edit), 2 usage, 3 not logged + in (ask me to run `tx login`), 4 `.texops.yaml` problem, 5 LaTeX errors. +- The full log of the last build is in `.texops/logs/.log`. +- Never run `tx login`, `tx build --live` or `tx token delete`. +``` diff --git a/site/docs/cli.md b/site/docs/cli.md index 2dedbfc..eae8398 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -2,41 +2,70 @@ title: CLI Reference --- -Complete reference for all `tx` commands, flags, environment variables, and files. All commands support `--help` for usage information. +Complete reference for all `tx` commands, flags, environment variables, and files. `tx --help` gives an overview, and `tx --help` describes a command with examples. To run `tx` from a coding agent or a script, see [Using tx from coding agents](agents.md). + +## Global options + +These flags work with every command, before or after the command name. + +| Flag | Description | +|------|-------------| +| `--json` | Print exactly one JSON document to stdout, also on failure, and nothing else. Progress, the build log and error messages go to stderr as plain lines. See [JSON output](#json-output). | +| `-h`, `--help` | Show help for `tx` or for the command and exit `0`. | + +`tx` never prompts without a terminal (stdin and stdout both a TTY). Instead it uses a default, or fails with a non-zero exit code when there is no safe default: usually `2` for missing input, `4` for `tx build` without `.texops.yaml`. The one exception is the selection list of `tx token delete --yes` without a name, which needs only stdout to be a TTY. ## `tx login` Authenticate with the TexOps service using the device code flow. `tx login` prints `Open and enter code ` to stderr, then opens the URL in a browser when possible, with the code already filled in. After authorization completes, the session JWT is stored in the system keyring (or the credentials file as a fallback). +A person has to approve the login in a browser. In CI or from a coding agent, use [`TX_API_TOKEN`](#environment-variables) instead. + | Flag | Description | |------|-------------| | `--no-browser` | Print the URL and code without opening a browser. | | `--timeout ` | How long to wait for authorization, as a Go duration such as `2m` or `90s`. Defaults to the lifetime of the login code. When it runs out (or the code expires first), `tx login` exits `1`. | -Commands that need credentials exit `3` when there are none. When the stored session has expired, the message names the date, for example `session expired on 2026-03-30; run 'tx login'`. +Commands that need credentials exit `3` when there are none, with the message `not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')`. When the stored session has expired, the message names the date, for example `session expired on 2026-03-30; run 'tx login'`. ## `tx init` -Create a `.texops.yaml` configuration file in the current directory. Recursively discovers `.tex` files containing `\documentclass` and presents them for selection. +Create a `.texops.yaml` configuration file in the current directory. Recursively discovers `.tex` files containing `\documentclass` and presents them for selection. Exits `4` if `.texops.yaml` already exists. The supported TeX Live versions come from the service (`GET /api/distributions`); when it cannot be reached, `tx` uses its built-in list. Without a terminal (stdin and stdout), `tx init` does not prompt: it uses the newest version, `pdflatex` and every discovered document. | Flag | Description | |------|-------------| -| `--texlive ` | TeX Live distribution version, e.g. `"2025"`. Skips the interactive prompt. Must be a version the service supports; otherwise `tx init` exits `2`, lists the supported versions and writes nothing. | -| `--compiler ` | LaTeX compiler (`pdflatex`, `xelatex`, `lualatex`, `latex`, `platex`, `uplatex`). Skips the interactive prompt. Any other value exits `2`. | +| `--texlive ` | TeX Live distribution version, e.g. `"2025"`. Defaults to the newest. Skips the interactive prompt. Must be a version the service supports; otherwise `tx init` exits `2`, lists the supported versions and writes nothing. | +| `--compiler ` | LaTeX compiler (`pdflatex`, `xelatex`, `lualatex`, `latex`, `platex`, `uplatex`). Defaults to `pdflatex`. Skips the interactive prompt. Any other value exits `2`. | | `--main ` | Fallback main `.tex` file, used only when recursive discovery finds no `.tex` files containing `\documentclass`. Does not override discovered documents. Defaults to `main.tex`. | -## `tx build` +## `tx build [name...]` -Build one or more documents defined in `.texops.yaml`. Positional arguments select documents by name; when none are given, all documents are built. On success, each PDF is downloaded to the output path defined in the config. +Build one or more documents defined in `.texops.yaml`. Positional arguments select documents by name; when none are given, all documents are built. An unknown name exits `2`. On success, each PDF is downloaded to the output path defined in the config. If `.texops.yaml` does not exist and both stdin and stdout are a TTY, an interactive prompt offers to run `tx init` first. Otherwise the build fails with exit code `4`. | Flag | Description | |------|-------------| | `--no-cache` | Rebuild without using the remote build cache. | -| `--live` | Watch for file changes and rebuild automatically. On each change, files are re-synced, the document is rebuilt, and the output PDF is rewritten in place so viewers like Skim refresh automatically. | +| `--log=stdout\|file` | Where the LaTeX log goes. `stdout` streams it while the document compiles (to stderr, next to the progress lines) and saves nothing. `file` streams nothing and saves the full log to `.texops/logs/.log` after each build. Defaults to `file` under a coding agent (see [`TX_AGENT`](#environment-variables)) or with `--json`, and to `stdout` otherwise. | +| `--live` | Watch for file changes and rebuild automatically. On each change, files are re-synced, the document is rebuilt, and the output PDF is rewritten in place so viewers like Skim refresh automatically. Runs until interrupted with Ctrl+C, so it is meant for people, not for scripts or agents. Cannot be combined with `--json` (exit `2`). | + +### Diagnostics + +At the end of every build, `tx` prints a block per document with the LaTeX errors and warnings the service found: + +```text +Build complete: 0 succeeded, 1 failed (4.2s) + paper: FAILED, 1 error, 1 warning (log: .texops/logs/paper.log) + sec/intro.tex:3: error: Undefined control sequence. + sec/intro.tex:1: warning: Citation `missing' undefined +``` + +Each diagnostic is `file:line: severity: message`, leaving out what the service could not determine. File paths are relative to the project root. The `(log: ...)` part appears only when the log was saved. Successful documents list their warnings too. When the service found more than 50 diagnostics, a last line says that more are in the log. + +With `--log=file`, a failed build also prints the last 20 lines of the log to stderr. The first time a log is saved, `tx` creates `.texops/.gitignore` containing `*`, so the directory never shows up in Git. `.texops/` is never uploaded. ## `tx status` @@ -44,21 +73,23 @@ Show authentication status including email, authentication method, and token exp ## `tx token create` -Create a new API token for CI pipelines or non-interactive use. The token value is displayed once and cannot be retrieved again. +Create a new API token for CI pipelines or non-interactive use. The token value is printed to stdout once and cannot be retrieved again. | Flag | Description | |------|-------------| | `--name ` | Name for the token. Required in non-interactive mode. | -| `--expires-in ` | Expiry duration: a positive integer followed by `d` (days) or `y` (years), max 10 years. Mutually exclusive with `--no-expiry`. | +| `--expires-in ` | Expiry duration: a positive integer followed by `d` (days) or `y` (years), max 10 years (`3650d` or `10y`). Mutually exclusive with `--no-expiry`. | | `--no-expiry` | Create a token that does not expire. Mutually exclusive with `--expires-in`. | +Without a terminal, `--name` and one of `--expires-in` or `--no-expiry` are required; otherwise `tx token create` exits `2`. + ## `tx token list` List all API tokens with their name, prefix, expiry, last-used date, and creation date. ## `tx token delete [name]` -Delete an API token. With a name argument, deletes the matching token after confirmation. Without a name argument, when stdout is a TTY and either stdin is a TTY or `--yes` is given, presents a selection list. +Delete an API token. With a name argument, deletes the matching token after confirmation. Without a name argument, when stdout is a TTY and either stdin is a TTY or `--yes` is given, presents a selection list. An unknown name exits `2`. Without a terminal (stdin and stdout), or with `--json`, `tx` cannot ask for confirmation: it exits `2` without deleting anything unless `--yes` is given. @@ -70,6 +101,153 @@ Without a terminal (stdin and stdout), or with `--json`, `tx` cannot ask for con Print the `tx` version and exit. Also available as `tx --version`. +## JSON output + +With `--json`, stdout carries exactly one JSON document followed by a newline, whatever the outcome. The exit code is the same as without `--json`. A human-readable error line still goes to stderr. + +When a command fails before it has a result (bad flags, no credentials, a missing `.texops.yaml`, a network error), the document is an error: + +```json +{"error": {"code": "not_authenticated", "message": "not authenticated: ask the user to run 'tx login' in a terminal, or set TX_API_TOKEN (create one with 'tx token create')"}} +``` + +| Field | Description | +|-------|-------------| +| `error.code` | One of `internal`, `network`, `timeout`, `usage`, `not_authenticated`, `config`, `build_failed`. Matches the [exit status](#exit-status). | +| `error.message` | The same message that is printed to stderr. | + +### `tx build --json` + +A build that ran prints a build document, also when documents failed: + +```json +{ + "ok": false, + "duration_ms": 4210, + "documents": [ + { + "name": "paper", + "main": "paper.tex", + "status": "failed", + "reason": "latex_error", + "output": null, + "log": ".texops/logs/paper.log", + "build_id": "bld_8f3k2", + "duration_ms": 3120, + "errors": [ + {"severity": "error", "file": "sec/intro.tex", "line": 3, "message": "Undefined control sequence.", "context": "\\badmacro"} + ], + "warnings": [ + {"severity": "warning", "kind": "undefined_citation", "file": "sec/intro.tex", "line": 1, "message": "Citation `missing' undefined"} + ], + "truncated": false + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `ok` | `true` when every document built. | +| `duration_ms` | Wall time of the whole command. | +| `documents[].name` | Document name from `.texops.yaml`. | +| `documents[].main` | Main file, relative to the project root. | +| `documents[].status` | `succeeded` or `failed`. | +| `documents[].reason` | Why the document failed, `null` on success: `latex_error` or `no_pdf` (the document did not compile), `timeout` (the build hit the time limit), `internal` (the service failed), `network`, `auth`, `sync` (uploading the project failed) or `config`. | +| `documents[].output` | PDF path relative to the project root, `null` on failure. | +| `documents[].log` | Path of the saved log (`--log=file`), else `null`. | +| `documents[].build_id` | Service build id, or `null`. | +| `documents[].duration_ms` | Time spent on this document. | +| `documents[].errors` | Error diagnostics (may be empty). | +| `documents[].warnings` | Warning diagnostics (may be empty). | +| `documents[].truncated` | `true` when the service found more than 50 diagnostics. | + +Each diagnostic has these fields; the optional ones are left out when unknown: + +| Field | Description | +|-------|-------------| +| `severity` | `error` or `warning`. | +| `kind` | Warnings only: `undefined_citation`, `undefined_reference`, `duplicate_label` or `rerun`. | +| `file` | Source file relative to the project root. | +| `line` | Line number. | +| `message` | The LaTeX message. | +| `context` | Errors only: the source text TeX showed after the line number. | + +### `tx status --json` + +```json +{"authenticated": true, "email": "ada@example.com", "method": "jwt", "source": "keyring", "expires_at": "2026-11-02T10:00:00Z"} +``` + +| Field | Description | +|-------|-------------| +| `authenticated` | `true` when logged in. When not, it is `false`, the other fields are left out, and the exit code is `3`. | +| `email` | Account email, or `null`. | +| `method` | `jwt` (a `tx login` session) or `api_token`. | +| `source` | Where the credentials came from: `env` (`TX_API_TOKEN`), `file` or `keyring`. | +| `expires_at` | RFC 3339 expiry, or `null` when the credentials do not expire. | + +### `tx init --json` + +```json +{"config": ".texops.yaml", "texlive": "2025", "compiler": "pdflatex", "documents": [{"name": "paper", "main": "paper.tex"}]} +``` + +| Field | Description | +|-------|-------------| +| `config` | Always `.texops.yaml`. | +| `texlive` | TeX Live version written to the config. | +| `compiler` | Compiler written to the config. | +| `documents[]` | `name`, `main` and, for a document in a subdirectory, `directory`. | + +### `tx login --json` + +```json +{"authenticated": true} +``` + +| Field | Description | +|-------|-------------| +| `authenticated` | Always `true`; a failed login prints an error document instead. | + +### `tx token list --json` + +An array, empty when there are no tokens: + +```json +[{"name": "ci", "prefix": "tx_3f9a", "expires_at": "2027-01-01T00:00:00Z", "last_used_at": null, "created_at": "2026-10-01T09:30:00Z"}] +``` + +| Field | Description | +|-------|-------------| +| `name` | Token name. | +| `prefix` | First characters of the token value. | +| `expires_at` | RFC 3339, or `null` when the token does not expire. | +| `last_used_at` | RFC 3339, or `null` when never used. | +| `created_at` | RFC 3339. | + +### `tx token create --json` + +```json +{"name": "ci", "token": "tx_3f9a...", "expires_at": "2027-01-01T00:00:00Z"} +``` + +| Field | Description | +|-------|-------------| +| `name` | Token name. | +| `token` | The token value. It is shown only once. | +| `expires_at` | RFC 3339, or `null` when the token does not expire. | + +### `tx token delete --json` + +```json +{"deleted": "ci"} +``` + +| Field | Description | +|-------|-------------| +| `deleted` | Name of the deleted token. | + ## Authentication order `tx` resolves credentials in this order: @@ -84,6 +262,8 @@ Print the `tx` version and exit. Also available as `tx --version`. |----------|-------------| | `TX_API_TOKEN` | API token for authentication. Takes priority over all other credential sources. | | `TX_API_URL` | Override the API endpoint URL. Takes priority over the `api_url` config key. | +| `TX_AGENT` | Set to `none` to turn off coding agent detection. | +| `AI_AGENT`, `CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT`, `GEMINI_CLI` | Set by coding agents. When one is present (and `TX_AGENT` is not `none`), `tx build` defaults to `--log=file`, and requests name the agent in the `User-Agent` header. | | `XDG_CONFIG_HOME` | When set to an absolute path, credentials are stored at `$XDG_CONFIG_HOME/texops/credentials.yaml`. | ## Files @@ -92,22 +272,24 @@ Print the `tx` version and exit. Also available as `tx --version`. |------|-------------| | `.texops.yaml` | Project configuration file. | | `.txignore` | File exclusion patterns (`.gitignore` syntax). Can appear in subdirectories. | +| `.texops/logs/.log` | Full LaTeX log of the last build of a document, saved with `--log=file`. | +| `.texops/.gitignore` | Created with the first saved log; contains `*` so Git ignores `.texops/`. | | `$XDG_CONFIG_HOME/texops/credentials.yaml` | Credentials file when `XDG_CONFIG_HOME` is set. | | `~/.config/texops/credentials.yaml` | Default credentials file location. | ## Output streams -Results go to stdout: the build summary, the `tx status` fields, the `tx token list` table and the token value printed by `tx token create`. Progress, the streamed build log, hints and error messages go to stderr. On a terminal both appear on screen; in a pipe, stdout carries only the results. Each error is printed once, to stderr. +Results go to stdout: the build summary, the `tx status` fields, the `tx token list` table and the token value printed by `tx token create`. Progress, the streamed build log, hints and error messages go to stderr. On a terminal both appear on screen; in a pipe, stdout carries only the results. Each error is printed once, to stderr. With `--json`, stdout carries only the JSON document. ## Exit status -| Code | Meaning | -|------|---------| -| `0` | Success, including `--help` and `tx version`. | -| `1` | Service or unexpected failure: network errors, server errors (5xx), sync or upload failures, a build that hit the time limit or failed inside the service. | -| `2` | Usage error: unknown command, missing subcommand or unknown flag, bad flag value (including an unsupported `--texlive` or `--compiler`), unknown document name, missing required input when there is no terminal to prompt on, `tx token delete` without `--yes` when there is no terminal to confirm on. | -| `3` | Not authenticated, or the session or API token has expired or was rejected. | -| `4` | Project configuration error: `.texops.yaml` is missing or invalid, or its TeX Live version is not supported. | -| `5` | A document failed to compile (LaTeX errors, or no PDF was produced). | +| Code | Meaning | JSON `error.code` | +|------|---------|-------------------| +| `0` | Success, including `--help` and `tx version`. | | +| `1` | Service or unexpected failure: network errors, server errors (5xx), sync or upload failures, a build that hit the time limit or failed inside the service. | `internal`, `network`, `timeout` | +| `2` | Usage error: unknown command, missing subcommand or unknown flag, bad flag value (including an unsupported `--texlive` or `--compiler`), unknown document name, `--json` with `--live`, missing required input when there is no terminal to prompt on, `tx token delete` without `--yes` when there is no terminal to confirm on. | `usage` | +| `3` | Not authenticated, or the session or API token has expired or was rejected. | `not_authenticated` | +| `4` | Project configuration error: `.texops.yaml` is missing or invalid, or its TeX Live version is not supported. | `config` | +| `5` | A document failed to compile (LaTeX errors, or no PDF was produced). | `build_failed` | When several documents fail for different reasons, `tx build` exits with the most severe code: `1` if any failure was a service failure, otherwise `3` for an authentication failure, then `4` for a configuration failure. It exits `5` only when every failure is a compile failure. diff --git a/site/docs/quickstart.md b/site/docs/quickstart.md index 9b9c586..a60ce85 100644 --- a/site/docs/quickstart.md +++ b/site/docs/quickstart.md @@ -88,3 +88,4 @@ tx build paper - [Configuration](configuration.md) — customize your `.texops.yaml` and control which files are uploaded - [CI/CD](ci-cd.md) — set up automated builds with API tokens - [CLI Reference](cli.md) — full list of commands and flags +- [Using tx from coding agents](agents.md) — let Claude Code, Codex and other agents build and fix your documents diff --git a/site/sidebars.ts b/site/sidebars.ts index fab6139..4171f2f 100644 --- a/site/sidebars.ts +++ b/site/sidebars.ts @@ -6,6 +6,7 @@ const sidebars: SidebarsConfig = { 'configuration', 'ci-cd', 'cli', + 'agents', ], }; From 0e69c3253806068c658585b28d55cd6422d1a36c Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Sat, 10 Oct 2026 00:15:31 +0100 Subject: [PATCH 08/11] Fix token list alignment, status for a rejected TX_API_TOKEN, and other edge cases - token list sizes every column to its widest value - tx status names TX_API_TOKEN when the server rejects it - tx token create accepts the name as an argument - tx without a command is a usage error (exit 2) - tx build saves a generated project_key only after the project is created --- README.md | 2 +- internal/cli/commands.go | 57 +++++++++++++----- internal/cli/commands_test.go | 4 +- internal/cli/help.go | 11 ++-- internal/cli/help_test.go | 11 ++-- internal/cli/json_test.go | 6 +- internal/cli/output.go | 49 +++++++++++---- internal/cli/run.go | 3 - internal/cli/run_test.go | 20 ++++--- internal/cli/validate_test.go | 109 +++++++++++++++++++++++++++++++--- man/tx.1 | 22 ++++++- man/tx.1.txt | 26 ++++---- site/docs/cli.md | 12 ++-- site/docs/configuration.md | 2 +- 14 files changed, 251 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index e29a3d5..25aceee 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ tx build # Build a specific document tx build --log=file # Save the LaTeX log to .texops/logs/ instead of streaming it tx build --live # Watch for changes and rebuild (until Ctrl+C) tx status # Show authentication status -tx token create [--name "CI"] # Create an API token +tx token create [name] # Create an API token tx token list # List API tokens tx token delete [name] [--yes] # Delete an API token tx --json # Print one JSON document to stdout diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 18ca45f..111e3ee 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -56,7 +56,7 @@ type TokenCmd struct { } type TokenCreateCmd struct { - Name string `long:"name" value-name:"name" description:"Name for the token (required without a terminal)"` + Name string `long:"name" value-name:"name" description:"Name for the token, the same as the name argument (required without a terminal)"` ExpiresIn string `long:"expires-in" value-name:"duration" description:"Expiry as a whole number of days or years, e.g. 30d, 90d, 1y (max 10y); without a terminal, this or --no-expiry is required"` NoExpiry bool `long:"no-expiry" description:"Create a token that never expires; cannot be combined with --expires-in"` UI *UI `no-flag:"true"` @@ -71,6 +71,10 @@ type TokenDeleteCmd struct { UI *UI `no-flag:"true"` } +func (cmd *TokenCreateCmd) Usage() string { + return "[create-OPTIONS] [name]" +} + func (cmd *TokenDeleteCmd) Usage() string { return "[delete-OPTIONS] [name]" } @@ -193,6 +197,9 @@ func checkStatus(ui *UI) (statusResult, error) { if err != nil { sp.Fail("Authentication check failed") if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.StatusCode == http.StatusUnauthorized { + if source == authSourceEnv { + return statusResult{}, authError(errors.New("TX_API_TOKEN was rejected (invalid, expired or deleted); set a valid token, or unset it to use your 'tx login' session")) + } return statusResult{}, authError(errors.New("Session expired. Run 'tx login' to re-authenticate.")) } return statusResult{}, err @@ -670,23 +677,12 @@ func runBuild(ctx context.Context, dir string, names []string, noCache, live boo api := NewAPIClient(ResolveAPIURL(config), token) - if config.ProjectKey == "" { + newKey := config.ProjectKey == "" + if newKey { key, err := generateProjectKey() if err != nil { return fmt.Errorf("generating project_key: %w", err) } - line := fmt.Sprintf("project_key: %q\n", key) - var updated []byte - if bytes.HasPrefix(configData, []byte("---\n")) { - // Insert after YAML document marker to keep valid single-document YAML - marker := []byte("---\n") - updated = append(marker, append([]byte(line), configData[len(marker):]...)...) - } else { - updated = append([]byte(line), configData...) - } - if err := os.WriteFile(configPath, updated, 0o600); err != nil { - return fmt.Errorf("updating .texops.yaml with project_key: %w", err) - } config.ProjectKey = key } @@ -697,6 +693,12 @@ func runBuild(ctx context.Context, dir string, names []string, noCache, live boo return classifyVersionError(err) } sp.Stop("Project ready") + + if newKey { + if err := writeProjectKey(configPath, configData, config.ProjectKey); err != nil { + return err + } + } projectID := project.ID p := buildParams{ @@ -734,6 +736,22 @@ func classifyVersionError(err error) error { return err } +// writeProjectKey prepends project_key to the config file, after a leading +// YAML document marker if there is one. +func writeProjectKey(configPath string, configData []byte, key string) error { + line := fmt.Sprintf("project_key: %q\n", key) + var updated []byte + if marker := []byte("---\n"); bytes.HasPrefix(configData, marker) { + updated = append(marker, append([]byte(line), configData[len(marker):]...)...) + } else { + updated = append([]byte(line), configData...) + } + if err := os.WriteFile(configPath, updated, 0o600); err != nil { + return fmt.Errorf("updating .texops.yaml with project_key: %w", err) + } + return nil +} + func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { sp := p.ui.Spin("Collecting files...") files, err := CollectFiles(ctx, p.dir) @@ -1085,11 +1103,18 @@ func (cmd *TokenCreateCmd) Execute(args []string) error { ui = defaultUI() } - // Determine name cmd.Name = strings.TrimSpace(cmd.Name) + switch { + case len(args) > 1: + return usageErrorf("too many arguments: tx token create takes at most one name") + case len(args) == 1 && cmd.Name != "" && strings.TrimSpace(args[0]) != cmd.Name: + return usageErrorf("token name given twice: %q and --name %q", args[0], cmd.Name) + case len(args) == 1: + cmd.Name = strings.TrimSpace(args[0]) + } if cmd.Name == "" { if !ui.IsInteractive() { - return usageErrorf("specify --name in non-interactive mode") + return usageErrorf("specify the token name (tx token create , or --name) in non-interactive mode") } name, err := ui.TextInput("Token name:") if err != nil { diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index ecd4b37..3f8fcc0 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -2449,7 +2449,7 @@ func TestTokenCreateCmd(t *testing.T) { } err := cmd.Execute(nil) require.Error(t, err) - assert.Contains(t, err.Error(), "specify --name in non-interactive mode") + assert.Contains(t, err.Error(), "specify the token name (tx token create , or --name) in non-interactive mode") }) t.Run("stdout TTY with non-TTY stdin does not prompt for a name", func(t *testing.T) { @@ -2462,7 +2462,7 @@ func TestTokenCreateCmd(t *testing.T) { err := cmd.Execute(nil) require.Error(t, err) assert.Equal(t, cli.ExitUsage, cli.AsExitError(err).Code) - assert.Contains(t, err.Error(), "specify --name in non-interactive mode") + assert.Contains(t, err.Error(), "specify the token name (tx token create , or --name) in non-interactive mode") }) t.Run("stdout TTY with non-TTY stdin does not prompt for the expiry", func(t *testing.T) { diff --git a/internal/cli/help.go b/internal/cli/help.go index e50a465..e974b29 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -78,18 +78,19 @@ Examples: CI or from a coding agent without 'tx login'. Examples: - tx token create --name ci --expires-in 90d + tx token create ci --expires-in 90d tx token list tx token delete ci --yes`}, {"token create", `Create an API token and print it to stdout. The value is shown only once. -On a terminal, tx asks for a missing name or expiry. Without one, --name and -either --expires-in or --no-expiry are required (exit 2 otherwise). +On a terminal, tx asks for a missing name or expiry. Without one, the name +(as an argument or --name) and either --expires-in or --no-expiry are +required (exit 2 otherwise). Examples: - tx token create --name ci --expires-in 90d - tx token create --name laptop --no-expiry --json`}, + tx token create ci --expires-in 90d + tx token create laptop --no-expiry --json`}, {"token list", `List API tokens with their name, prefix, expiry, last use and creation date. diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go index 41d35cd..b88e1b8 100644 --- a/internal/cli/help_test.go +++ b/internal/cli/help_test.go @@ -44,12 +44,13 @@ func TestHelp(t *testing.T) { assertGolden(t, "help-build.golden", r) }) - t.Run("bare tx prints the root help", func(t *testing.T) { + t.Run("bare tx prints the root help to stderr and exits 2", func(t *testing.T) { help := runTx(t, nil, "--help") r := runTx(t, nil) - require.Equal(t, cli.ExitOK, r.code, r) - assert.Equal(t, help.stdout, r.stdout, r) + require.Equal(t, cli.ExitUsage, r.code, r) + assert.Empty(t, r.stdout, r) + assert.Contains(t, r.stderr, help.stdout, r) }) t.Run("missing subcommand prints the command help to stderr", func(t *testing.T) { @@ -90,9 +91,9 @@ func TestHelp(t *testing.T) { r := runTx(t, nil, "token", "create", "--help") require.Equal(t, cli.ExitOK, r.code, r) - assert.Contains(t, r.stdout, "Without one, --name and\neither --expires-in or --no-expiry are required", r) + assert.Contains(t, r.stdout, "Without one, the name\n(as an argument or --name) and either --expires-in or --no-expiry are\nrequired", r) assert.Contains(t, r.stdout, "(max 10y)", r) - assert.Contains(t, r.stdout, "Examples:\n tx token create --name ci --expires-in 90d\n", r) + assert.Contains(t, r.stdout, "Examples:\n tx token create ci --expires-in 90d\n", r) }) t.Run("token list help has examples", func(t *testing.T) { diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go index 711c136..f11de5b 100644 --- a/internal/cli/json_test.go +++ b/internal/cli/json_test.go @@ -342,7 +342,7 @@ func TestRunJSONStatus(t *testing.T) { require.Equal(t, cli.ExitAuth, r.code, r) assert.JSONEq(t, `{"authenticated": false}`, r.stdout, r) - assert.Equal(t, "Checking authentication...\nAuthentication check failed\nSession expired. Run 'tx login' to re-authenticate.\n", r.stderr, r) + assert.Equal(t, "Checking authentication...\nAuthentication check failed\nTX_API_TOKEN was rejected (invalid, expired or deleted); set a valid token, or unset it to use your 'tx login' session\n", r.stderr, r) }) } @@ -393,8 +393,8 @@ func TestRunJSONToken(t *testing.T) { r := runTx(t, f, "--json", "token", "create") require.Equal(t, cli.ExitUsage, r.code, r) - assert.JSONEq(t, `{"error": {"code": "usage", "message": "specify --name in non-interactive mode"}}`, r.stdout, r) - assert.Equal(t, "specify --name in non-interactive mode\n", r.stderr, r) + assert.JSONEq(t, `{"error": {"code": "usage", "message": "specify the token name (tx token create , or --name) in non-interactive mode"}}`, r.stdout, r) + assert.Equal(t, "specify the token name (tx token create , or --name) in non-interactive mode\n", r.stderr, r) }) t.Run("token delete", func(t *testing.T) { diff --git a/internal/cli/output.go b/internal/cli/output.go index 5a05c29..7d159fb 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -274,23 +274,50 @@ func renderTokenList(ui *UI, tokens []APITokenListItem) error { ui.DimInfo("No tokens found. Create one with 'tx token create --name '.") return nil } - nameWidth := len("NAME") + rows := [][]string{{"NAME", "PREFIX", "EXPIRES", "LAST USED", "CREATED"}} for _, tok := range tokens { - nameWidth = max(nameWidth, utf8.RuneCountInString(tok.Name)) + rows = append(rows, []string{ + tok.Name, + tok.Prefix, + formatDatePtr(tok.ExpiresAt, "never"), + formatDatePtr(tok.LastUsedAt, "never"), + formatDate(tok.CreatedAt), + }) } - padName := func(name string) string { - return name + strings.Repeat(" ", nameWidth-utf8.RuneCountInString(name)) - } - ui.Log(fmt.Sprintf("%s %-12s %-14s %-14s %-12s", padName("NAME"), "PREFIX", "EXPIRES", "LAST USED", "CREATED")) - for _, tok := range tokens { - expires := formatDatePtr(tok.ExpiresAt, "never") - lastUsed := formatDatePtr(tok.LastUsedAt, "never") - created := formatDate(tok.CreatedAt) - ui.Log(fmt.Sprintf("%s %-12s %-14s %-14s %-12s", padName(tok.Name), tok.Prefix, expires, lastUsed, created)) + for _, line := range alignColumns(rows) { + ui.Log(line) } return nil } +// alignColumns pads every column but the last to its widest cell. +func alignColumns(rows [][]string) []string { + var widths []int + for _, row := range rows { + for i, cell := range row { + if i == len(widths) { + widths = append(widths, 0) + } + widths[i] = max(widths[i], utf8.RuneCountInString(cell)) + } + } + lines := make([]string, 0, len(rows)) + for _, row := range rows { + var b strings.Builder + for i, cell := range row { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(cell) + if i < len(row)-1 { + b.WriteString(strings.Repeat(" ", widths[i]-utf8.RuneCountInString(cell))) + } + } + lines = append(lines, b.String()) + } + return lines +} + type tokenCreateResult struct { Name string `json:"name"` Token string `json:"token"` diff --git a/internal/cli/run.go b/internal/cli/run.go index 4d4777b..eaf7923 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -50,9 +50,6 @@ func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Write case errors.Is(flagsErr.Type, flags.ErrHelp): writeHelp(parser, stdout) return ExitOK - case errors.Is(flagsErr.Type, flags.ErrCommandRequired) && len(args) == 0: - writeHelp(parser, stdout) - return ExitOK case errors.Is(flagsErr.Type, flags.ErrCommandRequired): writeHelp(parser, stderr) } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 5ca7838..08cef30 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -322,12 +322,13 @@ func TestRunExitCodes(t *testing.T) { assert.Empty(t, r.stdout, r) }) - t.Run("no command prints help and exits 0", func(t *testing.T) { + t.Run("no command prints help to stderr and exits 2", func(t *testing.T) { r := runTx(t, nil) - assert.Equal(t, cli.ExitOK, r.code, r) - assert.Contains(t, r.stdout, "Usage:", r) - assert.Empty(t, r.stderr, r) + assert.Equal(t, cli.ExitUsage, r.code, r) + assert.Contains(t, r.stderr, "Usage:", r) + assert.Contains(t, r.stderr, "Please specify one command of:", r) + assert.Empty(t, r.stdout, r) }) t.Run("unknown flag exits 2", func(t *testing.T) { @@ -366,18 +367,19 @@ func TestRunExitCodes(t *testing.T) { r := runTx(t, f, "token", "create") assert.Equal(t, cli.ExitUsage, r.code, r) - assert.Equal(t, "specify --name in non-interactive mode\n", r.stderr, r) + assert.Equal(t, "specify the token name (tx token create , or --name) in non-interactive mode\n", r.stderr, r) assert.Empty(t, r.requests, r) }) - t.Run("status with a rejected token exits 3", func(t *testing.T) { + t.Run("status with a rejected TX_API_TOKEN exits 3 and names the token", func(t *testing.T) { f := newFakeTexOps(t) f.whoamiStatus = http.StatusUnauthorized r := runTx(t, f, "status") assert.Equal(t, cli.ExitAuth, r.code, r) - assert.Contains(t, r.stderr, "Session expired. Run 'tx login' to re-authenticate.\n", r) + assert.Contains(t, r.stderr, "TX_API_TOKEN was rejected (invalid, expired or deleted); set a valid token, or unset it to use your 'tx login' session\n", r) + assert.NotContains(t, r.stderr, "tx login' to re-authenticate", r) }) t.Run("status without credentials exits 3", func(t *testing.T) { @@ -644,8 +646,8 @@ func TestRunOutputStreams(t *testing.T) { require.Equal(t, cli.ExitOK, r.code, r) assert.Equal(t, - " NAME PREFIX EXPIRES LAST USED CREATED \n"+ - " ci tx_secr 01 Jan 2027 never 01 Oct 2026 \n", + " NAME PREFIX EXPIRES LAST USED CREATED\n"+ + " ci tx_secr 01 Jan 2027 never 01 Oct 2026\n", r.stdout, r) assert.Equal(t, "Loading tokens...\n1 token(s)\n", r.stderr, r) }) diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go index 9ad2d56..3a31d5b 100644 --- a/internal/cli/validate_test.go +++ b/internal/cli/validate_test.go @@ -319,7 +319,7 @@ func TestInitTexlive(t *testing.T) { }) } -func TestTokenListNameColumn(t *testing.T) { +func TestTokenListColumns(t *testing.T) { t.Run("NAME column fits the longest name", func(t *testing.T) { f := newFakeTexOps(t) f.tokens = []map[string]any{ @@ -331,9 +331,26 @@ func TestTokenListNameColumn(t *testing.T) { require.Equal(t, cli.ExitOK, r.code, r) assert.Equal(t, - " NAME PREFIX EXPIRES LAST USED CREATED \n"+ - " github-actions-deploy-prod tx_aaaa 01 Jan 2027 never 01 Oct 2026 \n"+ - " dev tx_bbbb never 05 Oct 2026 02 Oct 2026 \n", + " NAME PREFIX EXPIRES LAST USED CREATED\n"+ + " github-actions-deploy-prod tx_aaaa 01 Jan 2027 never 01 Oct 2026\n"+ + " dev tx_bbbb never 05 Oct 2026 02 Oct 2026\n", + r.stdout, r) + }) + + t.Run("PREFIX column fits real 17-character prefixes", func(t *testing.T) { + f := newFakeTexOps(t) + f.tokens = []map[string]any{ + {"id": "tok_1", "name": "ci", "prefix": "texops_token_ISDR", "expires_at": "2027-01-01T00:00:00Z", "created_at": "2026-10-01T00:00:00Z"}, + {"id": "tok_2", "name": "dev", "prefix": "texops_token_abcd", "last_used_at": "2026-10-05T00:00:00Z", "created_at": "2026-10-02T00:00:00Z"}, + } + + r := runTx(t, f, "token", "list") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.Equal(t, + " NAME PREFIX EXPIRES LAST USED CREATED\n"+ + " ci texops_token_ISDR 01 Jan 2027 never 01 Oct 2026\n"+ + " dev texops_token_abcd never 05 Oct 2026 02 Oct 2026\n", r.stdout, r) }) @@ -348,9 +365,87 @@ func TestTokenListNameColumn(t *testing.T) { require.Equal(t, cli.ExitOK, r.code, r) assert.Equal(t, - " NAME PREFIX EXPIRES LAST USED CREATED \n"+ - " Déploiement tx_aaaa never 03 Oct 2026 01 Oct 2026 \n"+ - " dev tx_bbbb 01 Jan 2027 never 02 Oct 2026 \n", + " NAME PREFIX EXPIRES LAST USED CREATED\n"+ + " Déploiement tx_aaaa never 03 Oct 2026 01 Oct 2026\n"+ + " dev tx_bbbb 01 Jan 2027 never 02 Oct 2026\n", r.stdout, r) }) } + +func TestTokenCreateName(t *testing.T) { + createdWithoutName := map[string]any{"token": "tx_secret_value", "id": "tok_2", "prefix": "tx_secr", "created_at": "2026-10-01T00:00:00Z"} + + t.Run("positional name", func(t *testing.T) { + f := newFakeTexOps(t) + f.createdToken = createdWithoutName + + r := runTx(t, f, "token", "create", "deploy", "--no-expiry", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"name": "deploy", "token": "tx_secret_value", "expires_at": null}`, r.stdout, r) + }) + + t.Run("positional name equal to --name", func(t *testing.T) { + f := newFakeTexOps(t) + f.createdToken = createdWithoutName + + r := runTx(t, f, "token", "create", "deploy", "--name", "deploy", "--no-expiry", "--json") + + require.Equal(t, cli.ExitOK, r.code, r) + assert.JSONEq(t, `{"name": "deploy", "token": "tx_secret_value", "expires_at": null}`, r.stdout, r) + }) + + t.Run("positional name different from --name exits 2", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "create", "deploy", "--name", "ci", "--no-expiry") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "token name given twice: \"deploy\" and --name \"ci\"\n", r.stderr, r) + assert.NotContains(t, strings.Join(f.exchange, "\n"), "POST /auth/tokens", r) + }) + + t.Run("two positional names exit 2", func(t *testing.T) { + f := newFakeTexOps(t) + + r := runTx(t, f, "token", "create", "deploy", "ci", "--no-expiry") + + require.Equal(t, cli.ExitUsage, r.code, r) + assert.Equal(t, "too many arguments: tx token create takes at most one name\n", r.stderr, r) + assert.NotContains(t, strings.Join(f.exchange, "\n"), "POST /auth/tokens", r) + }) +} + +func TestBuildProjectKey(t *testing.T) { + configWithoutKey := "texlive: \"2025\"\ndocuments:\n - name: paper\n main: paper.tex\n" + + t.Run("failed project creation leaves the config untouched", func(t *testing.T) { + f := newFakeTexOps(t) + f.projectStatus = http.StatusBadRequest + f.projectBody = `{"error":"unsupported distribution version \"2099\" (supported: 2025, 2024)"}` + dir := projectDir(t, configWithoutKey) + + r := runTx(t, f, "build") + + require.Equal(t, cli.ExitConfig, r.code, r) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err) + assert.Equal(t, configWithoutKey, string(data), r) + }) + + t.Run("successful project creation saves the key that was sent", func(t *testing.T) { + f := newFakeTexOps(t) + dir := projectDir(t, "---\n"+configWithoutKey) + + r := runTx(t, f, "build") + + require.Equal(t, cli.ExitOK, r.code, r) + data, err := os.ReadFile(filepath.Join(dir, ".texops.yaml")) + require.NoError(t, err) + config, err := cli.ParseConfig(string(data)) + require.NoError(t, err, string(data)) + assert.Len(t, config.ProjectKey, 22, string(data)) + assert.Contains(t, f.exchanges(), config.ProjectKey, r) + assert.True(t, strings.HasPrefix(string(data), "---\nproject_key: "), string(data)) + }) +} diff --git a/man/tx.1 b/man/tx.1 index 672c221..1ec7fbb 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -27,6 +27,7 @@ .Op Fl -name Ar name .Op Fl -expires-in Ar duration .Op Fl -no-expiry +.Op Ar name .Nm .Cm token list .Nm @@ -244,15 +245,25 @@ authentication method, and token expiry. Exits 3 when not authenticated or when the session or token has expired or was rejected .Pq see EXIT STATUS . +When the rejected credential came from +.Ev TX_API_TOKEN , +the message names the variable, since +.Cm tx login +alone does not help while it is set. .Ss token create Create a new API token for use in CI pipelines or other non-interactive environments. The token value is displayed once and cannot be retrieved again. +The name can be given as an argument or with +.Fl -name ; +two different names are a usage error. .Pp Flags: .Bl -tag -width Ds .It Fl -name Ar name -Name for the token. +Name for the token, the same as the +.Ar name +argument. Required in non-interactive mode; prompted interactively otherwise. .It Fl -expires-in Ar duration Token expiry duration. @@ -497,7 +508,7 @@ If omitted, .Cm tx build generates one and writes it back to .Pa .texops.yaml -before building. +once the project has been created. .It Cm texlive .Pq required TeX Live distribution version @@ -737,7 +748,12 @@ failures, or a build that hit the time limit or failed inside the service .Ql timeout .Pc . .It 2 -Usage error: unknown command, missing subcommand or unknown flag, bad flag +Usage error: no command +.Po +.Nm +alone prints the help to standard error +.Pc , +unknown command, missing subcommand or unknown flag, bad flag value .Pq including an unsupported Fl -texlive No or Fl -compiler , unknown document name, diff --git a/man/tx.1.txt b/man/tx.1.txt index 478a718..bccdaa2 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -9,6 +9,7 @@ SYNOPSIS tx build [--live] [--no-cache] [--log=stdout | file] [name ...] tx status tx token create [--name name] [--expires-in duration] [--no-expiry] + [name] tx token list tx token delete [-y | --yes] [name] tx version @@ -157,18 +158,21 @@ COMMANDS Show authentication status including email (when available), authentication method, and token expiry. Exits 3 when not authenticated or when the session or token has expired or was rejected (see EXIT - STATUS). + STATUS). When the rejected credential came from TX_API_TOKEN, the + message names the variable, since tx login alone does not help while it + is set. token create Create a new API token for use in CI pipelines or other non-interactive environments. The token value is displayed once and cannot be retrieved - again. + again. The name can be given as an argument or with --name; two + different names are a usage error. Flags: --name name - Name for the token. Required in non-interactive mode; prompted - interactively otherwise. + Name for the token, the same as the name argument. Required in + non-interactive mode; prompted interactively otherwise. --expires-in duration Token expiry duration. A positive integer followed by ‘d’ (days) @@ -296,7 +300,7 @@ CONFIGURATION project_key Unique identifier for the project. Generated automatically by tx init. If omitted, tx build generates one and writes it back to - .texops.yaml before building. + .texops.yaml once the project has been created. texlive (required) TeX Live distribution version (e.g. "2025"). Must be @@ -458,12 +462,12 @@ EXIT STATUS sync or upload failures, or a build that hit the time limit or failed inside the service (‘internal’, ‘network’, ‘timeout’). - 2 Usage error: unknown command, missing subcommand or unknown flag, - bad flag value (including an unsupported --texlive or - --compiler), unknown document name, --json with --live, missing - required input when there is no terminal to prompt on, or token - delete without --yes when there is no terminal to confirm on - (‘usage’). + 2 Usage error: no command (tx alone prints the help to standard + error), unknown command, missing subcommand or unknown flag, bad + flag value (including an unsupported --texlive or --compiler), + unknown document name, --json with --live, missing required input + when there is no terminal to prompt on, or token delete without + --yes when there is no terminal to confirm on (‘usage’). 3 Not authenticated, or the session or API token has expired or was rejected (‘not_authenticated’). diff --git a/site/docs/cli.md b/site/docs/cli.md index eae8398..e56678a 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -69,19 +69,19 @@ With `--log=file`, a failed build also prints the last 20 lines of the log to st ## `tx status` -Show authentication status including email, authentication method, and token expiry. Exits `3` when not authenticated or when the session or token has expired or was rejected. +Show authentication status including email, authentication method, and token expiry. Exits `3` when not authenticated or when the session or token has expired or was rejected. When the rejected credential came from `TX_API_TOKEN`, the message says so (`TX_API_TOKEN was rejected (invalid, expired or deleted); set a valid token, or unset it to use your 'tx login' session`), since `tx login` alone does not help while the variable is set. -## `tx token create` +## `tx token create [name]` -Create a new API token for CI pipelines or non-interactive use. The token value is printed to stdout once and cannot be retrieved again. +Create a new API token for CI pipelines or non-interactive use. The token value is printed to stdout once and cannot be retrieved again. The name can be given as an argument (`tx token create ci`) or with `--name`; giving two different names exits `2`. | Flag | Description | |------|-------------| -| `--name ` | Name for the token. Required in non-interactive mode. | +| `--name ` | Name for the token, the same as the name argument. Required in non-interactive mode. | | `--expires-in ` | Expiry duration: a positive integer followed by `d` (days) or `y` (years), max 10 years (`3650d` or `10y`). Mutually exclusive with `--no-expiry`. | | `--no-expiry` | Create a token that does not expire. Mutually exclusive with `--expires-in`. | -Without a terminal, `--name` and one of `--expires-in` or `--no-expiry` are required; otherwise `tx token create` exits `2`. +Without a terminal, a name and one of `--expires-in` or `--no-expiry` are required; otherwise `tx token create` exits `2`. ## `tx token list` @@ -287,7 +287,7 @@ Results go to stdout: the build summary, the `tx status` fields, the `tx token l |------|---------|-------------------| | `0` | Success, including `--help` and `tx version`. | | | `1` | Service or unexpected failure: network errors, server errors (5xx), sync or upload failures, a build that hit the time limit or failed inside the service. | `internal`, `network`, `timeout` | -| `2` | Usage error: unknown command, missing subcommand or unknown flag, bad flag value (including an unsupported `--texlive` or `--compiler`), unknown document name, `--json` with `--live`, missing required input when there is no terminal to prompt on, `tx token delete` without `--yes` when there is no terminal to confirm on. | `usage` | +| `2` | Usage error: no command (`tx` alone prints the help to stderr), unknown command, missing subcommand or unknown flag, bad flag value (including an unsupported `--texlive` or `--compiler`), unknown document name, `--json` with `--live`, missing required input when there is no terminal to prompt on, `tx token delete` without `--yes` when there is no terminal to confirm on. | `usage` | | `3` | Not authenticated, or the session or API token has expired or was rejected. | `not_authenticated` | | `4` | Project configuration error: `.texops.yaml` is missing or invalid, or its TeX Live version is not supported. | `config` | | `5` | A document failed to compile (LaTeX errors, or no PDF was produced). | `build_failed` | diff --git a/site/docs/configuration.md b/site/docs/configuration.md index 9382783..7785e79 100644 --- a/site/docs/configuration.md +++ b/site/docs/configuration.md @@ -20,7 +20,7 @@ documents: | Key | Required | Description | |-----|----------|-------------| -| `project_key` | no | Unique project identifier. Generated by `tx init`. If missing, `tx build` generates one and writes it back. | +| `project_key` | no | Unique project identifier. Generated by `tx init`. If missing, `tx build` generates one and writes it back once the project has been created. | | `texlive` | **yes** | TeX Live distribution version, e.g. `"2025"`. Supported versions: 2013 through 2025 (full yearly range). | | `compiler` | no | LaTeX compiler. Defaults to `pdflatex`. | | `documents` | **yes** | A non-empty list of document definitions. | From 94ca02d648004f3cff223bbc32c4f0f82768b802 Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Sat, 10 Oct 2026 00:23:01 +0100 Subject: [PATCH 09/11] Rename --log=stdout to --log=terminal The streamed log goes to stderr, so stdout was a misleading name. --- internal/cli/buildlog.go | 8 ++--- internal/cli/commands.go | 2 +- internal/cli/commands_test.go | 46 ++++++++++++------------- internal/cli/diagnostics_test.go | 10 +++--- internal/cli/run_test.go | 8 ++--- internal/cli/testdata/help-build.golden | 28 +++++++-------- man/tx.1 | 8 ++--- man/tx.1.txt | 8 ++--- site/docs/agents.md | 2 +- site/docs/cli.md | 2 +- 10 files changed, 61 insertions(+), 61 deletions(-) diff --git a/internal/cli/buildlog.go b/internal/cli/buildlog.go index fd5eb3b..b6b20ed 100644 --- a/internal/cli/buildlog.go +++ b/internal/cli/buildlog.go @@ -14,14 +14,14 @@ import ( ) const ( - LogStdout = "stdout" - LogFile = "file" + LogTerminal = "terminal" + LogFile = "file" ) const logTailLines = 20 // resolveLogMode returns the --log value, defaulting to file under a coding -// agent or with --json and to stdout otherwise. +// agent or with --json and to terminal otherwise. func resolveLogMode(flag string, jsonMode bool, getenv func(string) string) string { if flag != "" { return flag @@ -29,7 +29,7 @@ func resolveLogMode(flag string, jsonMode bool, getenv func(string) string) stri if jsonMode || DetectAgent(getenv) != "" { return LogFile } - return LogStdout + return LogTerminal } // logFilePaths maps each document name to the project-relative path of its diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 111e3ee..7e5dbf9 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -102,7 +102,7 @@ type BuildCmd struct { } `positional-args:"true"` NoCache bool `long:"no-cache" description:"Clear the remote build cache and rebuild from scratch"` Live bool `long:"live" description:"Watch for changes and rebuild until interrupted with Ctrl+C; for people, not for scripts or agents; cannot be combined with --json"` - Log string `long:"log" choice:"stdout" choice:"file" description:"Where the LaTeX log goes: stdout streams it, file saves it to .texops/logs/.log (default: file under a coding agent or with --json, otherwise stdout)"` + Log string `long:"log" choice:"terminal" choice:"file" description:"Where the LaTeX log goes: terminal streams it to stderr, file saves it to .texops/logs/.log (default: file under a coding agent or with --json, otherwise terminal)"` UI *UI `no-flag:"true"` } diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 3f8fcc0..f61a849 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -924,7 +924,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) written, err := os.ReadFile(filepath.Join(dir, "paper.pdf")) @@ -1014,7 +1014,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) assert.Equal(t, "prj_auto123", createdProjectID) @@ -1057,7 +1057,7 @@ documents: t.Setenv("TX_API_URL", apiSrv.URL) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, 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) @@ -1078,7 +1078,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(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") }) @@ -1092,7 +1092,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { ui := cli.NewUIWithOptions(buf, true, &enterReader{first: "y\n"}) // Build will init then fail on auth — that's fine, we just check init happened. - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) configData, readErr := os.ReadFile(filepath.Join(dir, ".texops.yaml")) require.NoError(t, readErr, buf.String()) @@ -1111,7 +1111,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { buf := &bytes.Buffer{} ui := cli.NewUIWithTTYOptions(buf, true, false, strings.NewReader("y\n")) - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err, buf.String()) assert.Equal(t, cli.ExitConfig, cli.AsExitError(err).Code, buf.String()) @@ -1128,7 +1128,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { in := strings.NewReader("n\n") ui := cli.NewUIWithOptions(buf, true, in) - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") @@ -1220,7 +1220,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, true, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, true, false, cli.LogTerminal, ui) require.NoError(t, err) require.NotNil(t, receivedBuildOptions, "build_options should be sent in request") assert.Equal(t, "true", receivedBuildOptions["no_cache"]) @@ -1304,7 +1304,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, 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") @@ -1412,7 +1412,7 @@ documents: )) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) output := buf.String() @@ -1431,7 +1431,7 @@ documents: dir, requests := startSetup(t, sse(`event: done`+"\n"+`data: {"status":"success"}`)) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) assert.NotContains(t, buf.String(), "Sandbox ready") @@ -1443,7 +1443,7 @@ documents: dir, requests := startSetup(t, nil) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) assert.NotContains(t, buf.String(), "Sandbox ready") @@ -1459,7 +1459,7 @@ documents: )) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err) assert.Contains(t, buf.String(), "Failed to start sandbox: failed to set up TeX Live 2023") @@ -1605,7 +1605,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) // Should get exactly one session and one sync for same-version docs @@ -1640,7 +1640,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) // Should get two sessions (one per version) and two syncs @@ -1665,7 +1665,7 @@ documents: s := multiDocSetup(t, config, "") ui, _ := testUI() - err := cli.RunBuild(t.Context(), s.dir, []string{"paper"}, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), s.dir, []string{"paper"}, false, false, cli.LogTerminal, ui) require.NoError(t, err) // Only one document should be built @@ -1684,7 +1684,7 @@ documents: os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte(config), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, []string{"nonexistent"}, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, []string{"nonexistent"}, false, false, cli.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "unknown document") assert.Contains(t, err.Error(), "nonexistent") @@ -1702,7 +1702,7 @@ documents: s := multiDocSetup(t, config, "slides.tex") // slides will fail ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "one or more documents failed to build") @@ -1728,7 +1728,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) output := buf.String() @@ -1751,7 +1751,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(t.Context(), s.dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) assert.Len(t, *s.buildRequests, 2) @@ -1851,7 +1851,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) assert.Equal(t, "xelatex", receivedCompiler, "compiler from config should be sent in build request") @@ -1945,7 +1945,7 @@ documents: os.WriteFile(filepath.Join(dir, "slides.tex"), []byte(`\documentclass{beamer}\begin{document}Slides\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) require.Len(t, receivedCompilers, 2) @@ -2032,7 +2032,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.NoError(t, err) assert.Equal(t, "pdflatex", receivedCompiler, "default compiler should be pdflatex") diff --git a/internal/cli/diagnostics_test.go b/internal/cli/diagnostics_test.go index 6d4a8ea..cc4a2e4 100644 --- a/internal/cli/diagnostics_test.go +++ b/internal/cli/diagnostics_test.go @@ -436,7 +436,7 @@ func TestBuildLogMode(t *testing.T) { r := runTx(t, f, "build", "--log", "stderr") assert.Equal(t, cli.ExitUsage, r.code, r) - assert.Equal(t, "Invalid value `stderr' for option `--log'. Allowed values are: stdout or file\n", r.stderr, r) + assert.Equal(t, "Invalid value `stderr' for option `--log'. Allowed values are: terminal or file\n", r.stderr, r) assert.Empty(t, r.requests, r) }) @@ -455,14 +455,14 @@ func TestBuildLogMode(t *testing.T) { assert.Contains(t, r.stderr, "Last 3 lines of .texops/logs/paper.log:\n", r) }) - t.Run("--log=stdout overrides agent detection", func(t *testing.T) { + t.Run("--log=terminal overrides agent detection", func(t *testing.T) { t.Setenv("CLAUDECODE", "1") f := newFakeTexOps(t) f.done["paper.tex"] = latexErrorDone() f.logs["bld_1"] = numberedLog(3) projectDir(t, projectConfig) - r := runTx(t, f, "build", "paper", "--log", "stdout") + r := runTx(t, f, "build", "paper", "--log", "terminal") require.Equal(t, cli.ExitBuildFailed, r.code, r) assert.Contains(t, r.stderr, " latexmk output for paper.tex\n", r) @@ -587,13 +587,13 @@ func TestBuildDiagnosticsJSON(t *testing.T) { assert.Equal(t, numberedLog(2), readFile(t, filepath.Join(".texops", "logs", "paper.log"), r), r) }) - t.Run("--json with --log=stdout writes no log file", func(t *testing.T) { + t.Run("--json with --log=terminal writes no log file", func(t *testing.T) { f := newFakeTexOps(t) f.done["paper.tex"] = latexErrorDone() f.logs["bld_1"] = numberedLog(3) projectDir(t, projectConfig) - r := runTx(t, f, "build", "paper", "--json", "--log=stdout") + r := runTx(t, f, "build", "paper", "--json", "--log=terminal") require.Equal(t, cli.ExitBuildFailed, r.code, r) var doc struct { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 08cef30..5f1ac60 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -681,7 +681,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, cli.LogTerminal, ui) require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) assert.Equal(t, cli.KindBuildFailed, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) @@ -693,7 +693,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, []string{"paper"}, false, false, cli.LogTerminal, ui) require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) assert.Equal(t, cli.KindTimeout, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) @@ -707,7 +707,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err, buf.String()) assert.Equal(t, cli.KindNetwork, cli.AsExitError(err).Kind, buf.String()) @@ -720,7 +720,7 @@ func TestRunBuildErrorKind(t *testing.T) { dir := projectDir(t, projectConfig) ui, buf := testUI() - err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogStdout, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, cli.LogTerminal, ui) require.Error(t, err, "%s\n%s", buf.String(), f.exchanges()) assert.Equal(t, cli.KindConfig, cli.AsExitError(err).Kind, "%s\n%s", buf.String(), f.exchanges()) diff --git a/internal/cli/testdata/help-build.golden b/internal/cli/testdata/help-build.golden index 19075f3..6dc3c2b 100644 --- a/internal/cli/testdata/help-build.golden +++ b/internal/cli/testdata/help-build.golden @@ -16,23 +16,23 @@ Examples: tx build --live Application Options: - --json Print exactly one JSON document to stdout, also - on failure; progress and errors go to stderr + --json Print exactly one JSON document to stdout, also + on failure; progress and errors go to stderr Help Options: - -h, --help Show this help message + -h, --help Show this help message [build command options] - --no-cache Clear the remote build cache and rebuild from - scratch - --live Watch for changes and rebuild until interrupted - with Ctrl+C; for people, not for scripts or - agents; cannot be combined with --json - --log=[stdout|file] Where the LaTeX log goes: stdout streams it, file - saves it to .texops/logs/.log (default: file - under a coding agent or with --json, otherwise - stdout) + --no-cache Clear the remote build cache and rebuild from + scratch + --live Watch for changes and rebuild until interrupted + with Ctrl+C; for people, not for scripts or + agents; cannot be combined with --json + --log=[terminal|file] Where the LaTeX log goes: terminal streams it + to stderr, file saves it to + .texops/logs/.log (default: file under a + coding agent or with --json, otherwise terminal) [build command arguments] - name: Documents to build, by their name in - .texops.yaml (default: all) + name: Documents to build, by their name in + .texops.yaml (default: all) diff --git a/man/tx.1 b/man/tx.1 index 1ec7fbb..f38626f 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -18,7 +18,7 @@ .Cm build .Op Fl -live .Op Fl -no-cache -.Op Fl -log Ns = Ns Cm stdout | file +.Op Fl -log Ns = Ns Cm terminal | file .Op Ar name ... .Nm .Cm status @@ -187,9 +187,9 @@ Otherwise the build fails with exit status 4. .Pp Flags: .Bl -tag -width Ds -.It Fl -log Ns = Ns Cm stdout | file +.It Fl -log Ns = Ns Cm terminal | file Where the LaTeX log goes. -.Cm stdout +.Cm terminal streams it to standard error while the document compiles and saves nothing. .Cm file streams nothing and saves the full log to @@ -202,7 +202,7 @@ under a coding agent or with .Fl -json , and to -.Cm stdout +.Cm terminal otherwise. .It Fl -live Watch for file changes and rebuild automatically. diff --git a/man/tx.1.txt b/man/tx.1.txt index bccdaa2..68ff4f2 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -6,7 +6,7 @@ NAME SYNOPSIS tx login [--no-browser] [--timeout duration] tx init [--texlive version] [--compiler name] [--main file] - tx build [--live] [--no-cache] [--log=stdout | file] [name ...] + tx build [--live] [--no-cache] [--log=terminal | file] [name ...] tx status tx token create [--name name] [--expires-in duration] [--no-expiry] [name] @@ -120,13 +120,13 @@ COMMANDS Flags: - --log=stdout | file - Where the LaTeX log goes. stdout streams it to standard error + --log=terminal | file + Where the LaTeX log goes. terminal streams it to standard error while the document compiles and saves nothing. file streams nothing and saves the full log to .texops/logs/.log after each build; a failed build also prints the last 20 lines of the log. Defaults to file under a coding agent (see TX_AGENT) or - with --json, and to stdout otherwise. + with --json, and to terminal otherwise. --live Watch for file changes and rebuild automatically. After the initial build, the command monitors the project directory for diff --git a/site/docs/agents.md b/site/docs/agents.md index 6a16bb9..5130a9e 100644 --- a/site/docs/agents.md +++ b/site/docs/agents.md @@ -71,7 +71,7 @@ Every field is described in the [CLI reference](cli.md#json-output). When `tx` detects a coding agent, `tx build` saves the full LaTeX log to `.texops/logs/.log` instead of streaming it, and prints only the diagnostics and, on failure, the last 20 lines of the log. `--json` does the same. `.texops/` ignores itself in Git and is never uploaded. -`tx` recognizes an agent by `AI_AGENT`, `CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT` or `GEMINI_CLI` in the environment. Set `TX_AGENT=none` to turn detection off, or pass `--log=stdout` or `--log=file` to choose explicitly. +`tx` recognizes an agent by `AI_AGENT`, `CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT` or `GEMINI_CLI` in the environment. Set `TX_AGENT=none` to turn detection off, or pass `--log=terminal` or `--log=file` to choose explicitly. ## Exit codes diff --git a/site/docs/cli.md b/site/docs/cli.md index e56678a..21ea656 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -49,7 +49,7 @@ If `.texops.yaml` does not exist and both stdin and stdout are a TTY, an interac | Flag | Description | |------|-------------| | `--no-cache` | Rebuild without using the remote build cache. | -| `--log=stdout\|file` | Where the LaTeX log goes. `stdout` streams it while the document compiles (to stderr, next to the progress lines) and saves nothing. `file` streams nothing and saves the full log to `.texops/logs/.log` after each build. Defaults to `file` under a coding agent (see [`TX_AGENT`](#environment-variables)) or with `--json`, and to `stdout` otherwise. | +| `--log=terminal\|file` | Where the LaTeX log goes. `terminal` streams it while the document compiles (to stderr, next to the progress lines) and saves nothing. `file` streams nothing and saves the full log to `.texops/logs/.log` after each build. Defaults to `file` under a coding agent (see [`TX_AGENT`](#environment-variables)) or with `--json`, and to `terminal` otherwise. | | `--live` | Watch for file changes and rebuild automatically. On each change, files are re-synced, the document is rebuilt, and the output PDF is rewritten in place so viewers like Skim refresh automatically. Runs until interrupted with Ctrl+C, so it is meant for people, not for scripts or agents. Cannot be combined with `--json` (exit `2`). | ### Diagnostics From 4b500fd2e79b476a78fc7e5870ab1dd78565aadd Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Sat, 10 Oct 2026 00:41:03 +0100 Subject: [PATCH 10/11] Read build events longer than 64 KiB The done event carries the build's diagnostics on one SSE data line, which could pass bufio.Scanner's default 64 KiB limit and fail a successful build with "token too long". Lines up to 8 MiB are now read. --- internal/cli/client.go | 4 ++++ internal/cli/client_test.go | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/internal/cli/client.go b/internal/cli/client.go index 1e8b4b2..c8ba22f 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -837,6 +837,9 @@ func ParseSSEStream(reader io.Reader, onLog func(string)) (BuildDoneEvent, error return ParseSSEEvents(reader, onLog, onLog) } +// A done event carries the build's diagnostics on one data line. +const maxSSELineBytes = 8 << 20 + func ParseSSEEvents(reader io.Reader, onLog, onQueued func(string)) (BuildDoneEvent, error) { result := BuildDoneEvent{ Status: "error", @@ -844,6 +847,7 @@ func ParseSSEEvents(reader io.Reader, onLog, onQueued func(string)) (BuildDoneEv } scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64<<10), maxSSELineBytes) var eventType, eventData string for scanner.Scan() { diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index 20f6078..c0bbb99 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -567,6 +567,16 @@ func TestParseSSEStream(t *testing.T) { assert.Equal(t, cli.BuildDoneEvent{Status: "error", Message: "build failed with exit code 12", BuildID: "bld_1"}, result, input) }) + t.Run("done event longer than 64KB", func(t *testing.T) { + message := strings.Repeat("x", 100<<10) + input := "event: done\ndata: {\"status\":\"success\",\"message\":\"" + message + "\"}\n\n" + + result, err := cli.ParseSSEStream(strings.NewReader(input), nil) + + require.NoError(t, err) + assert.Equal(t, cli.BuildDoneEvent{Status: "success", Message: message}, result) + }) + t.Run("handles queued event", func(t *testing.T) { input := "event: queued\ndata: {\"message\":\"build queued, waiting for previous build to finish\"}\n\nevent: log\ndata: {\"message\":\"Starting build\"}\n\nevent: done\ndata: {\"status\":\"success\"}\n\n" reader := strings.NewReader(input) From 7e15fb40a8085f9b822b4bb6a93ffdc7d22ef61b Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Sat, 10 Oct 2026 00:41:03 +0100 Subject: [PATCH 11/11] Say truncated means more diagnostics than the service sent The service now also cuts the list to fit its size limit, so truncated can be set with fewer than 50 diagnostics. --- man/tx.1 | 2 +- man/tx.1.txt | 2 +- site/docs/cli.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/man/tx.1 b/man/tx.1 index f38626f..b7ee45e 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -404,7 +404,7 @@ and .Cm context .Pq errors: the source text TeX showed . .It Cm truncated -True when the service found more than 50 diagnostics. +True when the service found more diagnostics than it sent. .El .Pp The other commands print: diff --git a/man/tx.1.txt b/man/tx.1.txt index 68ff4f2..8ca785f 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -255,7 +255,7 @@ JSON OUTPUT TeX showed). truncated - True when the service found more than 50 diagnostics. + True when the service found more diagnostics than it sent. The other commands print: diff --git a/site/docs/cli.md b/site/docs/cli.md index 21ea656..5c75a30 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -63,7 +63,7 @@ Build complete: 0 succeeded, 1 failed (4.2s) sec/intro.tex:1: warning: Citation `missing' undefined ``` -Each diagnostic is `file:line: severity: message`, leaving out what the service could not determine. File paths are relative to the project root. The `(log: ...)` part appears only when the log was saved. Successful documents list their warnings too. When the service found more than 50 diagnostics, a last line says that more are in the log. +Each diagnostic is `file:line: severity: message`, leaving out what the service could not determine. File paths are relative to the project root. The `(log: ...)` part appears only when the log was saved. Successful documents list their warnings too. When the service found more diagnostics than it sent, a last line says that more are in the log. With `--log=file`, a failed build also prints the last 20 lines of the log to stderr. The first time a log is saved, `tx` creates `.texops/.gitignore` containing `*`, so the directory never shows up in Git. `.texops/` is never uploaded. @@ -160,7 +160,7 @@ A build that ran prints a build document, also when documents failed: | `documents[].duration_ms` | Time spent on this document. | | `documents[].errors` | Error diagnostics (may be empty). | | `documents[].warnings` | Warning diagnostics (may be empty). | -| `documents[].truncated` | `true` when the service found more than 50 diagnostics. | +| `documents[].truncated` | `true` when the service found more diagnostics than it sent. | Each diagnostic has these fields; the optional ones are left out when unknown: