diff --git a/README.md b/README.md index 60b8c31..25aceee 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,18 @@ 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] # Delete an API token +tx login [--no-browser] [--timeout ] # Authenticate with TexOps +tx init [--texlive ] [--compiler ] # Initialize a project in the current directory +tx build # Build all documents +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] # 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 @@ -45,12 +48,31 @@ 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 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. + +| 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, 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 | + +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/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/buildlog.go b/internal/cli/buildlog.go new file mode 100644 index 0000000..b6b20ed --- /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 ( + LogTerminal = "terminal" + LogFile = "file" +) + +const logTailLines = 20 + +// resolveLogMode returns the --log value, defaulting to file under a coding +// agent or with --json and to terminal otherwise. +func resolveLogMode(flag string, jsonMode bool, getenv func(string) string) string { + if flag != "" { + return flag + } + if jsonMode || DetectAgent(getenv) != "" { + return LogFile + } + return LogTerminal +} + +// 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 6fe0e5d..c8ba22f 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -19,14 +19,23 @@ 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 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 { @@ -51,10 +60,24 @@ 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"` + 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. +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 { @@ -86,6 +109,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, "/"), @@ -117,7 +149,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 } @@ -131,7 +163,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 @@ -151,7 +183,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 } @@ -165,7 +197,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 @@ -176,7 +208,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 } @@ -188,7 +220,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 @@ -204,7 +236,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 } @@ -228,12 +260,12 @@ 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)} } } 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 } @@ -246,7 +278,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 @@ -264,7 +296,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 } @@ -324,7 +356,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 } @@ -352,7 +384,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 } @@ -375,11 +407,41 @@ 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") } - 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 } @@ -425,7 +487,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 } @@ -441,7 +503,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) @@ -466,7 +528,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 } @@ -480,7 +542,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 @@ -576,7 +638,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 } @@ -590,53 +652,73 @@ 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 } 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) +} + +// 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 := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) + 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{}, fmt.Errorf("build request failed (%d): %s", resp.StatusCode, 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, @@ -647,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 } @@ -654,7 +739,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 } @@ -668,35 +753,10 @@ 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 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 := http.NewRequestWithContext(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 fmt.Errorf("PDF download failed (%d)", resp.StatusCode) + return BuildDoneEvent{}, &APIError{Op: "build request", StatusCode: resp.StatusCode, Body: readErrorBody(resp)} } - 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 { @@ -715,7 +775,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 } @@ -730,7 +790,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 } @@ -774,12 +834,20 @@ func writeFilePreserveInode(r io.Reader, outputPath string) error { } 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", Message: "Stream ended unexpectedly", } scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64<<10), maxSSELineBytes) var eventType, eventData string for scanner.Scan() { @@ -793,8 +861,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 04c829e..c0bbb99 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,45 @@ 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("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) @@ -550,6 +589,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) { @@ -746,6 +801,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") @@ -839,7 +927,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) @@ -876,7 +964,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"]) @@ -905,7 +993,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") @@ -934,7 +1022,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"]) @@ -963,7 +1051,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 06a2051..7e5dbf9 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -6,11 +6,13 @@ import ( "context" "errors" "fmt" + "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" + "slices" "strconv" "strings" "time" @@ -39,6 +41,7 @@ func openBrowser(url string) error { var PollInterval = 5 * time.Second type Options struct { + 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"` @@ -53,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, 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"` } @@ -64,7 +67,16 @@ type TokenListCmd struct { } type TokenDeleteCmd struct { - UI *UI `no-flag:"true"` + 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 *TokenCreateCmd) Usage() string { + return "[create-OPTIONS] [name]" +} + +func (cmd *TokenDeleteCmd) Usage() string { + return "[delete-OPTIONS] [name]" } type StatusCmd struct { @@ -72,21 +84,26 @@ 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" 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 (pdflatex, xelatex, lualatex, latex, platex, 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"` - 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:"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"` } var NewInstanceClientFn = func(instanceURL, jwt string) *InstanceClient { @@ -97,10 +114,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 { @@ -108,6 +140,8 @@ type buildParams struct { docs []Document api *APIClient noCache bool + logMode string + logPaths map[string]string ui *UI projectID string } @@ -139,12 +173,20 @@ func (cmd *StatusCmd) Execute(args []string) error { ui = defaultUI() } - token, err := ResolveAuth() + res, err := checkStatus(ui) if err != nil { - ui.Errorf("Not authenticated.") - ui.Log("") - ui.Log("Run 'tx login' to log in to TexOps.") - return 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 statusResult{}, err } apiURL := ResolveAPIURL(Config{}) @@ -154,29 +196,23 @@ 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 { + 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.")) } - ui.Errorf("Error: %s", err) - 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 { @@ -184,63 +220,66 @@ 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 { - 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") - // 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(fmt.Sprintf("Token polling failed: %s", err)) - return err } sp.Stop("Authorized") - // Step 4: Store JWT if err := storeJWT(tokenResp.JWT); err != nil { return err } - ui.Success("Logged in successfully") - return nil + return renderLogin(ui, loginResult{Authenticated: true}) } // DiscoverDocuments recursively finds .tex files containing \documentclass @@ -419,30 +458,56 @@ 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, ", ")) + } + + 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, ", ")) } - return initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, ui) + res, err := initProject(dir, cmd.Texlive, cmd.Compiler, cmd.Main, dists, ui) + if err != nil { + return err + } + return renderInit(ui, res) +} + +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, ui *UI) error { +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 fmt.Errorf("TexLive selection failed: %w", err) + return initResult{}, fmt.Errorf("TexLive selection failed: %w", err) } - texlive = TexliveVersions[idx] + texlive = dists.Versions[idx] } else { - texlive = TexliveVersions[0] + texlive = dists.Default } } @@ -450,7 +515,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 { @@ -460,7 +525,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 @@ -468,10 +533,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 @@ -485,20 +550,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. @@ -526,6 +596,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 @@ -534,7 +607,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 } @@ -543,28 +617,34 @@ 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") configData, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { - return err + return configError(err) } - if !ui.IsTTY() { - return errInitDeclined + if !ui.IsInteractive() { + 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 { + res, initErr := initProject(dir, "", "", "main.tex", texliveDistributions(ctx), 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 @@ -573,7 +653,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 +662,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) } @@ -597,33 +677,28 @@ func runBuild(ctx context.Context, dir string, names []string, noCache bool, liv 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 } 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") + + if newKey { + if err := writeProjectKey(configPath, configData, config.ProjectKey); err != nil { + return err + } + } projectID := project.ID p := buildParams{ @@ -631,6 +706,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, } @@ -639,37 +716,39 @@ 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.Log("") - ui.Status(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 { 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) +} + +// 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 +} +// 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 } @@ -681,7 +760,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 } @@ -729,8 +808,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: err}) + results = append(results, failedDoc(doc, err, failureReason(err, "internal"))) } continue } @@ -743,7 +823,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 } @@ -757,7 +837,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 } @@ -770,13 +850,13 @@ 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 } 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() } @@ -870,40 +950,68 @@ 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)) +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() - result, err := inst.Build(ctx, projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { - ui.Log(line) - }) + 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, 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 docResult{Name: doc.Name, Output: doc.Output, Err: err} + 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.Success(fmt.Sprintf("Build complete (%.1fs)", compileElapsed.Seconds())) - outputPath := filepath.Join(dir, doc.Output) + ui.StepSuccess(fmt.Sprintf("Build complete (%.1fs)", compileElapsed.Seconds())) + outputPath := filepath.Join(p.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 { + if err := inst.DownloadPDF(ctx, p.projectID, result.BuildID, outputPath); err != nil { sp.Fail(fmt.Sprintf("Download failed: %s", err)) - return docResult{Name: doc.Name, Output: doc.Output, Err: err} + return fail(err, failureReason(err, "internal")) } info, _ := os.Stat(outputPath) @@ -914,15 +1022,28 @@ 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, + 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) - return docResult{Name: doc.Name, Output: doc.Output, Err: fmt.Errorf("build failed: %s", msg)} + return fail(buildFailureError(result, fmt.Errorf("build failed: %s", msg)), buildFailureReason(result)) } // docNames returns a comma-separated list of document names. @@ -982,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.IsTTY() { - return fmt.Errorf("specify --name in non-interactive mode") + if !ui.IsInteractive() { + return usageErrorf("specify the token name (tx token create , or --name) in non-interactive mode") } name, err := ui.TextInput("Token name:") if err != nil { @@ -997,7 +1125,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 +1133,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") + if !ui.IsInteractive() { + return usageErrorf("specify --expires-in or --no-expiry in non-interactive mode") } // Interactive selection labels := make([]string, len(expiryOptions)) @@ -1041,21 +1169,17 @@ 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.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,26 +1200,11 @@ 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))) - 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 { @@ -1104,15 +1213,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 fmt.Errorf("specify token name as argument in non-interactive mode") - } - apiURL := ResolveAPIURL(Config{}) api := NewAPIClient(apiURL, authToken) @@ -1120,7 +1235,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 +1252,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 { @@ -1157,14 +1271,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...") @@ -1172,13 +1287,22 @@ 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") - return nil + 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 dc4756c..f61a849 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) { @@ -438,20 +627,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 +653,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 +661,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 +678,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 +699,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 +717,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 +731,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 +739,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 +748,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 +766,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 +788,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 +805,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 +824,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) }) } @@ -725,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.LogTerminal, ui) require.NoError(t, err) written, err := os.ReadFile(filepath.Join(dir, "paper.pdf")) @@ -815,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.LogTerminal, ui) require.NoError(t, err) assert.Equal(t, "prj_auto123", createdProjectID) @@ -858,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.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) @@ -879,31 +1078,46 @@ 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.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") }) 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) + 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) - 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, cli.LogTerminal, 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) { @@ -914,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.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") @@ -1006,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.LogTerminal, ui) require.NoError(t, err) require.NotNil(t, receivedBuildOptions, "build_options should be sent in request") assert.Equal(t, "true", receivedBuildOptions["no_cache"]) @@ -1090,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.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") @@ -1198,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.LogTerminal, ui) require.NoError(t, err) output := buf.String() @@ -1217,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.LogTerminal, ui) require.NoError(t, err) assert.NotContains(t, buf.String(), "Sandbox ready") @@ -1229,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.LogTerminal, ui) require.NoError(t, err) assert.NotContains(t, buf.String(), "Sandbox ready") @@ -1245,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.LogTerminal, ui) require.Error(t, err) assert.Contains(t, buf.String(), "Failed to start sandbox: failed to set up TeX Live 2023") @@ -1391,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.LogTerminal, ui) require.NoError(t, err) // Should get exactly one session and one sync for same-version docs @@ -1426,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.LogTerminal, ui) require.NoError(t, err) // Should get two sessions (one per version) and two syncs @@ -1451,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.LogTerminal, ui) require.NoError(t, err) // Only one document should be built @@ -1470,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.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "unknown document") assert.Contains(t, err.Error(), "nonexistent") @@ -1488,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.LogTerminal, ui) require.Error(t, err) assert.Contains(t, err.Error(), "one or more documents failed to build") @@ -1502,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) { @@ -1514,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.LogTerminal, ui) require.NoError(t, err) output := buf.String() @@ -1537,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.LogTerminal, ui) require.NoError(t, err) assert.Len(t, *s.buildRequests, 2) @@ -1637,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.LogTerminal, ui) require.NoError(t, err) assert.Equal(t, "xelatex", receivedCompiler, "compiler from config should be sent in build request") @@ -1731,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.LogTerminal, ui) require.NoError(t, err) require.Len(t, receivedCompilers, 2) @@ -1818,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.LogTerminal, ui) require.NoError(t, err) assert.Equal(t, "pdflatex", receivedCompiler, "default compiler should be pdflatex") @@ -1941,11 +2155,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: 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("API returns 401 suggests re-login", func(t *testing.T) { @@ -1962,11 +2175,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 +2195,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 +2390,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) { @@ -2233,7 +2449,33 @@ 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) { + 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 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) { + 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) { @@ -2387,13 +2629,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") }) @@ -2419,7 +2660,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/diagnostics_test.go b/internal/cli/diagnostics_test.go new file mode 100644 index 0000000..cc4a2e4 --- /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: terminal 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=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", "terminal") + + 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=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=terminal") + + 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/exit.go b/internal/cli/exit.go new file mode 100644 index 0000000..7e0f287 --- /dev/null +++ b/internal/cli/exit.go @@ -0,0 +1,141 @@ +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 +} + +// 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 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} + } + 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..a60480b 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" @@ -77,33 +78,52 @@ 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) { - // 1. API token from environment variable (highest priority override) + token, _, err := resolveAuthWithSource() + return token, err +} + +const ( + authSourceEnv = "env" + authSourceFile = "file" + 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) { if token := os.Getenv("TX_API_TOKEN"); token != "" { - return token, nil + 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, 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, 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 "", fmt.Errorf("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/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/help.go b/internal/cli/help.go new file mode 100644 index 0000000..e974b29 --- /dev/null +++ b/internal/cli/help.go @@ -0,0 +1,169 @@ +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 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, the name +(as an argument or --name) and either --expires-in or --no-expiry are +required (exit 2 otherwise). + +Examples: + 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. + +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..b88e1b8 --- /dev/null +++ b/internal/cli/help_test.go @@ -0,0 +1,114 @@ +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 to stderr and exits 2", func(t *testing.T) { + help := runTx(t, nil, "--help") + r := runTx(t, nil) + + 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) { + 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, 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 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/json_test.go b/internal/cli/json_test.go new file mode 100644 index 0000000..f11de5b --- /dev/null +++ b/internal/cli/json_test.go @@ -0,0 +1,561 @@ +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: 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) { + 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\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) + }) +} + +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 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) { + f := newFakeTexOps(t) + + 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) + 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) { + f := newFakeTexOps(t) + dir := projectDir(t, "") + + 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) + 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) + f := newFakeTexOps(t) + + r := runTx(t, f, "--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, "Open "+f.srv.URL+"/verify and enter 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/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 new file mode 100644 index 0000000..7d159fb --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,360 @@ +package cli + +import ( + "fmt" + "path/filepath" + "strings" + "time" + "unicode/utf8" +) + +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() { + for _, r := range results { + for _, line := range docSummaryLines(r) { + ui.Log(line) + } + } + 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 { + 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 { + 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 + } + rows := [][]string{{"NAME", "PREFIX", "EXPIRES", "LAST USED", "CREATED"}} + for _, tok := range tokens { + rows = append(rows, []string{ + tok.Name, + tok.Prefix, + formatDatePtr(tok.ExpiresAt, "never"), + formatDatePtr(tok.LastUsedAt, "never"), + formatDate(tok.CreatedAt), + }) + } + 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"` + 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 new file mode 100644 index 0000000..eaf7923 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,80 @@ +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 { + SetVersion(version) + + 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" + setupHelp(parser) + 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 { + return ExitOK + } + + if flagsErr, ok := errors.AsType[*flags.Error](err); ok { + switch { + case errors.Is(flagsErr.Type, flags.ErrHelp): + writeHelp(parser, stdout) + return ExitOK + case errors.Is(flagsErr.Type, flags.ErrCommandRequired): + writeHelp(parser, stderr) + } + 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 new file mode 100644 index 0000000..5f1ac60 --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,796 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "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 + instanceURL string + tokens []map[string]any + 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 + userAgents []requestUA +} + +type requestUA struct { + request string + userAgent string +} + +func newFakeTexOps(t *testing.T) *fakeTexOps { + t.Helper() + 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) + 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())) + 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) { + 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": + 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 { + 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/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": + 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 { + 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 + } + 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) + 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") + 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) + } +} + +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") +} + +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 + 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 to stderr and exits 2", func(t *testing.T) { + r := runTx(t, nil) + + 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) { + 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) { + f := newFakeTexOps(t) + projectDir(t, "") + + r := runTx(t, f, "init", "--compiler", "tex") + + assert.Equal(t, cli.ExitUsage, r.code, 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) { + 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 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 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, "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) { + noCredentials(t) + + r := runTx(t, nil, "status") + + assert.Equal(t, cli.ExitAuth, r.code, 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) + }) + + 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 (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) + }) + + 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, 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()) + }) + + 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, 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()) + }) + + 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, cli.LogTerminal, 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, 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()) + }) +} + +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/internal/cli/testdata/help-build.golden b/internal/cli/testdata/help-build.golden new file mode 100644 index 0000000..6dc3c2b --- /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=[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) 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/internal/cli/ui.go b/internal/cli/ui.go index 3e952bf..00da2a6 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "encoding/json" "fmt" "io" "os" @@ -28,14 +29,20 @@ 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 + json bool + wroteJSON bool } func newStyles(hasDarkBG bool, r *lipgloss.Renderer) uiStyles { @@ -61,52 +68,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 +151,76 @@ 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) +// 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) +} + +// 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 @@ -194,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) @@ -225,15 +287,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 +314,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 +322,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 +335,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 +455,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 +487,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 +511,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 +888,21 @@ func (ui *UI) TextInput(label string) (string, error) { } return m.textInput.Value(), nil } + +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) + } + 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..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()) }) } @@ -644,3 +679,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/validate_test.go b/internal/cli/validate_test.go new file mode 100644 index 0000000..3a31d5b --- /dev/null +++ b/internal/cli/validate_test.go @@ -0,0 +1,451 @@ +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 TestTokenListColumns(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("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) + }) + + 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) + }) +} + +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/internal/cli/watcher.go b/internal/cli/watcher.go index 3559f65..17087d8 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) @@ -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/man/tx.1 b/man/tx.1 index 03b2eaf..b7ee45e 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 @@ -16,6 +18,7 @@ .Cm build .Op Fl -live .Op Fl -no-cache +.Op Fl -log Ns = Ns Cm terminal | file .Op Ar name ... .Nm .Cm status @@ -24,10 +27,12 @@ .Op Fl -name Ar name .Op Fl -expires-in Ar duration .Op Fl -no-expiry +.Op Ar name .Nm .Cm token list .Nm .Cm token delete +.Op Fl y | -yes .Op Ar name .Nm .Cm version @@ -40,7 +45,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 @@ -68,10 +103,30 @@ 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. +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. +.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 @@ -83,10 +138,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 +153,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,13 +179,31 @@ 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 +.It Fl -log Ns = Ns Cm terminal | file +Where the LaTeX log goes. +.Cm terminal +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 terminal +otherwise. .It Fl -live Watch for file changes and rebuild automatically. After the initial build, the command monitors the project directory for @@ -134,24 +212,58 @@ 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 , 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 . +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. @@ -186,16 +298,192 @@ 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 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 diagnostics than it sent. +.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 @@ -220,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 @@ -359,6 +647,7 @@ and set it as .Bl -bullet .It .Pa .git/ , +.Pa .texops/ , .Pa .texops.yaml , and .Pa .txignore @@ -390,6 +679,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 . @@ -403,6 +706,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 @@ -411,21 +722,78 @@ 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. +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 -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 +.Po +.Ql internal , +.Ql network , +.Ql timeout +.Pc . +.It 2 +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, +.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 +.Pq Ql usage . +.It 3 +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 +.Pq Ql config . +.It 5 +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, +.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 .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 0041f77..8ca785f 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -4,13 +4,14 @@ 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 build [--live] [--no-cache] [--log=terminal | file] [name ...] tx status tx token create [--name name] [--expires-in duration] [--no-expiry] + [name] tx token list - tx token delete [name] + tx token delete [-y | --yes] [name] tx version tx --version @@ -18,7 +19,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: @@ -39,11 +59,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. + 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. + + --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. @@ -52,16 +84,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,12 +114,20 @@ 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: + --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 terminal 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 @@ -92,26 +135,44 @@ 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. 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). 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) @@ -129,16 +190,106 @@ 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 --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 diagnostics than it sent. + + 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 @@ -149,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 @@ -238,7 +389,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. @@ -261,6 +413,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 @@ -274,6 +434,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. @@ -281,17 +449,49 @@ 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. 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 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 (‘internal’, ‘network’, ‘timeout’). + + 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’). + + 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, 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: $ 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..5130a9e --- /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=terminal` 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/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 8e24944..5c75a30 100644 --- a/site/docs/cli.md +++ b/site/docs/cli.md @@ -2,59 +2,252 @@ 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. 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). + +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, 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. | -| `--compiler ` | LaTeX compiler (`pdflatex`, `xelatex`, `lualatex`, `latex`, `platex`, `uplatex`). Skips the interactive prompt. | +| `--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 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 | |------|-------------| | `--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=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 + +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 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. ## `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. 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 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. 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. | -| `--expires-in ` | Expiry duration: a positive integer followed by `d` (days) or `y` (years), max 10 years. Mutually exclusive with `--no-expiry`. | +| `--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, a 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 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. 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. + +| Flag | Description | +|------|-------------| +| `-y`, `--yes` | Delete without asking for confirmation, also on a terminal. | ## `tx version` 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 diagnostics than it sent. | + +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: @@ -69,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 @@ -77,12 +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. With `--json`, stdout carries only the JSON document. + ## Exit status -| Code | Meaning | -|------|---------| -| `0` | Success (including `tx status` when not authenticated). | -| `1` | Failure: parse errors, configuration errors, build failures, runtime errors. | +| 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: 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` | + +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/configuration.md b/site/docs/configuration.md index 09bf5c8..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. | @@ -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` 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', ], };