diff --git a/CLAUDE.md b/CLAUDE.md index f4f2a700b0..9e6b1ee00d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,29 @@ the commands are always runnable in every build. takes `--everywhere` (revoke every session on the active core, not just the current one) and `--all-contexts` (log out of every saved login) - `doctor`: bare runs the scan-and-fix flow, plus `trace`, `logs`, `bundle` +- `cluster`: the control plane's data-plane cluster catalog — `list` only, since + clusters are provisioned by Entire rather than by users. It renders `GET + /clusters` (`coreapi.ListClusters`, the same call the mirror wizard and + `repo mirror list` already make to map slugs to hosts) sorted by region then + slug. The table's columns are the values other commands take: REGION is the + jurisdiction slug behind `org create --region` and `project create + --region`; CLUSTER is the placement slug `repo mirror list --cluster` + accepts; HOST is the bare public host behind `repo create --cluster-host`, + `repo mirror create` and `repo clone --cluster`, reduced through + `hostFromPublicURL` so a publicUrl that fails validation renders `-` rather + than a spoofable host. `--json` is the wire model, `apiUrl` and `isDefault` + included, plus a synthesized `host` merged into each object + (`clusterJSON`, via the additive-only `mergeSynthesizedField` that `repo + create` uses for `remote`): the same validated host the table shows, absent + rather than dashed when `publicUrl` fails validation, so a script never has + to re-implement the guard over the raw URL. `apiUrl` is never a table + column, because the CLI dials the API URL itself. `isDefault` becomes a + DEFAULT column only when the catalog holds a non-default cluster + (`clusterTable`): that is the catalog in which a reader needs telling where + a region falls back to when a command names the region alone, and in a + catalog with one cluster per region the column would read yes on every + row. The catalog carries no health, capacity or usage data — nothing + server-side does — and hidden or decommissioned clusters never reach it. - `org`: control-plane organization management — `create`, `list`, `get`, `delete` - `project`: control-plane project management — `create`, `list`, `get`, `delete` - `repo`: control-plane repository lifecycle — `create`, `list`, `get`, `delete`, diff --git a/README.md b/README.md index f30631d5fa..2f3847c81c 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,7 @@ Descriptions below are the commands' own summaries. `entire help` always reflect | Command | Description | | ---------------- | --------------------------------------------------------------------------------- | +| `entire cluster` | Show the Entire clusters you can place projects and repos on (`list`) | | `entire org` | Manage Entire organizations (`create`, `list`, `get`, `delete`) | | `entire project` | Manage Entire projects (`create`, `list`, `get`, `delete`) | | `entire repo` | Manage Entire repositories (`create`, `list`, `get`, `delete`, `clone`, `mirror`, `visibility`) | @@ -393,7 +394,7 @@ These are visible in developer and nightly builds and hidden in stable releases, | `--agent-help-skill` | Install the Entire agent-help skill (points agents at `entire agent-help`) for the selected agent(s) | | `--telemetry=false` | Disable anonymous usage analytics | -Run in a directory that is not a git repository, `entire enable` offers to initialize one and (optionally) create a matching GitHub repo via the `gh` CLI. That path is driven by `--init-repo` / `--no-init-repo`, `--no-github`, `--repo-name`, `--repo-owner`, `--repo-visibility`, `--push`, `--skip-initial-commit`, and `--initial-commit-message`. See `entire enable --help` for the full list. +Run in a directory that is not a git repository, `entire enable` offers to initialize one and make an initial commit. It is local-only — no remote is created or pushed to, so publish the repository yourself when you are ready (`gh repo create`, `entire repo create`, or your forge's web UI). That path is driven by `--init-repo` / `--no-init-repo`, `--skip-initial-commit`, and `--initial-commit-message`. See `entire enable --help` for the full list. **Examples:** @@ -619,13 +620,13 @@ When enabled, Entire automatically generates AI summaries for checkpoints at com Summaries are also generated on demand, with or without this setting, by `entire checkpoint explain --generate`. -**Which agent writes them.** By default Claude Code (`claude` on your `PATH`, model `sonnet`). Set a different one with `summary_generation.provider` — `claude-code`, `codex`, `copilot-cli`, `cursor`, `gemini`, or `pi`, plus an optional `summary_generation.model` hint: +**Which agent writes them.** By default Claude Code (`claude` on your `PATH`, model `sonnet`). Set a different one with `summary_generation.provider` — `claude-code`, `codex`, `copilot-cli`, `cursor`, `gemini`, `opencode`, or `pi`, plus an optional `summary_generation.model` hint: ```bash entire configure --summarize-provider codex ``` -`opencode` and `factoryai-droid` cannot generate summaries. Whichever provider you pick must be installed and authenticated. +`factoryai-droid` cannot generate summaries. Whichever provider you pick must be installed and authenticated. **Requirements:** diff --git a/cmd/entire/cli/agent/opencode/generate.go b/cmd/entire/cli/agent/opencode/generate.go new file mode 100644 index 0000000000..a3ce9ee211 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/generate.go @@ -0,0 +1,136 @@ +package opencode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + + "github.com/tailscale/hujson" +) + +const openCodeBinary = "opencode" + +var _ agent.TextGenerator = (*OpenCodeAgent)(nil) + +// GenerateText runs OpenCode without repository context or tool access. An empty +// model preserves OpenCode's own default; explicit models use provider/model IDs. +func (a *OpenCodeAgent) GenerateText(ctx context.Context, prompt, model string) (string, error) { + dir, err := os.MkdirTemp("", "entire-opencode-summary-") + if err != nil { + return "", fmt.Errorf("creating OpenCode generation directory: %w", err) + } + defer os.RemoveAll(dir) + + // A unique agent name prevents merging permissions from a user's named agent. + name := filepath.Base(dir) + config, err := openCodeGenerationConfig(os.Getenv("OPENCODE_CONFIG_CONTENT"), name) + if err != nil { + return "", err + } + args := []string{"run", "--format", "json", "--dir", dir, "--agent", name, "--title", "Entire summary generation"} + if model != "" { + args = append(args, "--model", model) + } + raw, stderr, stdoutBytes, err := agent.RunIsolatedTextGeneratorCLI(ctx, a.CommandRunner, openCodeBinary, openCodeBinary, args, prompt, + "OPENCODE_CONFIG_CONTENT="+config, "OPENCODE_AUTO_SHARE=false") + if err == nil { + raw, err = parseOpenCodeGeneration(raw) + } + if err != nil { + return "", &agent.TextGenerationError{Err: fmt.Errorf("opencode text generation failed: %w", err), Stderr: stderr, StdoutBytes: stdoutBytes} + } + return raw, nil +} + +// Preserve inline provider/auth/model settings while overriding only the +// generation agent and sharing. Global provider credentials remain available. +func openCodeGenerationConfig(inline, name string) (string, error) { + config := make(map[string]json.RawMessage) + if inline != "" { + standard, err := hujson.Standardize([]byte(inline)) + if err != nil { + return "", fmt.Errorf("reading OPENCODE_CONFIG_CONTENT: %w", err) + } + if err := json.Unmarshal(standard, &config); err != nil { + return "", fmt.Errorf("reading OPENCODE_CONFIG_CONTENT: %w", err) + } + } + if config == nil { + config = make(map[string]json.RawMessage) + } + agents := make(map[string]json.RawMessage) + if raw, ok := config["agent"]; ok { + if err := json.Unmarshal(raw, &agents); err != nil { + return "", fmt.Errorf("reading OpenCode agent configuration: %w", err) + } + } + if agents == nil { + agents = make(map[string]json.RawMessage) + } + agents[name] = json.RawMessage(`{"mode":"primary","description":"Entire text generation","prompt":"Generate the requested text from the supplied prompt. Do not use tools or inspect files.","permission":{"*":"deny"}}`) + raw, err := json.Marshal(agents) + if err != nil { + return "", fmt.Errorf("encoding OpenCode agent configuration: %w", err) + } + config["agent"] = raw + config["share"] = json.RawMessage(`"disabled"`) + config["autoupdate"] = json.RawMessage(`false`) + raw, err = json.Marshal(config) + if err != nil { + return "", fmt.Errorf("encoding OpenCode generation configuration: %w", err) + } + return string(raw), nil +} + +// OpenCode emits completed text as JSON events and can emit an API error even +// when the process exits successfully. Never return partial text after an error. +func parseOpenCodeGeneration(raw string) (string, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + var parts []string + for { + var event struct { + Type string `json:"type"` + Part struct { + Text string `json:"text"` + } `json:"part"` + Error struct { + Name string `json:"name"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } `json:"error"` + } + err := decoder.Decode(&event) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", fmt.Errorf("decoding OpenCode output: %w", err) + } + switch event.Type { + case "text": + parts = append(parts, event.Part.Text) + case "error": + detail := event.Error.Data.Message + if detail == "" { + detail = event.Error.Name + } + if detail == "" { + detail = "unknown error" + } + return "", fmt.Errorf("OpenCode: %s", detail) + } + } + text := strings.TrimSpace(strings.Join(parts, "\n")) + if text == "" { + return "", errors.New("OpenCode returned no text") + } + return text, nil +} diff --git a/cmd/entire/cli/agent/opencode/generate_test.go b/cmd/entire/cli/agent/opencode/generate_test.go new file mode 100644 index 0000000000..c3eb002a80 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/generate_test.go @@ -0,0 +1,182 @@ +package opencode + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestOpenCodeSummaryCapability(t *testing.T) { + t.Parallel() + if _, ok := agent.AsTextGenerator(NewOpenCodeAgent()); !ok { + t.Fatal("OpenCode must support text generation for summaries and runner setup") + } + if got := agent.SummaryCLIBinaryName(agent.AgentNameOpenCode); got != openCodeBinary { + t.Fatalf("summary binary = %q, want opencode", got) + } +} + +func TestOpenCodeGenerateText(t *testing.T) { + t.Parallel() + for _, model := range []string{"", "openai/gpt-5.6-sol"} { + t.Run("model="+model, func(t *testing.T) { + t.Parallel() + var args []string + var command *exec.Cmd + a := &OpenCodeAgent{CommandRunner: func(ctx context.Context, binary string, argv ...string) *exec.Cmd { + if binary != openCodeBinary { + t.Fatalf("binary = %q", binary) + } + args = argv + command = exec.CommandContext(ctx, "cat") + return command + }} + prompt := `{"type":"text","part":{"type":"text","text":"hello","time":{"end":1}}}` + got, err := a.GenerateText(t.Context(), prompt, model) + if err != nil || got != "hello" { + t.Fatalf("GenerateText = %q, %v", got, err) + } + if slices.Contains(args, prompt) { + t.Fatal("prompt leaked into argv") + } + modelIndex := slices.Index(args, "--model") + if model == "" && modelIndex != -1 { + t.Fatal("default model must be left to OpenCode") + } + if model != "" && (modelIndex < 0 || args[modelIndex+1] != model) { + t.Fatalf("model missing from %v", args) + } + dirIndex := slices.Index(args, "--dir") + if dirIndex < 0 { + t.Fatal("missing isolated directory") + } + if _, err := os.Stat(args[dirIndex+1]); !os.IsNotExist(err) { + t.Fatalf("temp directory not removed: %v", err) + } + for _, entry := range command.Env { + if strings.HasPrefix(entry, "GIT_") { + t.Fatalf("git environment inherited: %s", entry) + } + } + }) + } +} + +func TestOpenCodeGenerateOutput(t *testing.T) { + t.Parallel() + tests := []struct{ name, raw, want, err string }{ + {"text", `{"type":"step_start"} +{"type":"text","part":{"text":"hello"}} +{"type":"reasoning","part":{"text":"private reasoning"}} +{"type":"text","part":{"text":"world"}} +{"type":"step_finish"}`, "hello\nworld", ""}, + {"api error after text", `{"type":"text","part":{"text":"partial"}} +{"type":"error","error":{"name":"APIError","data":{"message":"API key is invalid."}}}`, "", "API key is invalid."}, + {"named error", `{"type":"error","error":{"name":"UnknownError"}}`, "", "UnknownError"}, + {"empty error", `{"type":"error"}`, "", "error"}, + {"missing text", `{"type":"step_finish"}`, "", "no text"}, + {"blank text", `{"type":"text","part":{"text":" "}}`, "", "no text"}, + {"truncated", `{"type":"text","part":`, "", "decoding"}, + {"malformed after text", `{"type":"text","part":{"text":"partial"}}garbage`, "", "decoding"}, + {"long text", `{"type":"text","part":{"text":"` + strings.Repeat("x", 100000) + `"}}`, strings.Repeat("x", 100000), ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "events.jsonl") + if err := os.WriteFile(path, []byte(tt.raw), 0o600); err != nil { + t.Fatal(err) + } + a := &OpenCodeAgent{CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "cat", path) + }} + got, err := a.GenerateText(t.Context(), "synthetic prompt", "") + if got != tt.want { + t.Fatalf("text differs: got length %d, want length %d", len(got), len(tt.want)) + } + if tt.err == "" { + if err != nil { + t.Fatal(err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.err) { + t.Fatalf("error = %v, want %q", err, tt.err) + } + var generationErr *agent.TextGenerationError + if !errors.As(err, &generationErr) || generationErr.StdoutBytes != len(strings.TrimSpace(tt.raw)) { + t.Fatalf("missing output metadata: %v", err) + } + }) + } +} + +func TestOpenCodeGenerateCanceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + a := &OpenCodeAgent{CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { return exec.CommandContext(ctx, "cat") }} + _, err := a.GenerateText(ctx, "prompt", "") + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } +} + +func TestOpenCodeGenerationConfig(t *testing.T) { + t.Parallel() + raw, err := openCodeGenerationConfig(`{"model":"openai/custom","provider":{"openai":{"options":{"baseURL":"https://example.test"}}},"share":"auto","agent":{"build":{"permission":{"bash":"allow"}}}}`, "entire-test") + if err != nil { + t.Fatal(err) + } + var config map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &config); err != nil { + t.Fatal(err) + } + if string(config["model"]) != `"openai/custom"` || string(config["provider"]) != `{"openai":{"options":{"baseURL":"https://example.test"}}}` { + t.Fatalf("provider settings lost: %s", raw) + } + if string(config["share"]) != `"disabled"` { + t.Fatalf("sharing not disabled: %s", raw) + } + var agents map[string]struct { + Permission map[string]string `json:"permission"` + } + if err := json.Unmarshal(config["agent"], &agents); err != nil { + t.Fatal(err) + } + if agents["entire-test"].Permission["*"] != "deny" || agents["build"].Permission["bash"] != "allow" { + t.Fatalf("agent permissions: %s", raw) + } + for _, invalid := range []string{"{", `{"agent":1}`} { + if _, err := openCodeGenerationConfig(invalid, "test"); err == nil { + t.Fatalf("accepted invalid config %q", invalid) + } + } +} + +func TestOpenCodeGenerationConfigJSONC(t *testing.T) { + t.Parallel() + raw, err := openCodeGenerationConfig(`{ + // Keep the user's provider selection. + "model":"openai/custom", + "agent":{"build":{"permission":{"bash":"allow",},},}, + }`, "entire-test") + if err != nil { + t.Fatal(err) + } + var config map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &config); err != nil { + t.Fatal(err) + } + if string(config["model"]) != `"openai/custom"` { + t.Fatalf("model lost: %s", raw) + } +} diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index 732fab5b46..2a27fbe033 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -24,7 +24,10 @@ func init() { } //nolint:revive // OpenCodeAgent is clearer than Agent in this context -type OpenCodeAgent struct{} +type OpenCodeAgent struct { + // CommandRunner overrides text-generation subprocess creation when non-nil. + CommandRunner agent.TextCommandRunner +} // NewOpenCodeAgent creates a new OpenCode agent instance. func NewOpenCodeAgent() agent.Agent { diff --git a/cmd/entire/cli/agent/resume_command.go b/cmd/entire/cli/agent/resume_command.go index 8252afd73e..a22081596c 100644 --- a/cmd/entire/cli/agent/resume_command.go +++ b/cmd/entire/cli/agent/resume_command.go @@ -50,12 +50,12 @@ func ResumeCommandSpecFor(name types.AgentName, sessionID string) (ForegroundCom return ForegroundCommandSpec{Binary: "gemini", Args: []string{"--resume", sessionID}}, true case AgentNameOpenCode: if sessionID == "" { - return ForegroundCommandSpec{Binary: "opencode"}, true + return ForegroundCommandSpec{Binary: openCodeBinary}, true } if !isLaunchableResumeSessionID(sessionID) { return ForegroundCommandSpec{}, false } - return ForegroundCommandSpec{Binary: "opencode", Args: []string{"-s", sessionID}}, true + return ForegroundCommandSpec{Binary: openCodeBinary, Args: []string{"-s", sessionID}}, true case AgentNamePi: if sessionID == "" { return ForegroundCommandSpec{Binary: "pi", Args: []string{"--continue"}}, true diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go index 7efecd39fb..1979c70499 100644 --- a/cmd/entire/cli/agent/text_generator_cli.go +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -34,17 +34,20 @@ type TextCommandRunner func(ctx context.Context, name string, args ...string) *e // directory with all GIT_* environment variables removed. This avoids recursive // hook triggers and repo side effects while preserving provider-specific flags. // +// Optional envOverrides take precedence over inherited values; GIT_* entries +// are removed even from overrides. +// // Returns (result, capturedStderr, stdoutByteCount, err). capturedStderr and // stdoutByteCount are populated even on error so callers can wrap them into a // *agent.TextGenerationError for timeout diagnostics. -func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, binary, displayName string, args []string, stdin string) (string, string, int, error) { +func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, binary, displayName string, args []string, stdin string, envOverrides ...string) (string, string, int, error) { if runner == nil { runner = exec.CommandContext } cmd := runner(ctx, binary, args...) cmd.Dir = os.TempDir() - cmd.Env = StripGitEnv(os.Environ()) + cmd.Env = StripGitEnv(append(os.Environ(), envOverrides...)) // A killed provider CLI can leave a sandbox/MCP grandchild holding the // output pipe open, which blocks cmd.Run past the ctx deadline. Bound it. execx.TerminateOnCancel(cmd) @@ -99,6 +102,11 @@ func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, // Callers outside this package that need the binary name (e.g., the explain // diagnostic's "run `claude` directly" suggestion) should use // SummaryCLIBinaryName rather than duplicating the mapping. +// openCodeBinary is the OpenCode CLI executable. It happens to spell the same +// as AgentNameOpenCode, but it names a program on $PATH rather than a registry +// key, so it is its own constant instead of a cast of the agent name. +const openCodeBinary = "opencode" + var summaryProviderBinaries = map[types.AgentName]string{ AgentNameClaudeCode: "claude", AgentNameCodex: "codex", @@ -106,6 +114,7 @@ var summaryProviderBinaries = map[types.AgentName]string{ AgentNameCursor: "agent", AgentNameGemini: "gemini", AgentNamePi: "pi", + AgentNameOpenCode: openCodeBinary, } // SummaryCLIBinaryName returns the CLI binary name for a summary-capable diff --git a/cmd/entire/cli/agent/text_generator_cli_test.go b/cmd/entire/cli/agent/text_generator_cli_test.go index 9cdb7b45b5..f72785a4e4 100644 --- a/cmd/entire/cli/agent/text_generator_cli_test.go +++ b/cmd/entire/cli/agent/text_generator_cli_test.go @@ -245,3 +245,15 @@ func TestStripGitEnv(t *testing.T) { t.Fatalf("expected 3 entries, got %d: %v", len(filtered), filtered) } } + +func TestRunIsolatedTextGeneratorCLI_EnvironmentOverrides(t *testing.T) { + t.Parallel() + runner := func(ctx context.Context, _ string, _ ...string) *exec.Cmd { return exec.CommandContext(ctx, "env") } + out, _, _, err := RunIsolatedTextGeneratorCLI(t.Context(), runner, "test", "test", nil, "", "ENTIRE_GENERATION_PROBE=first", "ENTIRE_GENERATION_PROBE=last", "GIT_DIR=must-not-leak") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "ENTIRE_GENERATION_PROBE=last") || strings.Contains(out, "ENTIRE_GENERATION_PROBE=first") || strings.Contains(out, "GIT_DIR=") { + t.Fatalf("overrides not applied or Git environment leaked: %q", out) + } +} diff --git a/cmd/entire/cli/agent_help_cmd.go b/cmd/entire/cli/agent_help_cmd.go index 13d6f6651a..cf13d1ef06 100644 --- a/cmd/entire/cli/agent_help_cmd.go +++ b/cmd/entire/cli/agent_help_cmd.go @@ -164,6 +164,7 @@ var agentHelpClassification = map[string]agentHelpFacts{ "agent": {agentHelpAudienceUserOwned, false}, "auth": {agentHelpAudienceUserOwned, false}, "clean": {agentHelpAudienceUserOwned, false}, + "cluster": {agentHelpAudienceUserOwned, false}, "configure": {agentHelpAudienceUserOwned, false}, "disable": {agentHelpAudienceUserOwned, false}, "enable": {agentHelpAudienceUserOwned, false}, diff --git a/cmd/entire/cli/auth/active_context_test.go b/cmd/entire/cli/auth/active_context_test.go new file mode 100644 index 0000000000..ca1034d79d --- /dev/null +++ b/cmd/entire/cli/auth/active_context_test.go @@ -0,0 +1,102 @@ +package auth + +import ( + "errors" + "testing" + + "github.com/entireio/cli/internal/entireclient/contexts" +) + +// These tests drive process-global state (ENTIRE_CONFIG_DIR) so they cannot run +// in parallel. + +func TestActiveContext_ReturnsTheActingContext(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", configDir) + writeActiveContext(t, configDir, "alice@core", "https://ctx-core.example", "alice", "svc") + + c, ok, err := ActiveContext() + if err != nil || !ok { + t.Fatalf("ActiveContext() ok = %v, error = %v", ok, err) + } + if c.Name != "alice@core" || c.CoreURL != "https://ctx-core.example" { + t.Fatalf("context = %+v, want the acting one", c) + } +} + +// The context object itself is returned, not just its name, so callers needing +// its CoreURL do not re-find it by looping over the full list. Contexts() must +// still agree about which one is acting. +func TestActiveContext_AgreesWithContexts(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", configDir) + other := &contexts.Context{Name: "staging", CoreURL: "https://staging.example"} + acting := &contexts.Context{Name: "prod", CoreURL: "https://prod.example"} + if err := contexts.Save(configDir, &contexts.File{ + CurrentContext: "prod", + Contexts: []*contexts.Context{other, acting}, + }); err != nil { + t.Fatalf("write contexts.json: %v", err) + } + + c, ok, err := ActiveContext() + if err != nil || !ok { + t.Fatalf("ActiveContext() ok = %v, error = %v", ok, err) + } + _, current, err := Contexts() + if err != nil { + t.Fatalf("Contexts: %v", err) + } + if c.Name != current { + t.Fatalf("ActiveContext name = %q, Contexts current = %q; they must not disagree", c.Name, current) + } +} + +// A context with no CoreURL is an unusable pointer: reporting it as acting means +// dialing an empty host instead of telling the user to log in. +func TestActiveContext_BlankCoreURLIsNotActing(t *testing.T) { + for _, coreURL := range []string{"", " "} { + configDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", configDir) + writeActiveContext(t, configDir, "broken", coreURL, "alice", "svc") + + c, ok, err := ActiveContext() + if err != nil { + t.Fatalf("CoreURL %q: unexpected error %v", coreURL, err) + } + if ok || c != nil { + t.Fatalf("CoreURL %q: ok = %v, context = %+v, want no acting context", coreURL, ok, c) + } + } +} + +func TestActiveContext_NoCurrentContext(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", configDir) + + c, ok, err := ActiveContext() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || c != nil { + t.Fatalf("ok = %v, context = %+v, want no acting context", ok, c) + } +} + +// An explicit --context/$ENTIRE_CONTEXT naming no saved context is a hard error, +// not "nobody is acting": it must not degrade into the `entire login` hint. +func TestActiveContext_UnknownSelectionIsAnError(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", configDir) + t.Setenv(contexts.EnvContextVar, "nope") + writeActiveContext(t, configDir, "alice@core", "https://ctx-core.example", "alice", "svc") + + c, ok, err := ActiveContext() + if err == nil { + t.Fatalf("error = nil, want an unknown-context error; got ok = %v, context = %+v", ok, c) + } + var unknown *contexts.UnknownContextError + if !errors.As(err, &unknown) { + t.Fatalf("error = %v, want *contexts.UnknownContextError", err) + } +} diff --git a/cmd/entire/cli/auth/context_store.go b/cmd/entire/cli/auth/context_store.go index bb30805f31..4a31442c85 100644 --- a/cmd/entire/cli/auth/context_store.go +++ b/cmd/entire/cli/auth/context_store.go @@ -182,6 +182,36 @@ func Contexts() ([]*contexts.Context, string, error) { return f.Contexts, sel.Context.Name, nil } +// ActiveContext returns the login context currently acting, or ok=false when +// there is none. It exists so callers that need the context itself — its +// CoreURL, to mint a token against — do not have to take the name from Contexts +// and then re-find the object by looping over the slice. Three call sites grew +// that loop independently and two of them dropped the CoreURL guard below, which +// is the drift this accessor removes. +// +// A context with no CoreURL is reported as ok=false rather than returned: it is +// an unusable pointer, and treating it as active means dialing an empty host +// instead of telling the user to log in. +// +// A `--context`/$ENTIRE_CONTEXT selection is honoured, so the identity resolved +// here is the one every other command acts as. An explicit selection naming no +// saved context is a hard error, not ok=false: "you asked for a context that +// doesn't exist" must not degrade into the `entire login` hint. +func ActiveContext() (c *contexts.Context, ok bool, err error) { + f, err := contexts.Load(userdirs.Config()) + if err != nil { + return nil, false, fmt.Errorf("load contexts: %w", err) + } + sel, err := f.Active() + if err != nil { + return nil, false, err //nolint:wrapcheck // UnknownContextError is already a complete operator message + } + if sel.Context == nil || strings.TrimSpace(sel.Context.CoreURL) == "" { + return nil, false, nil + } + return sel.Context, true, nil +} + // StoredContexts returns all stored login contexts and the STORED // current_context, ignoring any `--context`/$ENTIRE_CONTEXT override. // diff --git a/cmd/entire/cli/cluster_group.go b/cmd/entire/cli/cluster_group.go new file mode 100644 index 0000000000..efbc1d5448 --- /dev/null +++ b/cmd/entire/cli/cluster_group.go @@ -0,0 +1,19 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +// newClusterCmd is the `entire cluster` command group: the catalog of +// data-plane clusters attached to the control plane the active login belongs +// to. Read-only — clusters are provisioned by Entire, not by users — so the +// group carries `list` and nothing else. +func newClusterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster", + Short: "Show the Entire clusters you can place projects and repos on", + } + addControlPlaneFlags(cmd) + cmd.AddCommand(newClusterListCmd()) + return cmd +} diff --git a/cmd/entire/cli/cluster_list.go b/cmd/entire/cli/cluster_list.go new file mode 100644 index 0000000000..6ea059d171 --- /dev/null +++ b/cmd/entire/cli/cluster_list.go @@ -0,0 +1,111 @@ +package cli + +import ( + "cmp" + "context" + "encoding/json" + "slices" + + "github.com/spf13/cobra" + + "github.com/entireio/cli/internal/coreapi" +) + +// clusterColumns is the human table view of a cluster. Every column is a value +// some other command takes, which is what the table is for: REGION is the +// jurisdiction slug `org create` and `project create` name with --region, +// CLUSTER is the slug mirror placements are keyed by (`repo mirror list +// --cluster` accepts it), HOST is what `repo create --cluster-host`, `repo +// mirror create` and `repo clone --cluster` take. The catalog's apiUrl is +// --json only: the CLI dials the API URL itself. +var clusterColumns = []string{colHeaderRegion, colHeaderCluster, "HOST"} + +func clusterRow(cl coreapi.Cluster) []string { + host, err := hostFromPublicURL(cl.PublicUrl) + if err != nil { + host = "-" // unsafe/malformed publicUrl: dashed, never a spoofable host (see clusterHostBySlug) + } + return []string{cl.Jurisdiction, cl.Slug, host} +} + +// clusterTable shapes the catalog's table. A DEFAULT column is added only when +// some cluster is not its region's default: that is the one catalog in which a +// reader needs telling where a region falls back to when a command names the +// region alone (`repo create` without --cluster-host), and the only one in +// which the column would not read yes on every row. Every consumer of +// isDefault picks the default within one jurisdiction, so the column is read +// per region. +func clusterTable(clusters []coreapi.Cluster) ([]string, func(coreapi.Cluster) []string) { + if !slices.ContainsFunc(clusters, func(cl coreapi.Cluster) bool { return !cl.IsDefault }) { + return clusterColumns, clusterRow + } + headers := append(slices.Clone(clusterColumns), "DEFAULT") + return headers, func(cl coreapi.Cluster) []string { + mark := "-" + if cl.IsDefault { + mark = "yes" + } + return append(clusterRow(cl), mark) + } +} + +// clusterJSON is the --json view of the catalog: the wire model with a +// synthesized `host` merged into each cluster — the same validated bare host +// the table's HOST column shows and that `repo create --cluster-host`, `repo +// mirror create` and `repo clone --cluster` take — so a script reads the safe +// value instead of re-implementing hostFromPublicURL over publicUrl. Where +// publicUrl fails validation the field is absent, not dashed: publicUrl stays +// for the consumer that wants the raw value, and an absent host says +// "unsafe" more honestly than a placeholder does. +func clusterJSON(clusters []coreapi.Cluster) (any, error) { + out := make([]map[string]json.RawMessage, 0, len(clusters)) + for i := range clusters { + cl := &clusters[i] + obj, err := mergeSynthesizedField(cl, "host", func() string { + host, err := hostFromPublicURL(cl.PublicUrl) + if err != nil { + return "" + } + return host + }) + if err != nil { + return nil, err + } + out = append(out, obj) + } + return out, nil +} + +// sortClusters orders the catalog for reading — by region, then by slug. The +// server returns registry order. +func sortClusters(clusters []coreapi.Cluster) { + slices.SortFunc(clusters, func(a, b coreapi.Cluster) int { + return cmp.Or( + cmp.Compare(a.Jurisdiction, b.Jurisdiction), + cmp.Compare(a.Slug, b.Slug), + ) + }) +} + +func newClusterListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: cmdList, + Short: "List the clusters Entire has available", + Example: " entire cluster list\n" + + " entire cluster list --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + view := listView[coreapi.Cluster]{table: clusterTable, toJSON: clusterJSON} + return runCoreListShaped(cmd, "No clusters found.", view, func(ctx context.Context, c *coreapi.Client) ([]coreapi.Cluster, error) { + out, err := c.ListClusters(ctx) + if err != nil { + return nil, err + } + sortClusters(out.Clusters) + return out.Clusters, nil + }) + }, + } + addJSONFlag(cmd) + return cmd +} diff --git a/cmd/entire/cli/cluster_list_test.go b/cmd/entire/cli/cluster_list_test.go new file mode 100644 index 0000000000..7e8122d36e --- /dev/null +++ b/cmd/entire/cli/cluster_list_test.go @@ -0,0 +1,171 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/internal/coreapi" +) + +// serveClusterList answers GET /api/v1/clusters with the given catalog, +// standing in for the control plane behind `entire cluster list`. +func serveClusterList(t *testing.T, clusters []coreapi.Cluster) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/clusters", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if err := printJSON(w, &coreapi.ListClustersOutputBody{Clusters: clusters}); err != nil { + t.Errorf("encode clusters: %v", err) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// clusterCatalogFixture is a catalog in registry order: two regions, two US +// clusters of which one is the default, and one row whose publicUrl carries the +// host@evil.com userinfo trick. +func clusterCatalogFixture() []coreapi.Cluster { + return []coreapi.Cluster{ + {Slug: "aws-us-west", Jurisdiction: "us", PublicUrl: "https://aws-us-west-2.entire.io"}, + {Slug: "aws-eu", Jurisdiction: "eu", PublicUrl: "https://aws-eu-central-1.entire.io/", IsDefault: true, ApiUrl: coreapi.NewOptString("https://aws-eu-central-1.api.entire.io")}, + {Slug: "aws-us-east", Jurisdiction: "us", PublicUrl: "https://aws-us-east-2.entire.io", IsDefault: true}, + {Slug: "poisoned", Jurisdiction: "us", PublicUrl: "https://aws-us-east-2.entire.io@evil.com"}, + } +} + +// tableCells splits rendered table output into one []string of cells per line, +// so assertions pin the values and their order without pinning column padding. +func tableCells(out string) [][]string { + var rows [][]string + for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") { + rows = append(rows, strings.Fields(line)) + } + return rows +} + +// The table is what a person copies from: REGION feeds `project create +// --region`, HOST feeds `repo mirror create` / `repo create --cluster-host` / +// `repo clone --cluster`, CLUSTER is the slug placements are keyed by. Rows are +// sorted by region then slug, and a publicUrl that cannot be reduced to a safe +// bare host renders dashed rather than spoofable. A catalog holding a +// non-default cluster gains a DEFAULT column, so a reader can see which +// cluster a region falls back to when a command names the region alone. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestClusterList_RendersRegionsAndHosts(t *testing.T) { + srv := serveClusterList(t, clusterCatalogFixture()) + + out, errOut, err := runCoreCmd(t, newClusterCmd, srv.URL, "list") + require.NoError(t, err) + require.Empty(t, errOut) + + require.Equal(t, [][]string{ + {"REGION", "CLUSTER", "HOST", "DEFAULT"}, + {"eu", "aws-eu", "aws-eu-central-1.entire.io", "yes"}, + {"us", "aws-us-east", "aws-us-east-2.entire.io", "yes"}, + {"us", "aws-us-west", "aws-us-west-2.entire.io", "-"}, + {"us", "poisoned", "-", "-"}, + }, tableCells(out)) +} + +// With one cluster per region every cluster is its region's default, so a +// DEFAULT column would read yes on every row and say nothing. It is added only +// when the catalog holds a non-default cluster; this is the live catalog's +// shape today. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestClusterList_OneClusterPerRegionOmitsDefaultColumn(t *testing.T) { + srv := serveClusterList(t, []coreapi.Cluster{ + {Slug: "aws-us-east", Jurisdiction: "us", PublicUrl: "https://aws-us-east-2.entire.io", IsDefault: true}, + {Slug: "aws-eu", Jurisdiction: "eu", PublicUrl: "https://aws-eu-central-1.entire.io", IsDefault: true}, + }) + + out, errOut, err := runCoreCmd(t, newClusterCmd, srv.URL, "list") + require.NoError(t, err) + require.Empty(t, errOut) + + require.Equal(t, [][]string{ + {"REGION", "CLUSTER", "HOST"}, + {"eu", "aws-eu", "aws-eu-central-1.entire.io"}, + {"us", "aws-us-east", "aws-us-east-2.entire.io"}, + }, tableCells(out)) +} + +// --json is the wire model, in the same order as the table: every catalog +// field survives (apiUrl included, which the table omits, and isDefault, which +// the table shows only when some cluster is not one), and publicUrl is passed +// through verbatim. Merged into each object is a synthesized `host`, the same +// validated bare host the table's HOST column shows, so a script gets the safe +// value without re-implementing hostFromPublicURL; where publicUrl fails +// validation the field is absent rather than dashed or spoofable. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestClusterList_JSONIsTheSortedWireModel(t *testing.T) { + srv := serveClusterList(t, clusterCatalogFixture()) + + out, errOut, err := runCoreCmd(t, newClusterCmd, srv.URL, "list", "--json") + require.NoError(t, err) + require.Empty(t, errOut) + + var got []coreapi.Cluster + require.NoError(t, json.Unmarshal([]byte(out), &got)) + require.Len(t, got, 4) + slugs := make([]string, 0, len(got)) + for _, cl := range got { + slugs = append(slugs, cl.Slug) + } + require.Equal(t, []string{"aws-eu", "aws-us-east", "aws-us-west", "poisoned"}, slugs) + require.Equal(t, "https://aws-eu-central-1.api.entire.io", got[0].ApiUrl.Or("")) + require.False(t, got[1].ApiUrl.IsSet(), "an unset apiUrl must stay absent, not become an empty string") + require.True(t, got[1].IsDefault) + require.False(t, got[2].IsDefault) + require.Equal(t, "https://aws-us-east-2.entire.io@evil.com", got[3].PublicUrl) + + var objs []map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(out), &objs)) + require.JSONEq(t, `"aws-eu-central-1.entire.io"`, string(objs[0]["host"]), "trailing slash and scheme reduced to the bare host") + require.JSONEq(t, `"aws-us-east-2.entire.io"`, string(objs[1]["host"])) + require.NotContains(t, objs[3], "host", "an unsafe publicUrl gets no host, not a placeholder") + require.NotContains(t, objs[0], "AdditionalProps", "the wire encoder, not reflection, must produce the object") +} + +// clusterJSON writes its validated host only when the object has no host of +// its own, so the day the catalog gains a first-class `host` the synthesis +// silently stops and the raw server value flows through --json unchecked — +// the state finding 1 on this command's trail flagged. This pins the +// assumption: when it fails, decide whether to validate the server's field or +// retire the synthesis, rather than deleting the test. +func TestClusterWireModelHasNoHostField(t *testing.T) { + t.Parallel() + _, has := reflect.TypeOf(coreapi.Cluster{}).FieldByName("Host") + require.False(t, has, "coreapi.Cluster gained a Host field; clusterJSON's synthesized host is now shadowed by an unvalidated server value") +} + +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestClusterList_EmptyCatalogJSONIsEmptyArray(t *testing.T) { + srv := serveClusterList(t, nil) + + out, errOut, err := runCoreCmd(t, newClusterCmd, srv.URL, "list", "--json") + require.NoError(t, err) + require.Empty(t, errOut) + require.JSONEq(t, "[]", out) +} + +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestClusterList_EmptyCatalog(t *testing.T) { + srv := serveClusterList(t, nil) + + out, errOut, err := runCoreCmd(t, newClusterCmd, srv.URL, "list") + require.NoError(t, err) + require.Empty(t, errOut) + require.Equal(t, "No clusters found.\n", out) +} diff --git a/cmd/entire/cli/corecmd.go b/cmd/entire/cli/corecmd.go index 3a618790b0..c917180555 100644 --- a/cmd/entire/cli/corecmd.go +++ b/cmd/entire/cli/corecmd.go @@ -169,6 +169,24 @@ func runCoreList[T any](cmd *cobra.Command, empty string, headers []string, row return runCore(cmd, renderCoreList(cmd, empty, headers, row, fn)) } +// listView is how a list renders once its items are known, for the command +// whose output depends on what came back. table is required and picks the +// headers and row function; toJSON is optional and, when set, replaces the +// raw wire model on --json — it must be additive-only, merging synthesized +// fields into the marshalled objects (see mergeSynthesizedField) and never +// dropping or overriding a server field. +type listView[T any] struct { + table func(items []T) (headers []string, row func(T) []string) + toJSON func(items []T) (any, error) +} + +// runCoreListShaped is runCoreList with the rendering decided after the fetch. +// `cluster list` adds its DEFAULT column this way, only when the catalog holds +// a non-default cluster, and merges a validated `host` into its --json. +func runCoreListShaped[T any](cmd *cobra.Command, empty string, view listView[T], fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error { + return runCore(cmd, renderCoreListShaped(cmd, empty, view, fn)) +} + // runCoreListForCluster is runCoreList for a resource-provider command (see // runCoreForCluster): identical table/JSON/empty-state rendering, but dialing // the core that fronts clusterHost rather than the active context. @@ -177,11 +195,18 @@ func runCoreListForCluster[T any](cmd *cobra.Command, clusterHost, empty string, } // renderCoreList builds the run-function shared by runCoreList and -// runCoreListForCluster: fetch via fn, then render as a table (default), the -// empty sentence (no items), or raw JSON (--json). Kept separate from the +// runCoreListForCluster for a table with fixed columns. Kept separate from the // client-selection so the two list variants differ only in which core they // dial. func renderCoreList[T any](cmd *cobra.Command, empty string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) func(context.Context, *coreapi.Client) error { + view := listView[T]{table: func([]T) ([]string, func(T) []string) { return headers, row }} + return renderCoreListShaped(cmd, empty, view, fn) +} + +// renderCoreListShaped is the rendering every list variant shares: fetch via +// fn, then render as a table (default), the empty sentence (no items), or JSON +// (--json) — the raw wire model unless the view supplies its own. +func renderCoreListShaped[T any](cmd *cobra.Command, empty string, view listView[T], fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) func(context.Context, *coreapi.Client) error { return func(ctx context.Context, c *coreapi.Client) error { items, err := fn(ctx, c) if err != nil { @@ -191,16 +216,57 @@ func renderCoreList[T any](cmd *cobra.Command, empty string, headers []string, r if items == nil { items = []T{} // a nil slice encodes as null; scripts expect [] } - return printJSON(cmd.OutOrStdout(), items) + if view.toJSON == nil { + return printJSON(cmd.OutOrStdout(), items) + } + out, err := view.toJSON(items) + if err != nil { + return err + } + return printJSON(cmd.OutOrStdout(), out) } if len(items) == 0 { fmt.Fprintln(cmd.OutOrStdout(), empty) return nil } + headers, row := view.table(items) return printTable(cmd.OutOrStdout(), headers, items, row) } } +// mergeSynthesizedField renders a wire object as JSON with one synthesized +// string field merged in. The generated types carry custom marshalers plus +// arbitrary additional properties, so they can't be embedded in a wrapper +// struct; instead v is round-tripped through its own encoder (pass a pointer — +// the marshalers have pointer receivers) and the field is merged into the +// resulting object. Additive-only: if the object already carries the field +// (a future first-class field, or one arriving via additional properties) it +// is left untouched, so the server value always wins, and an empty synth +// result adds nothing rather than a half-formed placeholder. +func mergeSynthesizedField(v any, field string, synth func() string) (map[string]json.RawMessage, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("encode %T: %w", v, err) + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, fmt.Errorf("decode %T: %w", v, err) + } + if _, ok := obj[field]; ok { + return obj, nil + } + value := synth() + if value == "" { + return obj, nil + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode %s: %w", field, err) + } + obj[field] = encoded + return obj, nil +} + // coreListFetchBudget bounds how many entries a bounded list command fetches // by default. The control plane pages but cannot filter or sort these lists, // so without a bound every call would walk the entire collection — thousands @@ -611,6 +677,15 @@ func runCoreClient(cmd *cobra.Command, newClient func(context.Context) (*coreapi return fmt.Errorf("connect to Entire control plane: %w", err) } if err := fn(cmd.Context(), client); err != nil { + // Commands that already reported a partial success own the rendering. + // renderCoreError extracts API problems through wrappers, discarding + // SilentError and causing main to print again. Guard here rather than + // changing that display helper: the mirror-create wizard needs its + // plain message before it prints. + var silent *SilentError + if errors.As(err, &silent) { + return err + } return renderCoreError(err) } return nil diff --git a/cmd/entire/cli/corecmd_mutation_test.go b/cmd/entire/cli/corecmd_mutation_test.go index 6b934c5158..ff435e82d2 100644 --- a/cmd/entire/cli/corecmd_mutation_test.go +++ b/cmd/entire/cli/corecmd_mutation_test.go @@ -52,24 +52,32 @@ func TestOrgCreate_JSONOnRequest(t *testing.T) { // testRepoCreateProjectULID is the --project value for the repo-create tests // below: a syntactically valid ULID so resolveProjectRef skips the by-name -// lookup and the fake server only needs to answer POST /api/v1/repos. +// lookup and the fake server only needs to answer repository requests. const testRepoCreateProjectULID = "01HZX7QABCDEFGHJKMNPQRSTV2" -// newCreateRepoServer answers POST /api/v1/repos with a created repo whose +// newCreateRepoServer answers creation and authoritative GETs with a repo whose // clusterHost/path resolve to a clone URL. The 201 status is load-bearing, // same as newCreateOrgServer. func newCreateRepoServer(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + if r.Method == http.MethodPost { + assert.Equal(t, "/api/v1/repos", r.URL.Path) + w.WriteHeader(http.StatusCreated) + } else { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/"+testDeleteULID, r.URL.Path) + assert.Equal(t, "true", r.URL.Query().Get("authoritative")) + } repo := &coreapi.Repo{ ID: testDeleteULID, Name: "web", OwningProjectId: testRepoCreateProjectULID, - ClusterHost: coreapi.NewOptString("c.example.com"), - Path: coreapi.NewOptString("/gh/o/web"), + // The authoritative GET confirms the creation fixture is active. + State: coreapi.NewOptString("active"), + ClusterHost: coreapi.NewOptString("c.example.com"), + Path: coreapi.NewOptString("/gh/o/web"), } if err := printJSON(w, repo); err != nil { t.Errorf("encode repo: %v", err) @@ -86,7 +94,7 @@ func TestRepoCreate_HumanByDefault(t *testing.T) { require.NoError(t, err) require.Contains(t, out, "✓ Created repository web ("+testDeleteULID+")") require.Contains(t, out, "Remote: entire://c.example.com/gh/o/web") - require.Empty(t, errOut) + require.Contains(t, errOut, "Waiting for repository web to become active") } // Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index d210f5df8d..bbbf009690 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -65,7 +65,7 @@ Checks performed: Fix by re-running 'entire enable --force'. 5. Summary provider: warn when summary_generation.provider names a registered - agent that cannot generate text (e.g. opencode), which makes + agent that cannot generate text (e.g. factoryai-droid), which makes 'entire checkpoint explain --generate', 'entire dispatch' and 'entire runner setup' fail. Reports the file to change; does not rewrite it. diff --git a/cmd/entire/cli/doctor_summary_provider_test.go b/cmd/entire/cli/doctor_summary_provider_test.go index eb4b393de5..836a79d341 100644 --- a/cmd/entire/cli/doctor_summary_provider_test.go +++ b/cmd/entire/cli/doctor_summary_provider_test.go @@ -168,7 +168,7 @@ func TestCheckSummaryProvider_SilentWhenSettingsWillNotLoad(t *testing.T) { // the local layer still overrides. // // Runs against REAL settings loading and the REAL registry rather than the -// stubs the other tests use. Two reasons: opencode is genuinely incapable, so +// stubs the other tests use. Two reasons: factoryai-droid is genuinely incapable, so // the registry needs no help; and the tracked-local case cannot be stubbed at // all, because localLayerRejection is unexported — only a real Load over a real // tracked file produces it. @@ -185,7 +185,7 @@ func TestCheckSummaryProvider_RemedyTargetsTheLayerHoldingTheValue(t *testing.T) }{ { name: "provider from the project layer", - project: `{"enabled":true,"summary_generation":{"provider":"opencode"}}`, + project: `{"enabled":true,"summary_generation":{"provider":"factoryai-droid"}}`, wantFile: settings.EntireSettingsFile, wantLocal: false, why: "only the project file carries it", @@ -193,14 +193,14 @@ func TestCheckSummaryProvider_RemedyTargetsTheLayerHoldingTheValue(t *testing.T) { name: "provider from the local layer", project: `{"enabled":true}`, - local: `{"summary_generation":{"provider":"opencode"}}`, + local: `{"summary_generation":{"provider":"factoryai-droid"}}`, wantFile: settings.EntireSettingsLocalFile, wantLocal: true, why: "the local layer supplies it, so configure needs --local", }, { name: "local layer supplies a different provider", - project: `{"enabled":true,"summary_generation":{"provider":"opencode"}}`, + project: `{"enabled":true,"summary_generation":{"provider":"factoryai-droid"}}`, local: `{"summary_generation":{"provider":"claude-code"}}`, wantFile: settings.EntireSettingsFile, wantLocal: false, @@ -208,8 +208,8 @@ func TestCheckSummaryProvider_RemedyTargetsTheLayerHoldingTheValue(t *testing.T) }, { name: "tracked local layer is ignored by the loader", - project: `{"enabled":true,"summary_generation":{"provider":"opencode"}}`, - local: `{"summary_generation":{"provider":"opencode"}}`, + project: `{"enabled":true,"summary_generation":{"provider":"factoryai-droid"}}`, + local: `{"summary_generation":{"provider":"factoryai-droid"}}`, trackLocal: true, wantFile: settings.EntireSettingsFile, wantLocal: false, diff --git a/cmd/entire/cli/entiredir_guard_test.go b/cmd/entire/cli/entiredir_guard_test.go index 7025b7deb3..6b9aa37f8e 100644 --- a/cmd/entire/cli/entiredir_guard_test.go +++ b/cmd/entire/cli/entiredir_guard_test.go @@ -38,6 +38,7 @@ var entireDirCheckExemptions = map[string]string{ "entire auth": "reads ~/.config/entire and the OS keyring, never the repo", "entire login": "control-plane login; user-level credentials only", "entire logout": "control-plane logout; user-level credentials only", + "entire cluster": "control-plane only", "entire org": "control-plane only", "entire project": "control-plane only", "entire repo": "control-plane only; git content operations are out of scope", diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index f905d712b5..6e9a57e9c3 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -116,7 +116,7 @@ func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkp // and "install claude-code" names nothing you can install. The mapping // lives in isSummaryCLIAvailable; deriving this needs that, not the // name list. - return nil, errors.New("no summary-capable provider is available; install claude, codex, gemini, pi, cursor, or copilot, install an external entire-agent-* plugin that declares text_generator, or set summary_generation.provider in settings") + return nil, errors.New("no summary-capable provider is available; install claude, codex, gemini, pi, opencode, cursor, or copilot, install an external entire-agent-* plugin that declares text_generator, or set summary_generation.provider in settings") case 1: return autoSelectSummaryProvider(ctx, w, candidates[0].Name, "non-interactive auto-select: single installed provider", selectionAutomatic) default: @@ -331,7 +331,7 @@ func summaryCapableProviderNames() []string { // The list is the whole point. Every agent name is a valid value for `entire // enable --agent`, and summary_generation.provider takes names out of that same // registry — so writing the agent you code with into it looks right and is how -// `opencode` and `factoryai-droid` end up there. The bare sentence said the +// `factoryai-droid` ends up there. The bare sentence said the // value was wrong without saying what a right one looks like, and neither // `status` nor the settings loader mentions the field at all, so this error is // the first and only place a user hears about it. diff --git a/cmd/entire/cli/explain_summary_provider_test.go b/cmd/entire/cli/explain_summary_provider_test.go index 33f9dc5869..8c6b7989a3 100644 --- a/cmd/entire/cli/explain_summary_provider_test.go +++ b/cmd/entire/cli/explain_summary_provider_test.go @@ -1374,9 +1374,9 @@ func TestUnsupportedSummaryProviderError_DegradesWithNoCapableProviders(t *testi func TestSummaryCapableProviderNames_MatchesTheBuiltInAgents(t *testing.T) { t.Parallel() - // opencode and factoryai-droid are deliberately absent: both are registered - // agents with no GenerateText, and naming one is the fault this feature reports. - want := []string{"claude-code", "codex", "copilot-cli", "cursor", "gemini", "pi"} + // factoryai-droid is deliberately absent: it is a registered agent with no + // GenerateText, and naming it is the fault this feature reports. + want := []string{"claude-code", "codex", "copilot-cli", "cursor", "gemini", "opencode", "pi"} got := summaryCapableProviderNames() if !slices.Equal(got, want) { t.Errorf("summary-capable providers = %v, want %v\n"+ diff --git a/cmd/entire/cli/global_test.go b/cmd/entire/cli/global_test.go index 90e3649c95..0e3d74a67c 100644 --- a/cmd/entire/cli/global_test.go +++ b/cmd/entire/cli/global_test.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "github.com/entireio/cli/cmd/entire/cli/auth" "os" "path/filepath" "testing" @@ -38,6 +39,20 @@ func TestMain(m *testing.M) { os.Setenv("ENTIRE_CONFIG_DIR", filepath.Join(isolationDir, "config")) os.Setenv("XDG_CACHE_HOME", filepath.Join(isolationDir, "cache")) + // ENTIRE_TOKEN is isolated by ABSENCE, not by a redirected path, so it is + // not in the block above. Left set, it outranks every stored context in + // resolveEntireIdentityProfile, so a test driving the production identity + // resolver sends the developer's own bearer to the host in that token's aud + // claim — a live request to a real core from a unit test — and then fails, + // because the resolver returns a transport error instead of the guidance + // the test asserts. Unset, not set-to-blank: blank is "set but blank", + // which ParseEnvToken maps to errEntireEnvTokenRejected, whose guidance also + // carries the git-config line the tests look for — so they would pass + // without exercising the path they exist to pin. + if err := os.Unsetenv(auth.EnvTokenVar); err != nil { + panic(fmt.Errorf("failed to unset %s: %w", auth.EnvTokenVar, err)) + } + // Register a default ConfigSource so tests that call ConfigScoped // (directly or indirectly via Commit/CreateTag) don't fail with // "no config loader registered". diff --git a/cmd/entire/cli/integration_test/enable_identity_test.go b/cmd/entire/cli/integration_test/enable_identity_test.go new file mode 100644 index 0000000000..25ce14ca84 --- /dev/null +++ b/cmd/entire/cli/integration_test/enable_identity_test.go @@ -0,0 +1,69 @@ +//go:build integration + +package integration + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// TestEnable_NoIdentityNoTerminal_FailsFast spawns the real binary with no +// controlling terminal in a repo with no git identity, and asserts `entire +// enable` refuses immediately instead of starting a device-code login. +// +// The unit tests for this path all inject canPrompt, so none of them exercise +// the production wiring. That mattered: the first version of this feature +// gated on IsKnownUnattended, which is false under Claude Code, Codex, and any +// headless non-CI context, and the resulting device-code flow blocked in +// waitForApproval for up to 15 minutes on a code nobody would read. Every unit +// test passed. Only spawning the binary without a TTY reproduces it. +// +// The deadline is the assertion: a pass must come from a fast refusal, not from +// a test that happens to outlive the wait. +func TestEnable_NoIdentityNoTerminal_FailsFast(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + // InitRepo configures a local identity; the bug needs it absent. + for _, key := range []string{"user.name", "user.email"} { + unset := exec.CommandContext(t.Context(), "git", "config", "--local", "--unset-all", key) + unset.Dir = dir + // Exit 5 means the key was not set, which is the state we want anyway. + unset.Run() //nolint:errcheck // see above + } + + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + + start := time.Now() + cmd := execx.NonInteractive(ctx, getTestBinary(), "enable", "--agent", "claude-code") + cmd.Dir = dir + cmd.Env = testutil.GitIsolatedEnv() + out, err := cmd.CombinedOutput() + elapsed := time.Since(start) + + if ctx.Err() != nil { + t.Fatalf("enable did not return within %v — it is waiting on something (device login?):\n%s", elapsed, out) + } + if err == nil { + t.Fatalf("enable succeeded with no git identity and no terminal; want a refusal:\n%s", out) + } + // Well inside any device-code wait, so a regression cannot pass by being slow. + if elapsed > 30*time.Second { + t.Errorf("enable took %v to refuse; expected an immediate failure", elapsed) + } + text := string(out) + if !strings.Contains(text, "git config --global user.name") { + t.Errorf("output does not offer the direct git config fix:\n%s", text) + } + if strings.Contains(text, "Device code:") { + t.Errorf("a device-code login was started where it cannot be completed:\n%s", text) + } +} diff --git a/cmd/entire/cli/integration_test/reftable_repo_test.go b/cmd/entire/cli/integration_test/reftable_repo_test.go index 67d8a87054..c53810f2cb 100644 --- a/cmd/entire/cli/integration_test/reftable_repo_test.go +++ b/cmd/entire/cli/integration_test/reftable_repo_test.go @@ -50,7 +50,6 @@ func TestReftableRepository_EnableAndFirstCheckpoint(t *testing.T) { // git-refs. output := env.RunCLI( "enable", - "--no-github", "--agent", "claude-code", "--telemetry=false", "--checkpoint-backend", "branch", @@ -152,7 +151,7 @@ func TestReftableRepository_LinkedWorktree(t *testing.T) { // Pin the git-branch backend (see TestReftableRepository_EnableAndFirstCheckpoint): // first-run enable now defaults to git-refs, but this test asserts the // v1-branch metadata flow. - runCLIIn(t, env, worktreePath, "enable", "--no-github", "--agent", "claude-code", "--telemetry=false", "--checkpoint-backend", "branch") + runCLIIn(t, env, worktreePath, "enable", "--agent", "claude-code", "--telemetry=false", "--checkpoint-backend", "branch") if got := gitOutput(t, worktreePath, "rev-parse", "--show-ref-format"); got != refFormatReftable { t.Fatalf("worktree ref format = %q, want reftable", got) @@ -188,7 +187,7 @@ func TestReftableRepository_GitRefsBackend(t *testing.T) { // No --checkpoint-backend flag: exercise the shipped first-run default, which // must write the git-refs primary into settings.json. - env.RunCLI("enable", "--no-github", "--agent", "claude-code", "--telemetry=false") + env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") if s := env.ReadFile(".entire/settings.json"); !strings.Contains(s, `"git-refs"`) { t.Fatalf("first-run enable on a reftable repo should default to the git-refs backend, settings.json:\n%s", s) } @@ -266,7 +265,7 @@ func TestReftableRepository_GitRefsBackend_LinkedWorktree(t *testing.T) { gitOutput(t, env.RepoDir, "worktree", "add", "-b", "feature/wt", worktreePath) // Default backend (git-refs): no --checkpoint-backend flag. - runCLIIn(t, env, worktreePath, "enable", "--no-github", "--agent", "claude-code", "--telemetry=false") + runCLIIn(t, env, worktreePath, "enable", "--agent", "claude-code", "--telemetry=false") if s := readWorktreeFile(t, worktreePath, ".entire/settings.json"); !strings.Contains(s, `"git-refs"`) { t.Fatalf("enable in a reftable worktree should default to git-refs, settings.json:\n%s", s) diff --git a/cmd/entire/cli/integration_test/setup_test.go b/cmd/entire/cli/integration_test/setup_test.go index 4cc25055d1..30916e6a0f 100644 --- a/cmd/entire/cli/integration_test/setup_test.go +++ b/cmd/entire/cli/integration_test/setup_test.go @@ -5,6 +5,7 @@ package integration import ( "context" "fmt" + "github.com/entireio/cli/cmd/entire/cli/auth" "os" "os/exec" "path/filepath" @@ -85,6 +86,17 @@ func TestMain(m *testing.M) { } } + // Same shape, same reason: absence, not a redirected path. ENTIRE_TOKEN + // outranks stored contexts in the identity resolver, and gitenv.Isolated() + // filters only GIT_CONFIG_*, so it reaches the spawned binary too — a test + // asserting the no-identity guidance would instead get a transport error + // from the host in the developer's token aud. + if err := os.Unsetenv(auth.EnvTokenVar); err != nil { + fmt.Fprintf(os.Stderr, "failed to unset %s: %v\n", auth.EnvTokenVar, err) + os.RemoveAll(tmpDir) + os.Exit(1) + } + moduleRoot := findModuleRoot() buildCmd := exec.CommandContext(context.Background(), "go", "build", "-o", testBinaryPath, ".") buildCmd.Dir = filepath.Join(moduleRoot, "cmd", "entire") diff --git a/cmd/entire/cli/integration_test/sha256_repo_test.go b/cmd/entire/cli/integration_test/sha256_repo_test.go index ecf1094d1f..c511f8baa7 100644 --- a/cmd/entire/cli/integration_test/sha256_repo_test.go +++ b/cmd/entire/cli/integration_test/sha256_repo_test.go @@ -34,7 +34,6 @@ func TestSHA256Repository_EnableAndFirstCheckpoint(t *testing.T) { // git-refs. output := env.RunCLI( "enable", - "--no-github", "--agent", agentClaudeCode, "--telemetry=false", "--checkpoint-backend", "branch", diff --git a/cmd/entire/cli/login.go b/cmd/entire/cli/login.go index 504cfa64b1..9641efb81a 100644 --- a/cmd/entire/cli/login.go +++ b/cmd/entire/cli/login.go @@ -171,27 +171,7 @@ func newLoginCmd() *cobra.Command { Use: "login", Short: "Log in to Entire", RunE: func(cmd *cobra.Command, _ []string) error { - loginServer, err := parseLoginServer(server) - if err != nil { - return fmt.Errorf("invalid --server: %w", err) - } - if err := requireSecureLoginServer(loginServer, insecureHTTPAuth); err != nil { - return err - } - client := auth.NewClient(loginServer, nil, insecureHTTPAuth) - // Closure adapts the concrete *auth.BrowserAuthFlow result to the - // browserAuthFlow interface (func types are invariant, so the - // method value alone won't do). On error the flow is a typed nil, - // which is fine — runLoginAuto checks err before touching it. - startBrowser := func(ctx context.Context) (browserAuthFlow, error) { - return client.StartBrowserAuth(ctx) - } - return runLoginAuto(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), - client, startBrowser, defaultLoginURLInteractor(cmd.ErrOrStderr()), loginFlowFacts{ - useDevice: useDevice, - canPrompt: interactive.CanPromptInteractively(), - sshSession: isSSHSession(), - }) + return runLoginCommand(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), server, insecureHTTPAuth, useDevice) }, } cmd.Flags().StringVar(&server, "server", api.DefaultAuthBaseURL, @@ -201,6 +181,28 @@ func newLoginCmd() *cobra.Command { return cmd } +func runLoginCommand(ctx context.Context, outW, errW io.Writer, server string, insecureHTTPAuth, useDevice bool) error { + loginServer, err := parseLoginServer(server) + if err != nil { + return fmt.Errorf("invalid --server: %w", err) + } + if err := requireSecureLoginServer(loginServer, insecureHTTPAuth); err != nil { + return err + } + client := auth.NewClient(loginServer, nil, insecureHTTPAuth) + // Closure adapts the concrete *auth.BrowserAuthFlow result to the + // browserAuthFlow interface (func types are invariant, so the method value + // alone won't do). + startBrowser := func(ctx context.Context) (browserAuthFlow, error) { + return client.StartBrowserAuth(ctx) + } + return runLoginAuto(ctx, outW, errW, client, startBrowser, defaultLoginURLInteractor(errW), loginFlowFacts{ + useDevice: useDevice, + canPrompt: interactive.CanPromptInteractively(), + sshSession: isSSHSession(), + }) +} + // parseLoginServer validates and canonicalises the --server value: an // http(s) origin with nothing but scheme and host. Userinfo, path, query, // and fragment are rejected rather than silently dropped — the value diff --git a/cmd/entire/cli/opencode_summary_test.go b/cmd/entire/cli/opencode_summary_test.go new file mode 100644 index 0000000000..2802dbe543 --- /dev/null +++ b/cmd/entire/cli/opencode_summary_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestConfigureOpenCodeSummaryAndRunner(t *testing.T) { + // Changes CWD and the provider registry; must remain non-parallel. + catPath, err := exec.LookPath("cat") + if err != nil { + t.Fatal(err) + } + setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + stubCLIAvailable(t) + cmd := newSetupCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--summarize-provider", "opencode", "--summarize-model", "openai/test"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + s, err := settings.LoadFromFile(EntireSettingsFile) + if err != nil { + t.Fatal(err) + } + if s.SummaryGeneration == nil || s.SummaryGeneration.Provider != "opencode" || s.SummaryGeneration.Model != "openai/test" { + t.Fatalf("settings = %+v", s.SummaryGeneration) + } + found := false + for _, provider := range listEnabledSummaryProviders(t.Context()) { + if provider.Name == agent.AgentNameOpenCode { + found = true + } + } + if !found { + t.Fatal("OpenCode missing from existing provider selection") + } + for _, model := range []string{"", "openai/test"} { + provider, err := buildCheckpointSummaryProvider(agent.AgentNameOpenCode, model) + if err != nil { + t.Fatal(err) + } + if provider.Model != model || provider.Generator == nil || provider.TextGenerator == nil { + t.Fatalf("incorrect provider: %+v", provider) + } + } + root := setupRunnersDir(t) + writeRunner(t, filepath.Join(root, ".entire", "runners"), "trail-risk", "Evaluate {{ diff }}.") + runners, err := loadTuneRunners(root, "risk") + if err != nil { + t.Fatal(err) + } + response := `{"trail-risk":"Evaluate {{ diff }} and return a risk assessment."}` + event, err := json.Marshal(map[string]any{"type": "text", "part": map[string]string{"text": response}}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "events.jsonl") + if err := os.WriteFile(path, event, 0o600); err != nil { + t.Fatal(err) + } + agent.Register(agent.AgentNameOpenCode, func() agent.Agent { + return &opencode.OpenCodeAgent{CommandRunner: func(ctx context.Context, _ string, args ...string) *exec.Cmd { + i := slices.Index(args, "--model") + if i < 0 || args[i+1] != "openai/test" { + t.Fatalf("runner model not forwarded: %v", args) + } + return exec.CommandContext(ctx, catPath, path) + }} + }) + t.Cleanup(func() { agent.Register(agent.AgentNameOpenCode, opencode.NewOpenCodeAgent) }) + var output bytes.Buffer + provider, err := resolveCheckpointSummaryProvider(t.Context(), io.Discard) + if err != nil { + t.Fatal(err) + } + changes, skipped, err := runTuning(t.Context(), io.Discard, provider, runners, "synthetic tuning prompt", "") + if err != nil { + t.Fatal(err) + } + if err := applyTunedRunners(&output, io.Discard, root, changes, skipped, nil); err != nil { + t.Fatal(err) + } + updated, err := loadTuneRunners(root, "risk") + if err != nil { + t.Fatal(err) + } + if updated[0].Template != "Evaluate {{ diff }} and return a risk assessment." { + t.Fatalf("template = %q", updated[0].Template) + } +} diff --git a/cmd/entire/cli/repo.go b/cmd/entire/cli/repo.go index 577fdc8101..98310a27b6 100644 --- a/cmd/entire/cli/repo.go +++ b/cmd/entire/cli/repo.go @@ -2,11 +2,11 @@ package cli import ( "context" - "encoding/json" "errors" "fmt" "strconv" "strings" + "time" "github.com/spf13/cobra" @@ -44,31 +44,35 @@ func repoRow(r coreapi.Repo) []string { } // repoDetailColumns / repoDetailRow extend the shared repo view with the -// entire:// clone URL for the single-repo `get` output. The list view stays on -// the lean repoColumns — a full clone URL per row would bloat the table — but a -// person inspecting one repo wants the URL they can paste into `git clone` -// (COR-699). REMOTE is "-" until the repo is provisioned enough to have a -// resolvable cluster host + path. -var repoDetailColumns = []string{"ID", "NAME", "PROJECT", "CLUSTER", "STATE", "REMOTE"} +// provisioning reason and entire:// clone URL for the single-repo `get` output. +// The list view stays on the lean repoColumns — a full clone URL per row would +// bloat the table — but a person inspecting one repo wants the URL they can +// paste into `git clone` (COR-699). REMOTE is "-" until the repo is provisioned +// enough to have a resolvable cluster host + path. +var repoDetailColumns = []string{"ID", "NAME", "PROJECT", "CLUSTER", "STATE", "PROVISION REASON", "REMOTE"} func repoDetailRow(r coreapi.Repo) []string { remote := repoRemoteURL(r) if remote == "" { remote = "-" } - return append(repoRow(r), remote) + return append(repoRow(r), r.ProvisionReason.Or("-"), remote) } // repoRemoteURL synthesizes the entire:// clone/remote URL for a repo from // its resolved cluster host and path — the form `git clone` and // `git remote add` accept, which git-remote-entire reads back as the repo // slug from the URL path. Returns "" when either coordinate is missing (a -// still-provisioning repo may not have them yet); a half-formed URL is worse -// than none. +// still-provisioning repo may not have them yet) or when the host is not a +// bare host[:port] (validateClusterHost): the URL is pasted straight into +// `git clone`, which reads `real-host@evil.com` as userinfo and sends the repo +// token to evil.com, so a spoofable URL is worse than none. `repo clone` +// refuses the same host at its end; this keeps the printed and --json copies +// from handing out what clone would refuse. func repoRemoteURL(r coreapi.Repo) string { host := strings.TrimSpace(r.ClusterHost.Or("")) path := strings.TrimSpace(r.Path.Or("")) - if host == "" || path == "" { + if path == "" || validateClusterHost(host) != nil { return "" } return "entire://" + host + "/" + strings.TrimPrefix(path, "/") @@ -76,36 +80,14 @@ func repoRemoteURL(r coreapi.Repo) string { // repoCreateOutput renders a created repo as JSON with a synthesized `remote` // field merged in — the entire:// URL callers paste into `git clone` or -// `git remote add`. The repo carries a custom marshaler plus arbitrary -// additional properties, so it can't simply be embedded in a wrapper struct; -// instead it's round-tripped through its own encoder and the remote is merged -// into the resulting object. The synthesis only fills a gap: if the wire -// object already carries a `remote` (a future first-class field, or one -// arriving via additional properties) it's left untouched, so the -// server-provided value always wins. The field is omitted when the clone -// coordinates aren't resolvable yet rather than emitted half-formed. +// `git remote add` (see mergeSynthesizedField for the merge rules). The field +// is omitted when the clone coordinates aren't resolvable yet rather than +// emitted half-formed. func repoCreateOutput(r *coreapi.Repo) (any, error) { if r == nil { return nil, errors.New("nil repo") } - raw, err := json.Marshal(r) - if err != nil { - return nil, fmt.Errorf("encode repo: %w", err) - } - var obj map[string]json.RawMessage - if err := json.Unmarshal(raw, &obj); err != nil { - return nil, fmt.Errorf("decode repo: %w", err) - } - if _, ok := obj["remote"]; !ok { - if remote := repoRemoteURL(*r); remote != "" { - encoded, err := json.Marshal(remote) - if err != nil { - return nil, fmt.Errorf("encode remote: %w", err) - } - obj["remote"] = encoded - } - } - return obj, nil + return mergeSynthesizedField(r, "remote", func() string { return repoRemoteURL(*r) }) } // parseObjectFormat maps the CLI flag value to the wire enum, rejecting @@ -127,11 +109,36 @@ func newRepoCreateCmd() *cobra.Command { projectID string clusterHost string objectFormat string + noWait bool + waitTimeout time.Duration ) cmd := &cobra.Command{ Use: cmdCreateName, Short: "Create a repository in a project", - Args: cobra.ExactArgs(1), + Long: `Create a repository and wait for provisioning to become active by +default. Active means provisioning completed; later pushes or mirror +creation can still fail for other reasons. + +--wait-timeout must be positive. It bounds project resolution, creation, +and readiness polling after client setup, including creation with +--no-wait. Use --no-wait to return without confirming readiness. + +If creation succeeds but readiness cannot be confirmed, the command exits +nonzero and preserves the repository result. Do not create again to +recover. With --json, stdout contains one repository object; progress +and recovery instructions go to stderr.`, + Example: " entire repo create web --project acme\n" + + " entire repo create web --project acme --no-wait\n" + + " entire repo create web --project acme --wait-timeout=5m", + PreRunE: func(_ *cobra.Command, _ []string) error { + // Invalid flag values are usage errors, including zero/negative + // durations; match mirror create and Cobra's malformed-value path. + if waitTimeout <= 0 { + return errors.New("--wait-timeout must be positive") + } + return nil + }, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Refuse a name Entire could not address once it existed: every ref // parser drops a trailing `.git` (see gitDirSuffix), so the repo @@ -145,6 +152,12 @@ func newRepoCreateCmd() *cobra.Command { } return err } + if clusterHost != "" { + if err := validateClusterHost(clusterHost); err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("invalid --cluster-host: %w", err) + } + } var format coreapi.CreateRepoInputBodyObjectFormat if objectFormat != "" { parsed, err := parseObjectFormat(objectFormat) @@ -154,15 +167,14 @@ func newRepoCreateCmd() *cobra.Command { } format = parsed } - return runCoreMutation(cmd, func(ctx context.Context, c *coreapi.Client) (string, any, error) { + return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { + ctx, cancel := context.WithTimeout(ctx, waitTimeout) + defer cancel() projID, err := resolveProjectRef(ctx, c, projectID) if err != nil { - return "", nil, err - } - body := &coreapi.CreateRepoInputBody{ - Name: args[0], - ProjectId: projID, + return err } + body := &coreapi.CreateRepoInputBody{Name: args[0], ProjectId: projID} if clusterHost != "" { body.ClusterHost = coreapi.NewOptString(clusterHost) } @@ -171,20 +183,24 @@ func newRepoCreateCmd() *cobra.Command { } created, err := c.CreateRepo(ctx, body) if err != nil { - return "", nil, err - } - wire, err := repoCreateOutput(created) - if err != nil { - return "", nil, err + return err } - msg := fmt.Sprintf("✓ Created repository %s (%s)", created.Name, created.ID) - if remote := repoRemoteURL(*created); remote != "" { - msg += "\n Remote: " + remote + var waitErr error + if !noWait { + var finish func(bool) + waitErr = awaitRepoActive(ctx, c, created, func() { + finish = startSpinner(cmd.ErrOrStderr(), "Waiting for repository "+created.Name+" to become active") + }) + if finish != nil { + finish(waitErr == nil) + } } - return msg, wire, nil + return reportRepoCreation(cmd, created, noWait, waitErr) }) }, } + cmd.Flags().BoolVar(&noWait, "no-wait", false, "Return after creation without confirming provisioning readiness") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 10*time.Minute, "Time limit for project resolution, creation, and provisioning readiness") cmd.Flags().StringVar(&projectID, "project", "", "Owning project (name or ULID) (required)") cmd.Flags().StringVar(&clusterHost, "cluster-host", "", "Public host of the cluster to pin the repo to (defaults to the jurisdiction default)") cmd.Flags().StringVar(&objectFormat, "object-format", "", "Git object format for the repository: sha1 or sha256 (defaults to the server default)") @@ -306,20 +322,48 @@ func newRepoListCmd() *cobra.Command { func newRepoGetCmd() *cobra.Command { var project string + var authoritative bool cmd := &cobra.Command{ Use: "get ", Short: "Show a repository by /et// path, name, or ULID", - Args: cobra.ExactArgs(1), + Long: `Show repository details. Use --authoritative to also check provisioning +status. This command does not wait: it exits successfully when it can +read the repository, even if provisioning is still in progress or has failed. +Use --authoritative --json and inspect state for scripting; active means +provisioning has completed. + +The default read cannot confirm readiness. If the server cannot provide +readiness information, --authoritative reports an error or a missing state; +neither confirms readiness.`, + Example: " entire repo get /et/acme/web\n" + + " entire repo get /et/acme/web --json\n" + + " entire repo get /et/acme/web --authoritative", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runCoreObject(cmd, repoDetailColumns, repoDetailRow, func(ctx context.Context, c *coreapi.Client) (*coreapi.Repo, error) { repoID, err := resolveRepoRef(ctx, c, args[0], project) if err != nil { return nil, err } - return c.GetRepo(ctx, coreapi.GetRepoParams{RepoId: repoID}) + params := coreapi.GetRepoParams{RepoId: repoID} + if authoritative { + params.Authoritative = coreapi.NewOptBool(true) + } + repo, err := c.GetRepo(ctx, params) + if authoritative && readinessCheckUnavailable(err) { + // A registry-only fallback cannot answer the readiness question. + // Keep that choice explicit, and print here so renderCoreError + // cannot strip the recovery hint with the API error wrapper. + // The plain read is the default, so the hint names no flag: a + // value the user would have to restate is not a recovery step. + fmt.Fprintf(cmd.ErrOrStderr(), "%v\nUse entire repo get %s to inspect repository details without a readiness check.\n", renderRepoReadError(err), repoID) + return nil, NewSilentError(err) + } + return repo, err }) }, } + cmd.Flags().BoolVar(&authoritative, "authoritative", false, "Check repository provisioning status") bindRepoProjectFlag(cmd, &project) addJSONFlag(cmd) return cmd diff --git a/cmd/entire/cli/repo_clone.go b/cmd/entire/cli/repo_clone.go index 13817c01e9..d637801684 100644 --- a/cmd/entire/cli/repo_clone.go +++ b/cmd/entire/cli/repo_clone.go @@ -167,16 +167,20 @@ func resolveNativeCloneURL(ctx context.Context, c *coreapi.Client, project, repo if err != nil { return "", err } + // The host is server-provided but interpolated into the entire:// clone URL, + // so apply the same anti-token-leak guard as the mirror path (see the + // validateClusterHost call on the /gh/ branch). Checked before the URL is + // built: repoRemoteURL applies the same guard and answers "" for a bad + // host, which would otherwise be reported as a repo still provisioning. + if host := strings.TrimSpace(repo.ClusterHost.Or("")); host != "" { + if err := validateClusterHost(host); err != nil { + return "", fmt.Errorf("repo has an invalid cluster host %q: %w", host, err) + } + } cloneURL := repoRemoteURL(*repo) if cloneURL == "" { return "", fmt.Errorf("repo %s/%s has no clone URL yet (still provisioning?)", project, repoName) } - // The host is server-provided but interpolated into the entire:// clone URL, - // so apply the same anti-token-leak guard as the mirror path (see the - // validateClusterHost call on the /gh/ branch). - if err := validateClusterHost(repo.ClusterHost.Or("")); err != nil { - return "", fmt.Errorf("repo has an invalid cluster host %q: %w", repo.ClusterHost.Or(""), err) - } return cloneURL, nil } diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 373c88ea68..2b5a9a968c 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -482,6 +482,13 @@ func newRepoMirrorCreateCmd() *cobra.Command { " entire repo mirror create github.com/octocat/hello-world\n" + " entire repo mirror create github.com/octocat/hello-world aws-us-east-2.entire.io", Args: cobra.RangeArgs(0, 2), + PreRunE: func(_ *cobra.Command, _ []string) error { + // Preserve zero as an unbounded wait for existing callers. + if waitTimeout < 0 { + return errors.New("--wait-timeout must be zero or positive") + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { opts := mirrorCreateOptions{noWait: noWait, timeout: waitTimeout} if len(args) == 0 { @@ -527,7 +534,7 @@ func newRepoMirrorCreateCmd() *cobra.Command { }, } cmd.Flags().BoolVar(&noWait, "no-wait", false, "Return once the placement is registered, without waiting for the initial clone") - cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 30*time.Minute, "How long to wait for mirror request submission, placement, and clone readiness") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 30*time.Minute, "How long to wait for mirror request submission, placement, and clone readiness (0 waits indefinitely)") return cmd } @@ -568,6 +575,7 @@ func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, c } waitCtx := ctx + // Zero preserves the caller context without adding a timeout. if opts.timeout > 0 { var cancel context.CancelFunc waitCtx, cancel = context.WithTimeout(ctx, opts.timeout) diff --git a/cmd/entire/cli/repo_mirror_test.go b/cmd/entire/cli/repo_mirror_test.go index 00713c5db9..6d0e1f1c25 100644 --- a/cmd/entire/cli/repo_mirror_test.go +++ b/cmd/entire/cli/repo_mirror_test.go @@ -135,7 +135,7 @@ func TestRepoMirrorCreate_WaitTimeoutHelp(t *testing.T) { flag := newRepoMirrorCreateCmd().Flags().Lookup("wait-timeout") require.NotNil(t, flag) - require.Equal(t, "How long to wait for mirror request submission, placement, and clone readiness", flag.Usage) + require.Equal(t, "How long to wait for mirror request submission, placement, and clone readiness (0 waits indefinitely)", flag.Usage) } // TestReportOneShotMirror exercises the one-shot create's presentation across diff --git a/cmd/entire/cli/repo_readiness.go b/cmd/entire/cli/repo_readiness.go new file mode 100644 index 0000000000..d41bf01d80 --- /dev/null +++ b/cmd/entire/cli/repo_readiness.go @@ -0,0 +1,308 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/entireio/cli/internal/coreapi" +) + +// These values mirror the provisioning enum in entiredb api/corev1/repos.go. +// Mirror clone readiness has its own enum: an active repo need not be cloned. +const ( + repoStateProvisioning = "provisioning" + repoStateActive = "active" + repoStateFailed = "failed" +) + +// repoPollInterval is the first delay after the immediate probe. Reusing mirror's +// fixed 2s cadence mostly polls between scans: entiredb core/regional/reconciler.go +// scans every 30s by default and fails after 10 provisioning attempts. Double +// 2s through 4/8/16s to a 30s cap, with ±20% jitter to spread concurrent creates. +// This preserves quick activation feedback while allowing roughly 23 reads in +// the 10m command budget, rather than 300. Tests may shorten the initial delay. +var repoPollInterval = 2 * time.Second + +// repoPollRandom supplies independent jitter samples. Tests replace it to pin +// actual timer schedules; a shared global random seed would affect other users. +// This randomness spreads requests and carries no security-sensitive value. +var repoPollRandom = rand.Float64 + +const repoPollMaxInterval = 30 * time.Second + +// repoLifecycleGetter is the slice of *coreapi.Client needed by awaitRepoActive, +// declared as an interface so the poll is unit-testable with a fake. It uses the +// federation-validated transport: a cluster-specific client is no shortcut, +// because ENTIRE_TOKEN still chooses its audience core and a foreign registry +// snapshot cannot confirm lifecycle state. +type repoLifecycleGetter interface { + GetRepo(ctx context.Context, params coreapi.GetRepoParams) (*coreapi.Repo, error) +} + +// awaitRepoActive waits for an authoritative repository snapshot to become active. +// ctx must carry the caller's deadline; unlike awaitMirrorReady, this function +// has no overall timeout of its own. result is overwritten in place with the +// last observed snapshot, retaining creation coordinates omitted by enrichment. +// Only provisioning warrants polling; foreign, missing or unknown state cannot +// confirm readiness. onPoll, when non-nil, runs once just before polling starts. +func awaitRepoActive(ctx context.Context, c repoLifecycleGetter, result *coreapi.Repo, onPoll func()) error { + timer := time.NewTimer(repoPollInterval) + defer timer.Stop() + + interval := repoPollInterval + var failures repoPollFailures + started := false + authoritative := false + for { + if result.Foreign.Or(false) { + return errors.New("repository readiness unconfirmed: server returned a foreign registry snapshot") + } + switch result.State.Or("") { + case repoStateActive: + // POST may report registry state. Only a successful authoritative + // read can confirm readiness, including after transient read errors. + if authoritative { + return nil + } + case repoStateFailed: + return fmt.Errorf("repository provisioning failed: %s", result.ProvisionReason.Or("no reason supplied")) + case repoStateProvisioning: + case "": + return errors.New("the server did not return repository readiness information") + default: + return fmt.Errorf("repository readiness unconfirmed: unsupported lifecycle state %q", result.State.Or("")) + } + if err := ctx.Err(); err != nil { + return classifyWaitContextErr(err, "waiting for repository provisioning") + } + if failures.expired() { + return fmt.Errorf("poll repository lifecycle: %w", failures.last) + } + if !started { + started = true + if onPoll != nil { + onPoll() + } + } + + // Once reads fail, bound in-flight retries too: clipping only the sleep + // would let a stuck request spend the entire creation deadline. + pollCtx := ctx + cancel := func() {} + if failures.last != nil { + pollCtx, cancel = context.WithDeadline(ctx, failures.deadline) + } + snapshot, err := c.GetRepo(pollCtx, coreapi.GetRepoParams{RepoId: result.ID, Authoritative: coreapi.NewOptBool(true)}) + cancel() + switch { + case err != nil: + if ctx.Err() != nil { + return classifyWaitContextErr(ctx.Err(), "waiting for repository provisioning") + } + if failures.expired() { + return fmt.Errorf("poll repository lifecycle: %w", failures.last) + } + if failures.record(err) { + // Preserve the raw chain, as awaitMirrorReady does. Rendering is + // reportRepoCreation's job; callers still need API/signal identity. + return fmt.Errorf("poll repository lifecycle: %w", err) + } + default: + if snapshot.ID != result.ID { + return errors.New("repository readiness unconfirmed: inspection returned a different repository ID") + } + failures = repoPollFailures{} + retainRepoCreation(result, snapshot) + authoritative = true + // Observe terminal or incompatible state before sleeping. + if result.State.Or("") != repoStateProvisioning || result.Foreign.Or(false) { + continue + } + } + delay := repoPollDelay(interval, repoPollRandom()) + interval = min(interval*2, repoPollMaxInterval) + if failures.last != nil { + delay = min(delay, time.Until(failures.deadline)) + } + timer.Reset(delay) + select { + case <-ctx.Done(): + return classifyWaitContextErr(ctx.Err(), "waiting for repository provisioning") + case <-timer.C: + } + } +} + +// repoPollDelay adds bounded jitter to an interval; sample is in [0, 1]. +// The pure calculation lets boundary tests exercise endpoints exactly, instead +// of relying on the random source to eventually generate them. +func repoPollDelay(interval time.Duration, sample float64) time.Duration { + return time.Duration(float64(interval) * (0.8 + 0.4*sample)) +} + +// repoPollFailures bounds an uninterrupted run of read errors, independently of +// the provisioning timeout. Mirror's 15-error/~30s visibility allowance does not +// transfer to a repo we just created: give ordinary 4xx only two attempts/10s for +// replication lag, and 408/429/5xx/transport errors +// six attempts/60s for a transient outage. The wall-clock window starts at the +// first failed response and bounds subsequent requests and sleeps; successful +// reads reset it. Counts alone would make backoff stretch failure unpredictably. +// +// ErrorModelStatusCode exposes status and body, not Retry-After headers. The +// server's per-user limiter uses http.Error (text/plain), which ogen surfaces as +// a decode error, likewise without Retry-After. Both shapes use the bounded +// transient policy until the transport exposes headers; do not pretend that a +// problem body's optional fields are an HTTP retry directive. +type repoPollFailures struct { + last error + first time.Time + deadline time.Time + count int +} + +func (f *repoPollFailures) expired() bool { + return f.last != nil && !time.Now().Before(f.deadline) +} + +func (f *repoPollFailures) record(err error) bool { + if f.last == nil { + f.first = time.Now() + } + f.deadline = f.first.Add(time.Minute) + f.last = err + f.count++ + limit := 6 + var problem *coreapi.ErrorModelStatusCode + if errors.As(err, &problem) && problem.StatusCode >= 400 && problem.StatusCode < 500 && + problem.StatusCode != http.StatusRequestTimeout && problem.StatusCode != http.StatusTooManyRequests { + // The window is recomputed from f.first for the CURRENT error's class, + // so a 4xx after a transient run moves the deadline into the past. That + // is unobservable only because this limit is 2: such a 4xx is always + // failure #2-or-later, so count ends the run on this same call, while a + // 4xx that is the FIRST failure has f.first == now and nothing to + // backdate. Raise the limit and the retroactive expiry becomes + // reachable — runs would then end through expired() rather than the + // count, so make that a deliberate choice rather than a side effect. + limit = 2 + f.deadline = f.first.Add(10 * time.Second) + } + return f.count >= limit || f.expired() +} + +// retainRepoCreation fills omitted creation coordinates into snapshot, then +// overwrites result with that snapshot. Both arguments are mutated. Name and +// owning project identify the successful POST; cluster host, path and the remote +// additional property let the user recover the clone URL if later enrichment +// fails (including after cleanup; remote coordinates follow COR-699). +// Preserve omitted additional properties too, including create-only fields +// such as commitToken. Fresh snapshot properties win collisions. Typed lifecycle, +// reason, permissions and foreign fields are replaced to avoid stale readiness. +func retainRepoCreation(result, snapshot *coreapi.Repo) { + if snapshot.Name == "" { + snapshot.Name = result.Name + } + if snapshot.OwningProjectId == "" { + snapshot.OwningProjectId = result.OwningProjectId + } + if snapshot.ClusterHost.Or("") == "" { + snapshot.ClusterHost = result.ClusterHost + } + if snapshot.Path.Or("") == "" { + snapshot.Path = result.Path + } + for key, value := range result.AdditionalProps { + if snapshot.AdditionalProps == nil { + snapshot.AdditionalProps = make(coreapi.RepoAdditional) + } + if _, set := snapshot.AdditionalProps[key]; !set { + snapshot.AdditionalProps[key] = value + } + } + *result = *snapshot +} + +// reportRepoCreation reports the successful POST even when waiting failed, +// unlike runCoreMutation. A nonzero exit does not mean another POST is safe. +func reportRepoCreation(cmd *cobra.Command, result *coreapi.Repo, noWait bool, waitErr error) error { + // repoRemoteURL answers "" for both an invalid host and a repo still + // provisioning; warn so the missing remote does not suggest waiting. + if host := strings.TrimSpace(result.ClusterHost.Or("")); host != "" { + if err := validateClusterHost(host); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: the server returned an invalid cluster host for this repository, so no remote URL was derived: %v\n", err) + } + } + var outputErr error + if jsonRequested(cmd) { + wire, err := repoCreateOutput(result) + if err != nil { + outputErr = err + } else { + outputErr = printJSON(cmd.OutOrStdout(), wire) + } + } else { + fmt.Fprintf(cmd.OutOrStdout(), "✓ Created repository %s (%s)\n Last observed state: %s\n", result.Name, result.ID, result.State.Or("unavailable")) + if reason := result.ProvisionReason.Or(""); reason != "" { + fmt.Fprintln(cmd.OutOrStdout(), " Provision reason: "+reason) + } + if remote := repoRemoteURL(*result); remote != "" { + fmt.Fprintln(cmd.OutOrStdout(), " Remote: "+remote) + } + } + if waitErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Repository creation succeeded: %s (%s). Readiness was not confirmed: %v\n", result.Name, result.ID, renderRepoReadError(waitErr)) + fmt.Fprintf(cmd.ErrOrStderr(), "Inspect repository details with: entire repo get %s\nCheck readiness with: entire repo get %s --authoritative\nWhen that command reports active, retry the intended push or mirror creation. If readiness remains unavailable, contact support with this repository ID. Do not create the repository again. For future creates, --no-wait skips readiness checks.\n", result.ID, result.ID) + return NewSilentError(errors.Join(waitErr, outputErr)) + } + if noWait && (result.State.Or("") != repoStateActive || result.Foreign.Or(false)) { + fmt.Fprintf(cmd.ErrOrStderr(), "Repository readiness is unconfirmed (--no-wait). Check readiness with: entire repo get %s --authoritative\n", result.ID) + } + return outputErr +} + +// renderRepoReadError keeps compatibility diagnostics local to readiness reads. +// Match the structured validation location and message, not a generic 422: +// unrelated validation failures must not be described as an older core. +func renderRepoReadError(err error) error { + if readinessParameterUnsupported(err) { + return fmt.Errorf("%w: query.authoritative: unknown query parameter; the server does not support repository readiness checks", renderCoreError(err)) + } + return renderCoreError(err) +} + +// readinessParameterUnsupported reports whether the server rejected the +// authoritative query parameter itself. See renderRepoReadError on why the +// structured location and message are matched rather than a bare 422. +func readinessParameterUnsupported(err error) bool { + var problem *coreapi.ErrorModelStatusCode + if !errors.As(err, &problem) || problem.StatusCode != http.StatusUnprocessableEntity { + return false + } + for _, detail := range problem.Response.Errors { + if detail.Location.Or("") == "query.authoritative" && detail.Message.Or("") == "unknown query parameter" { + return true + } + } + return false +} + +// readinessCheckUnavailable reports whether dropping the readiness check would +// plausibly let the read succeed: the core cannot serve or route the lifecycle +// read (503), or it does not know the parameter at all (the compatibility 422 +// above). Every other failure — the repository is missing, the caller cannot +// see it, the ID is malformed — is about the repository rather than about +// readiness, so a retry without the check answers nothing and the hint is +// withheld. +func readinessCheckUnavailable(err error) bool { + var problem *coreapi.ErrorModelStatusCode + if errors.As(err, &problem) && problem.StatusCode == http.StatusServiceUnavailable { + return true + } + return readinessParameterUnsupported(err) +} diff --git a/cmd/entire/cli/repo_readiness_test.go b/cmd/entire/cli/repo_readiness_test.go new file mode 100644 index 0000000000..9f9feb4b44 --- /dev/null +++ b/cmd/entire/cli/repo_readiness_test.go @@ -0,0 +1,743 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/entireio/cli/internal/coreapi" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Not parallel: runCoreCmd replaces the process-global client seam. +func TestRepoGetAuthoritativeSnapshot(t *testing.T) { + for _, state := range []string{"provisioning", "active", "failed", "", "future"} { + t.Run(state, func(t *testing.T) { + var reads atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reads.Add(1) + if r.Method != http.MethodGet || r.URL.Query().Get("authoritative") != "true" { + t.Errorf("expected authoritative GET, got %s %s", r.Method, r.URL) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":%q,"provider":"entire","state":%q,"provisionReason":"max retries exhausted","capabilities":{"canManage":false,"canPush":false,"canPull":true}}`, testDeleteULID, testProjectULID, state) + })) + defer srv.Close() + for _, args := range [][]string{{testDeleteULID}, {testDeleteULID, "--json"}} { + out, _, err := runCoreCmd(t, newRepoGetCmd, srv.URL, append(args, "--authoritative")...) + require.NoError(t, err) + require.Contains(t, out, "max retries exhausted") + if len(args) > 1 { + var obj map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &obj)) + require.Equal(t, state, obj["state"]) + } + } + require.EqualValues(t, 2, reads.Load(), "one snapshot per invocation") + }) + } +} + +func TestRepoCreateReadinessFlags(t *testing.T) { + // Not parallel: shared client seam. + for _, tc := range []struct { + name string + args []string + wantErr bool + wantCreates int32 + }{ + {name: "no wait", args: []string{"--no-wait"}, wantCreates: 1}, + {name: "zero", args: []string{"--wait-timeout=0"}, wantErr: true}, + {name: "negative", args: []string{"--wait-timeout=-1s"}, wantErr: true}, + {name: "invalid", args: []string{"--wait-timeout=oops"}, wantErr: true}, + {name: "no wait still validates", args: []string{"--no-wait", "--wait-timeout=0"}, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var creates atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("unexpected %s", r.Method) + } + creates.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":%q,"provider":"entire","state":"provisioning","capabilities":{"canManage":true,"canPush":true,"canPull":true}}`, testDeleteULID, testProjectULID) + })) + defer srv.Close() + args := append([]string{"web", "--project", testProjectULID, "--json"}, tc.args...) + out, stderr, err := runCoreCmd(t, func() *cobra.Command { + cmd := newRepoCreateCmd() + // The real root delegates error output to main, not Cobra. + cmd.SilenceErrors = true + return cmd + }, srv.URL, args...) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Contains(t, out, testDeleteULID) + require.Contains(t, stderr, "unconfirmed") + } + require.Equal(t, tc.wantCreates, creates.Load()) + }) + } +} + +// Not parallel: replaces the repository cadence seam. +func TestRepoCreateReadinessResults(t *testing.T) { + prev := repoPollInterval + repoPollInterval = time.Millisecond + t.Cleanup(func() { repoPollInterval = prev }) + for _, tc := range []struct { + name, initial, final string + pollStatus, polls int + wantErr bool + foreign, mismatched bool + }{ + {name: "already active", initial: "active", final: "active", polls: 1}, + {name: "active creation but region still provisioning", initial: "active", final: "active", polls: 3}, + {name: "active creation but failed region", initial: "active", final: "failed", polls: 1, wantErr: true}, + {name: "active creation but unavailable region", initial: "active", pollStatus: 503, polls: 6, wantErr: true}, + {name: "active creation but foreign snapshot", initial: "active", final: "active", foreign: true, polls: 1, wantErr: true}, + {name: "active creation but missing lifecycle", initial: "active", final: "", polls: 1, wantErr: true}, + {name: "multiple pending", initial: "provisioning", final: "active", polls: 3}, + {name: "failed snapshot with restored access", initial: "provisioning", final: "failed", polls: 1, wantErr: true}, + {name: "old server missing state", initial: "", wantErr: true}, + {name: "unknown initial", initial: "future", wantErr: true}, + {name: "missing on poll", initial: "provisioning", final: "", polls: 1, wantErr: true}, + {name: "unknown on poll", initial: "provisioning", final: "future", polls: 1, wantErr: true}, + {name: "creator access removed by cleanup", initial: "provisioning", pollStatus: 403, polls: 2, wantErr: true}, + {name: "foreign registry removed by cleanup", initial: "provisioning", pollStatus: 404, polls: 2, wantErr: true}, + {name: "routing unavailable", initial: "provisioning", pollStatus: 503, polls: 6, wantErr: true}, + {name: "missing home host", initial: "provisioning", pollStatus: 500, polls: 6, wantErr: true}, + {name: "foreign on poll", initial: "provisioning", final: "active", foreign: true, polls: 1, wantErr: true}, + {name: "mismatched ID", initial: "provisioning", final: "active", mismatched: true, polls: 1, wantErr: true}, + {name: "rejected parameter", initial: "provisioning", pollStatus: 422, polls: 2, wantErr: true}, + {name: "rate limited", initial: "provisioning", pollStatus: 429, polls: 6, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, asJSON := range []bool{false, true} { + var posts, gets atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + state := tc.initial + switch r.Method { + case http.MethodPost: + posts.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":%q,"provider":"entire","state":%q,"commitToken":"tok-abc","clusterHost":"cell.example","path":"/et/project/web","capabilities":{"canManage":true,"canPush":true,"canPull":true}}`, testDeleteULID, testProjectULID, state) + return + case http.MethodGet: + n := gets.Add(1) + if r.URL.Query().Get("authoritative") != "true" { + t.Error("missing authoritative query") + } + if tc.pollStatus == http.StatusTooManyRequests { + http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests) + return + } + if tc.pollStatus != 0 { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(tc.pollStatus) + detail := map[int]string{403: "permission denied", 404: "repo not found", 503: "repository lifecycle unavailable on this core", 500: `cluster jurisdiction "eu" has no auth host wired on this core`}[tc.pollStatus] + if tc.pollStatus == 422 { + fmt.Fprint(w, `{"detail":"validation failed","errors":[{"message":"unknown query parameter","location":"query.authoritative"}]}`) + } else { + fmt.Fprintf(w, `{"status":%d,"title":%q,"detail":%q}`, tc.pollStatus, http.StatusText(tc.pollStatus), detail) + } + return + } + state = "provisioning" + if int(n) >= tc.polls { + state = tc.final + } + default: + t.Errorf("unexpected request: %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + // Real enrichment can omit remote coordinates and owning project after cleanup. + snapshotID := testDeleteULID + if tc.mismatched { + snapshotID = testProjectULID + } + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":"","provider":"entire","state":%q,"provisionReason":"max retries exhausted","foreign":%t,"capabilities":{"canManage":false,"canPush":false,"canPull":true}}`, snapshotID, state, tc.foreign) + })) + args := []string{"web", "--project", testProjectULID} + if asJSON { + args = append(args, "--json") + } + out, stderr, err := runCoreCmd(t, func() *cobra.Command { + cmd := newRepoCreateCmd() + // The real root delegates error output to main, not Cobra. + cmd.SilenceErrors = true + return cmd + }, srv.URL, args...) + srv.Close() + if tc.wantErr { + require.Error(t, err) + require.Contains(t, stderr, "creation succeeded") + require.Contains(t, stderr, "repo get "+testDeleteULID) + require.Contains(t, stderr, "support") + require.Contains(t, stderr, "--authoritative") + if tc.pollStatus == 422 { + require.Contains(t, stderr, "query.authoritative") + require.Contains(t, stderr, "unknown query parameter") + require.Contains(t, stderr, "--no-wait") + } + if tc.pollStatus == http.StatusForbidden { + var statusErr *coreapi.ErrorModelStatusCode + require.ErrorAs(t, err, &statusErr) + var silent *SilentError + require.ErrorAs(t, err, &silent) + // Model main's rendering gate: removing runCoreClient's guard + // must produce a second copy of the problem detail here. + if !errors.As(err, &silent) { + stderr += fmt.Sprintln(renderCoreError(err)) + } + require.Equal(t, 1, strings.Count(stderr, "permission denied")) + } + } else { + require.NoError(t, err) + } + require.Contains(t, out, testDeleteULID) + require.Contains(t, out, "entire://cell.example/et/project/web") + require.EqualValues(t, 1, posts.Load()) + require.EqualValues(t, tc.polls, gets.Load()) + if asJSON { + var obj map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &obj)) + require.Equal(t, "tok-abc", obj["commitToken"]) + require.Equal(t, testProjectULID, obj["owningProjectId"]) + require.Equal(t, testDeleteULID, obj["id"]) + expectedState := tc.initial + if tc.polls > 0 && tc.pollStatus == 0 && !tc.mismatched { + expectedState = tc.final + } + require.Equal(t, expectedState, obj["state"]) + } + } + }) + } +} + +type repoReadFunc func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) + +func (f repoReadFunc) GetRepo(ctx context.Context, p coreapi.GetRepoParams) (*coreapi.Repo, error) { + return f(ctx, p) +} + +func TestAwaitRepoActive(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + foreign bool + state string + }{ + {name: "foreign registry", foreign: true}, + {name: "foreign active is not authoritative", foreign: true, state: "active"}, + {name: "unknown", state: "future"}, + {name: "missing"}, + {name: "failed", state: "failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString(tc.state), Foreign: coreapi.NewOptBool(tc.foreign)} + calls := 0 + err := awaitRepoActive(t.Context(), repoReadFunc(func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) { + calls++ + return nil, errors.New("unexpected") + }), result, nil) + require.Error(t, err) + require.Zero(t, calls) + require.Equal(t, testDeleteULID, result.ID) + }) + } + t.Run("errors reset after successful read", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("provisioning")} + calls := 0 + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + err := awaitRepoActive(ctx, repoReadFunc(func(_ context.Context, p coreapi.GetRepoParams) (*coreapi.Repo, error) { + calls++ + require.True(t, p.Authoritative.Or(false)) + if calls != 3 && calls != 6 { + return nil, errors.New("temporary read error") + } + state := "provisioning" + if calls == 6 { + state = "active" + } + return &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString(state)}, nil + }), result, nil) + require.NoError(t, err) + require.Equal(t, 6, calls) + require.Equal(t, "active", result.State.Or("")) + }) + }) + for _, inFlight := range []bool{false, true} { + t.Run(fmt.Sprintf("deadline in flight %v", inFlight), func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("provisioning")} + calls := 0 + err := awaitRepoActive(ctx, repoReadFunc(func(ctx context.Context, _ coreapi.GetRepoParams) (*coreapi.Repo, error) { + calls++ + if inFlight { + <-ctx.Done() + return nil, ctx.Err() + } + return result, nil + }), result, nil) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Equal(t, "provisioning", result.State.Or("")) + if inFlight { + require.Equal(t, 1, calls) + } else { + // The lower jitter bounds allow a third probe at 4.8s. + require.GreaterOrEqual(t, calls, 2) + require.LessOrEqual(t, calls, 3) + } + }) + }) + } + t.Run("canceled before polling", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("provisioning")} + err := awaitRepoActive(ctx, repoReadFunc(func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) { + t.Error("read after cancellation") + return nil, errors.New("unexpected read") + }), result, nil) + require.ErrorIs(t, err, context.Canceled) + }) +} + +// Not parallel: replaces activeCoreClient. The HTTP request is canceled only +// after POST has succeeded, pinning both output preservation and error identity +// used by main's signal-exit path. +func TestRepoCreateInterruptedAfterCreation(t *testing.T) { + for _, timeout := range []bool{false, true} { + t.Run(fmt.Sprintf("timeout %v", timeout), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var posts, gets atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":%q,"provider":"entire","state":"provisioning","commitToken":"tok-abc","clusterHost":"cell.example","path":"/et/project/web","capabilities":{"canManage":true,"canPush":true,"canPull":true}}`, testDeleteULID, testProjectULID) + return + } + gets.Add(1) + if !timeout { + cancel() + } + <-r.Context().Done() + })) + defer srv.Close() + prev := activeCoreClient + activeCoreClient = func(context.Context) (*coreapi.Client, error) { return coreapi.NewWithBearer(srv.URL, "tok") } + t.Cleanup(func() { activeCoreClient = prev }) + cmd := newRepoCreateCmd() + var out, stderr bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&stderr) + args := []string{"web", "--project", testProjectULID, "--json"} + if timeout { + args = append(args, "--wait-timeout=1s") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(ctx) + if timeout { + require.ErrorIs(t, err, context.DeadlineExceeded) + } else { + require.ErrorIs(t, err, context.Canceled) + } + var obj map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &obj)) + require.Equal(t, testDeleteULID, obj["id"]) + require.Equal(t, "entire://cell.example/et/project/web", obj["remote"]) + require.Contains(t, stderr.String(), "creation succeeded") + require.EqualValues(t, 1, posts.Load()) + require.EqualValues(t, 1, gets.Load()) + }) + } +} + +func TestReportRepoCreationNoWaitReason(t *testing.T) { + t.Parallel() + cmd := newRepoCreateCmd() + var out, stderr bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&stderr) + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("failed"), ProvisionReason: coreapi.NewOptString("max retries exhausted")} + require.NoError(t, reportRepoCreation(cmd, result, true, nil)) + require.Contains(t, out.String(), "max retries exhausted") + require.Contains(t, stderr.String(), "unconfirmed") +} + +func TestRepoCreateAlreadyReportedCoreError(t *testing.T) { + t.Parallel() + statusErr := &coreapi.ErrorModelStatusCode{StatusCode: http.StatusForbidden, + Response: coreapi.ErrorModel{Detail: coreapi.NewOptString("permission denied")}} + original := fmt.Errorf("command failed: %w", NewSilentError(errors.Join(statusErr, context.Canceled))) + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + err := runCoreClient(cmd, func(context.Context) (*coreapi.Client, error) { return &coreapi.Client{}, nil }, + func(context.Context, *coreapi.Client) error { return original }) + var silent *SilentError + require.ErrorAs(t, err, &silent) + require.ErrorIs(t, err, statusErr) + require.ErrorIs(t, err, context.Canceled) + // Display callers (notably the mirror wizard) still need a plain message. + rendered := renderCoreError(original) + require.EqualError(t, rendered, "permission denied") + require.NotErrorAs(t, rendered, &silent) +} + +// Not parallel: replaces the random-sample seam to pin the jittered schedule. +func TestAwaitRepoActiveBackoff(t *testing.T) { + previous := repoPollRandom + samples := []float64{0, 0.5, 1, 0, 0.5, 1} + next := 0 + repoPollRandom = func() float64 { + sample := samples[next%len(samples)] + next++ + return sample + } + t.Cleanup(func() { repoPollRandom = previous }) + synctest.Test(t, func(t *testing.T) { + start := time.Now() + var probes []time.Duration + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("provisioning")} + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + err := awaitRepoActive(ctx, repoReadFunc(func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) { + probes = append(probes, time.Since(start)) + state := "provisioning" + if len(probes) == 7 { + state = "active" + } + return &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString(state)}, nil + }), result, nil) + require.NoError(t, err) + require.Zero(t, probes[0], "the first probe must be immediate") + // Explicit values pin the production backoff and both jitter extremes, + // independently of repoPollDelay's implementation. + for i, want := range []time.Duration{ + 1600 * time.Millisecond, 4 * time.Second, 9600 * time.Millisecond, + 12800 * time.Millisecond, 30 * time.Second, 36 * time.Second, + } { + require.Equal(t, want, probes[i+1]-probes[i]) + } + require.Equal(t, 6, next, "each sleep gets a fresh jitter sample") + }) +} + +func TestAwaitRepoActiveErrorBudget(t *testing.T) { + t.Parallel() + for _, status := range []int{403, 404, 408, 429, 500, 503} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + start := time.Now() + calls := 0 + problem := &coreapi.ErrorModelStatusCode{StatusCode: status} + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("provisioning")} + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) + defer cancel() + err := awaitRepoActive(ctx, repoReadFunc(func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) { + calls++ + return nil, problem + }), result, nil) + require.ErrorIs(t, err, problem) + if status == 403 || status == 404 { + require.Equal(t, 2, calls) + require.LessOrEqual(t, time.Since(start), 10*time.Second) + } else { + require.GreaterOrEqual(t, calls, 5) + require.LessOrEqual(t, calls, 6) + require.LessOrEqual(t, time.Since(start), time.Minute) + } + }) + }) + } +} + +func TestRepoCreateMirrorReadinessFlags(t *testing.T) { + t.Parallel() + for _, value := range []string{"-1s", "oops"} { + t.Run(value, func(t *testing.T) { + t.Parallel() + for _, constructor := range []func() *cobra.Command{newRepoCreateCmd, newRepoMirrorCreateCmd} { + cmd := constructor() + cmd.RunE = func(*cobra.Command, []string) error { t.Error("invalid timeout reached RunE"); return nil } + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + args := []string{"foo", "--wait-timeout=" + value} + if cmd.Flags().Lookup("project") != nil { + args = append(args, "--project", testProjectULID) + } + cmd.SetArgs(args) + require.Error(t, cmd.ExecuteContext(t.Context())) + require.False(t, cmd.SilenceUsage, "invalid flags are usage errors on both commands") + } + }) + } +} + +// Not parallel: runCoreCmd replaces the shared client constructor. +func TestRepoGetAuthoritativeFlag(t *testing.T) { + // hint marks the failures a plain read could still answer. Every other + // status is a statement about the repository, so retrying without the + // readiness check changes nothing and the hint must stay away. + for _, tc := range []struct { + name, flag, query, body string + status int + hint bool + }{ + {name: "default"}, + {name: "explicit", flag: "--authoritative=true", query: "true"}, + {name: "plain", flag: "--authoritative=false"}, + {name: "unavailable", flag: "--authoritative", query: "true", status: 503, hint: true}, + {name: "rejected parameter", flag: "--authoritative", query: "true", status: 422, hint: true, + body: `{"detail":"repository read failed","errors":[{"message":"unknown query parameter","location":"query.authoritative"}]}`}, + {name: "unrelated validation", flag: "--authoritative", query: "true", status: 422, + body: `{"detail":"repository read failed","errors":[{"message":"expected a ULID","location":"path.repo_id"}]}`}, + {name: "forbidden", flag: "--authoritative", query: "true", status: 403}, + {name: "missing", flag: "--authoritative", query: "true", status: 404}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + assert.Equal(t, tc.query, r.URL.Query().Get("authoritative")) + assert.Equal(t, tc.query != "", r.URL.Query().Has("authoritative")) + if tc.status != 0 { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(tc.status) + if tc.body != "" { + fmt.Fprint(w, tc.body) + } else { + fmt.Fprintf(w, `{"status":%d,"detail":"repository read failed"}`, tc.status) + } + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":%q,"capabilities":{"canManage":false,"canPush":false,"canPull":true}}`, testDeleteULID, testProjectULID) + })) + defer srv.Close() + args := []string{testDeleteULID} + if tc.flag != "" { + args = append(args, tc.flag) + } + _, stderr, err := runCoreCmd(t, newRepoGetCmd, srv.URL, args...) + if tc.status != 0 { + require.Error(t, err) + var silent *SilentError + if !errors.As(err, &silent) { + stderr += err.Error() + } + // The server's own message reaches the user either way. + require.Contains(t, stderr, "repository read failed") + if tc.hint { + require.Contains(t, stderr, "entire repo get "+testDeleteULID+" to inspect") + require.Contains(t, stderr, "without a readiness check") + } else { + require.NotContains(t, stderr, "readiness check") + } + if tc.hint && tc.status == 422 { + require.Contains(t, stderr, "query.authoritative") + require.Contains(t, stderr, "unknown query parameter") + } + // The plain read is the default; naming a flag value would send + // the user to restate one they never had to pass. + require.NotContains(t, stderr, "--authoritative=false") + require.NotContains(t, stderr, "--no-wait") + } else { + require.NoError(t, err) + } + require.EqualValues(t, 1, calls.Load(), "no silent fallback") + }) + } +} + +func TestAwaitRepoActiveJitterBounds(t *testing.T) { + t.Parallel() + // Pin both ends and the midpoint independently of the random source used + // by production; the synctest poll test checks the actual timer schedule. + for _, sample := range []struct { + value float64 + want time.Duration + }{ + {0, 24 * time.Second}, {0.5, 30 * time.Second}, {1, 36 * time.Second}, + } { + require.Equal(t, sample.want, repoPollDelay(30*time.Second, sample.value)) + } +} + +func TestAwaitRepoActivePollingCallback(t *testing.T) { + t.Parallel() + for _, initial := range []string{"active", "failed", "", "future", "provisioning"} { + t.Run(initial, func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + started, reads := 0, 0 + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString(initial)} + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + err := awaitRepoActive(ctx, repoReadFunc(func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) { + require.Equal(t, 1, started, "progress starts before the first read") + reads++ + state := "provisioning" + if reads == 2 { + state = "active" + } + return &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString(state)}, nil + }), result, func() { started++ }) + if initial == "provisioning" || initial == "active" { + require.NoError(t, err) + require.Equal(t, 1, started) + require.Equal(t, 2, reads) + } else { + require.Zero(t, started) + require.Zero(t, reads) + } + if initial == "" { + require.ErrorContains(t, err, "the server did not return repository readiness information") + } + }) + }) + } +} + +func TestAwaitRepoActiveErrorWindowBoundsInflight(t *testing.T) { + t.Parallel() + for _, status := range []int{404, 503} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + start := time.Now() + problem := &coreapi.ErrorModelStatusCode{StatusCode: status} + calls := 0 + result := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("provisioning")} + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) + defer cancel() + err := awaitRepoActive(ctx, repoReadFunc(func(ctx context.Context, _ coreapi.GetRepoParams) (*coreapi.Repo, error) { + calls++ + if calls == 1 { + return nil, problem + } + <-ctx.Done() + return nil, ctx.Err() + }), result, nil) + require.ErrorIs(t, err, problem, "keep the observed API failure when its retry window expires") + require.Equal(t, 2, calls) + want := time.Minute + if status == 404 { + want = 10 * time.Second + } + require.Equal(t, want, time.Since(start)) + require.NoError(t, ctx.Err(), "error window is distinct from the command deadline") + }) + }) + } +} + +func TestAwaitRepoActiveRetainsOnlyCreationCoordinates(t *testing.T) { + t.Parallel() + result := &coreapi.Repo{ID: testDeleteULID, Name: "web", OwningProjectId: testProjectULID, + State: coreapi.NewOptString("provisioning"), ProvisionReason: coreapi.NewOptString("stale"), + ClusterHost: coreapi.NewOptString("cell.example"), Path: coreapi.NewOptString("/et/acme/web"), + AdditionalProps: coreapi.RepoAdditional{"remote": []byte(`"entire://cell.example/et/acme/web"`)}} + snapshot := &coreapi.Repo{ID: testDeleteULID, State: coreapi.NewOptString("active")} + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + err := awaitRepoActive(ctx, repoReadFunc(func(context.Context, coreapi.GetRepoParams) (*coreapi.Repo, error) { return snapshot, nil }), result, nil) + require.NoError(t, err) + require.Equal(t, "web", result.Name) + require.Equal(t, testProjectULID, result.OwningProjectId) + require.Equal(t, "cell.example", result.ClusterHost.Or("")) + require.Equal(t, "/et/acme/web", result.Path.Or("")) + require.JSONEq(t, `"entire://cell.example/et/acme/web"`, string(result.AdditionalProps["remote"])) + require.Equal(t, "active", result.State.Or("")) + require.False(t, result.ProvisionReason.IsSet(), "do not preserve stale lifecycle enrichment") + require.Equal(t, *snapshot, *result) +} + +func TestRepoMirrorZeroTimeout(t *testing.T) { + t.Parallel() + cmd := newRepoMirrorCreateCmd() + called := false + cmd.RunE = func(*cobra.Command, []string) error { called = true; return nil } + cmd.SetArgs([]string{"foo", "--wait-timeout=0"}) + require.NoError(t, cmd.ExecuteContext(t.Context())) + require.True(t, called) +} + +func TestRepoPollMixedErrors(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + var failures repoPollFailures + require.False(t, failures.record(&coreapi.ErrorModelStatusCode{StatusCode: 404})) + time.Sleep(2 * time.Second) + require.False(t, failures.record(&coreapi.ErrorModelStatusCode{StatusCode: 503})) + time.Sleep(9 * time.Second) + require.False(t, failures.expired(), "transient response restores the longer window") + time.Sleep(49 * time.Second) + require.True(t, failures.expired(), "window still starts at the first failure") + }) +} + +func TestRetainRepoAdditionalProperties(t *testing.T) { + t.Parallel() + result := &coreapi.Repo{AdditionalProps: coreapi.RepoAdditional{ + "commitToken": []byte(`"tok-abc"`), "future": []byte(`{"version":1}`), + }} + snapshot := &coreapi.Repo{AdditionalProps: coreapi.RepoAdditional{ + "future": []byte(`{"version":2}`), + }} + retainRepoCreation(result, snapshot) + require.JSONEq(t, `"tok-abc"`, string(result.AdditionalProps["commitToken"])) + require.JSONEq(t, `{"version":2}`, string(result.AdditionalProps["future"])) +} + +func TestRepoReadErrorUnrelatedValidation(t *testing.T) { + t.Parallel() + problem := &coreapi.ErrorModelStatusCode{StatusCode: 422, Response: coreapi.ErrorModel{ + Detail: coreapi.NewOptString("invalid repository ID"), + Errors: []coreapi.ErrorDetail{{Location: coreapi.NewOptString("path.repoId"), + Message: coreapi.NewOptString("invalid value")}}, + }} + require.EqualError(t, renderRepoReadError(problem), "invalid repository ID") +} + +func TestRepoPollTransientThenOrdinaryError(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + var failures repoPollFailures + require.False(t, failures.record(&coreapi.ErrorModelStatusCode{StatusCode: 503})) + time.Sleep(2 * time.Second) + require.True(t, failures.record(&coreapi.ErrorModelStatusCode{StatusCode: 404}), + "ordinary failures still enforce the two-attempt limit") + }) +} diff --git a/cmd/entire/cli/repo_test.go b/cmd/entire/cli/repo_test.go index cd01e99a19..dae4b7e816 100644 --- a/cmd/entire/cli/repo_test.go +++ b/cmd/entire/cli/repo_test.go @@ -59,6 +59,18 @@ func TestRepoRemoteURL(t *testing.T) { }, want: "", }, + { + // The URL is pasted into `git clone`, which would read the real + // cluster as userinfo and send the repo token to evil.com; no URL + // is safer than a spoofable one, and `repo clone` refuses the same + // host at its end. + name: "a host that is not a bare host yields no URL", + repo: coreapi.Repo{ + ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io@evil.com"), + Path: coreapi.NewOptString("acme/web"), + }, + want: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -279,28 +291,41 @@ func TestParseObjectFormat(t *testing.T) { // minimal created repo and delivers each raw request body on the returned // channel. Points the active-context client seam at the server. func serveRepoCreate(t *testing.T) <-chan []byte { + t.Helper() + return serveRepoCreateWith(t, &coreapi.Repo{ + ID: "01KS6KFJR2XS6PZ188MVYE07AN", + Name: "web", + OwningProjectId: testProjectULID, + }) +} + +// serveRepoCreateWith is serveRepoCreate answering with the given created repo. +func serveRepoCreateWith(t *testing.T, created *coreapi.Repo) <-chan []byte { t.Helper() bodyCh := make(chan []byte, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos" { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos": + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read create body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + bodyCh <- raw + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/"+created.ID && r.URL.Query().Get("authoritative") == "true": + w.Header().Set("Content-Type", "application/json") + default: t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) return } - raw, err := io.ReadAll(r.Body) - if err != nil { - t.Errorf("read create body: %v", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - bodyCh <- raw - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := printJSON(w, &coreapi.Repo{ - ID: "01KS6KFJR2XS6PZ188MVYE07AN", - Name: "web", - OwningProjectId: testProjectULID, - }); err != nil { + response := *created + // The authoritative GET confirms the creation fixture is active. + response.State = coreapi.NewOptString("active") + if err := printJSON(w, &response); err != nil { t.Errorf("encode create response: %v", err) } })) @@ -323,6 +348,14 @@ func execRepoCreate(t *testing.T, args ...string) error { // execRepoCreateNamed is execRepoCreate with the repo name itself under test. func execRepoCreateNamed(t *testing.T, name string, args ...string) error { + t.Helper() + _, _, err := runRepoCreateNamed(t, name, args...) + return err +} + +// runRepoCreateNamed is execRepoCreateNamed returning what the command wrote +// to stdout and stderr as well. +func runRepoCreateNamed(t *testing.T, name string, args ...string) (stdout, stderr string, err error) { t.Helper() parent := &cobra.Command{Use: "repo"} addControlPlaneFlags(parent) @@ -331,7 +364,30 @@ func execRepoCreateNamed(t *testing.T, name string, args ...string) error { parent.SetOut(&out) parent.SetErr(&errOut) parent.SetArgs(append([]string{"create", name, "--project", testProjectULID}, args...)) - return parent.ExecuteContext(t.Context()) + err = parent.ExecuteContext(t.Context()) + return out.String(), errOut.String(), err +} + +// TestRepoCreate_WarnsOnInvalidServerHost pins that a created repo whose +// clusterHost fails validation is reported, not just quietly stripped of its +// remote: repoRemoteURL answers "" for both that and a still-provisioning +// repo, and only the warning tells the two apart. +// +// Not parallel: swaps the package-level activeCoreClient seam. +func TestRepoCreate_WarnsOnInvalidServerHost(t *testing.T) { + serveRepoCreateWith(t, &coreapi.Repo{ + ID: "01KS6KFJR2XS6PZ188MVYE07AN", + Name: "web", + OwningProjectId: testProjectULID, + ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io@evil.com"), + Path: coreapi.NewOptString("/acme/web"), + }) + stdout, stderr, err := runRepoCreateNamed(t, "web") + require.NoError(t, err) + require.NotContains(t, stdout, "Remote:") + require.NotContains(t, stdout, "evil.com") + require.Contains(t, stderr, "invalid cluster host") + require.Contains(t, stderr, "evil.com") } // TestRepoCreate_RejectsGitSuffix pins that the CLI refuses a name it would not @@ -365,6 +421,37 @@ func TestRepoCreate_RejectsGitSuffix(t *testing.T) { }) } +// TestRepoCreate_RejectsUnsafeClusterHost pins that --cluster-host gets the +// same bare-host check every other host-taking flag applies before the value +// is sent: the server pins the repo to it and echoes it back as clusterHost, +// which then becomes a clone URL, so a spoofable value must fail here rather +// than be created and refused at every later use. +// +// Not parallel: swaps the package-level activeCoreClient seam. +func TestRepoCreate_RejectsUnsafeClusterHost(t *testing.T) { + for _, host := range []string{"aws-us-east-2.entire.io@evil.com", "https://aws-us-east-2.entire.io", "aws-us-east-2.entire.io/path"} { + t.Run(host, func(t *testing.T) { + bodyCh := serveRepoCreate(t) + err := execRepoCreate(t, "--cluster-host", host) + require.ErrorContains(t, err, "--cluster-host") + require.ErrorContains(t, err, host) + select { + case raw := <-bodyCh: + t.Fatalf("no create request expected, got body %s", raw) + default: + } + }) + } + + t.Run("a bare host reaches the wire body", func(t *testing.T) { + bodyCh := serveRepoCreate(t) + require.NoError(t, execRepoCreate(t, "--cluster-host", "aws-us-east-2.entire.io")) + var body map[string]any + require.NoError(t, json.Unmarshal(<-bodyCh, &body)) + require.Equal(t, "aws-us-east-2.entire.io", body["clusterHost"]) + }) +} + // TestRepoCreate_ObjectFormat pins the --object-format wiring: a set flag // reaches the wire body, an unset flag leaves the field to the server // default, and an invalid value fails fast before any request is sent. diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 2c475f86b7..706a201cd2 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -175,6 +175,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(exemptFromEntireDirCheck(newLabsCmd())) // 'labs' (experimental workflow discovery) cmd.AddCommand(exemptFromEntireDirCheck(inGroup(newPluginGroupCmd(), groupSetup))) // 'plugin' (managed install/list/remove) experimental.Register(cmd, newImportCmd()) // 'import' (experimental; import pre-existing agent history) + cmd.AddCommand(exemptFromEntireDirCheck(inGroup(newClusterCmd(), groupControlPlane))) // 'cluster' — control-plane cluster catalog cmd.AddCommand(exemptFromEntireDirCheck(inGroup(newOrgCmd(), groupControlPlane))) // 'org' — control-plane org management cmd.AddCommand(exemptFromEntireDirCheck(inGroup(newProjectCmd(), groupControlPlane))) // 'project' — control-plane project management cmd.AddCommand(exemptFromEntireDirCheck(inGroup(newRepoCmd(), groupControlPlane))) // 'repo' — control-plane repo lifecycle diff --git a/cmd/entire/cli/root_test.go b/cmd/entire/cli/root_test.go index 90731ebf1d..008f6d0af1 100644 --- a/cmd/entire/cli/root_test.go +++ b/cmd/entire/cli/root_test.go @@ -297,6 +297,7 @@ func TestRoot_VisibleCommandsAreGrouped(t *testing.T) { "login": groupAccount, "logout": groupAccount, "auth": groupAccount, + "cluster": groupControlPlane, "org": groupControlPlane, "project": groupControlPlane, "repo": groupControlPlane, diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 8a768fe6ec..9a8f4183cd 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -416,9 +416,14 @@ func parseCheckpointRemoteFlag(value string) (provider, repo string, err error) return provider, repo, nil } -// runSetupFlow runs the first-time setup flow (agent selection + hooks + settings). -// Shared by root command (no args), `entire configure`, and `entire enable` on fresh repos. -func runSetupFlow(ctx context.Context, w io.Writer, opts EnableOptions) error { +// selectAgentsForSetup is the agent-selection half of first-time setup, split +// out so the bare-enable path can run the identity preflight between selection +// and runEnableInteractive — which is the whole point of the split, since the +// preflight has to sit after the user has chosen agents but before anything +// writes hooks or settings. Kept as one implementation because the alternative +// is two copies that drift, and it is small enough to sit under dupl's +// threshold where lint would not notice. +func selectAgentsForSetup(ctx context.Context, w io.Writer, opts EnableOptions) ([]agent.Agent, error) { // Discover external agent plugins so they appear in agent selection. // Use DiscoverAndRegisterAlways to bypass the external_agents setting — // during setup the setting doesn't exist yet. @@ -431,12 +436,70 @@ func runSetupFlow(ctx context.Context, w io.Writer, opts EnableOptions) error { agents, err := detectOrSelectAgent(ctx, w, selectFn) if err != nil { - return fmt.Errorf("agent selection failed: %w", err) + return nil, fmt.Errorf("agent selection failed: %w", err) + } + return agents, nil +} + +// runSetupFlow runs the first-time setup flow (agent selection + identity + +// hooks + settings). Shared by the root command (no args), `entire configure`, +// and `entire agent`. +// +// The identity preflight belongs here, not only in `entire enable`: these +// callers reach the same end state — hooks installed, settings written, commits +// flowing — for the same "existing repo, not set up yet" case, so a repo +// onboarded by bare `entire` would otherwise keep attributing commits to an +// unknown author, which is the bug the preflight exists to fix. +func runSetupFlow(ctx context.Context, w io.Writer, opts EnableOptions) error { + return runSetupFlowWithPreflight(ctx, w, opts, defaultIdentityPreflight(ctx, w)) +} + +// runSetupFlowWithPreflight is runSetupFlow with the identity step injected, so +// tests can drive the ordering without reaching the network. The ordering is +// the point: select agents, resolve identity, then write. The preflight sits +// between the two because it may fail or start a login, and neither should +// happen after hooks and settings are already on disk. +// +// `entire enable` deliberately does not route through here. It has to install +// its logger between the preflight and the writes — late enough that a rejected +// enable leaves no .entire/logs behind, and its context has to be re-read after +// that — so it spells the same three steps out itself, sharing +// selectAgentsForSetup rather than this wrapper. +func runSetupFlowWithPreflight(ctx context.Context, w io.Writer, opts EnableOptions, preflight func() error) error { + agents, err := selectAgentsForSetup(ctx, w, opts) + if err != nil { + return err + } + if preflight != nil { + if err := preflight(); err != nil { + return err + } } return runEnableInteractive(ctx, w, agents, opts) } +// defaultIdentityPreflight builds the identity step for callers that have no +// command flags to thread through. It resolves the worktree root itself and is +// a no-op when that fails: these entry points only run inside a git repo, so a +// failure here means something is wrong that setup will report in its own +// terms rather than as an identity error. +func defaultIdentityPreflight(ctx context.Context, w io.Writer) func() error { + return func() error { + repoRoot, rootErr := paths.WorktreeRoot(ctx) + if rootErr != nil { + // Callers reach here only from inside a git repo (root.go checks + // first), so this is unreachable in practice. If it ever is not, + // setup's own prerequisite handling gives the accurate message — + // failing here instead would report a missing identity for what is + // really a missing repo. + return nil //nolint:nilerr // deliberate skip; see above + } + return ensureGitIdentity(ctx, w, execRunner{}, repoRoot, + newEntireGitIdentityResolver(w, os.Stderr, false)) + } +} + // selectAllAgents is a selectFn that selects all available agents. // Used by --yes to skip the interactive agent selection prompt. func selectAllAgents(available []string) ([]string, error) { @@ -475,6 +538,24 @@ func hookAgentOptions(selected map[types.AgentName]struct{}) []huh.Option[string // runManageAgents shows which agents are currently enabled and lets the user // add or remove agents. Deselecting an installed agent removes its hooks. func runManageAgents(ctx context.Context, w io.Writer, opts EnableOptions, selectFn func(available []string) ([]string, error)) error { + return runManageAgentsWithPreflight(ctx, w, opts, selectFn, nil) +} + +func runManageAgentsWithPreflight( + ctx context.Context, + w io.Writer, + opts EnableOptions, + selectFn func(available []string) ([]string, error), + preflight func() error, +) error { + runPreflight := func() error { + if preflight == nil { + return nil + } + fn := preflight + preflight = nil + return fn() + } installedNames := GetAgentsWithHooksInstalled(ctx) // Show currently installed agents @@ -504,6 +585,9 @@ func runManageAgents(ctx context.Context, w io.Writer, opts EnableOptions, selec discoverNamedExternalAgent(ctx, name) selectedAgentNames = append(selectedAgentNames, string(name)) } + if err := runPreflight(); err != nil { + return err + } return applyAgentChanges(ctx, w, selectedAgentNames, installedNames, opts) } if opts.SearchSkill { @@ -557,6 +641,9 @@ func runManageAgents(ctx context.Context, w io.Writer, opts EnableOptions, selec return fmt.Errorf("agent selection cancelled: %w", err) } } + if err := runPreflight(); err != nil { + return err + } // Nothing selected and nothing installed — no-op. if len(selectedAgentNames) == 0 && len(installedNames) == 0 { @@ -796,7 +883,7 @@ Examples: cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)") cmd.Flags().StringVar(&opts.CheckpointBackend, flagCheckpointBackend, "", checkpointBackendFlagUsage) - cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, gemini, pi, cursor, copilot-cli)") + cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, gemini, pi, opencode, cursor, copilot-cli)") cmd.Flags().StringVar(&summarizeModel, flagSummarizeModel, "", "Set the model hint used by explain --generate") cmd.Flags().IntVar(&summarizeTimeoutSeconds, flagSummarizeTimeout, 0, "Set the hard deadline (seconds) for explain --generate summary generation. 0 clears the setting, leaving summary generation unbounded.") cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics") @@ -806,10 +893,14 @@ Examples: } func newEnableCmd() *cobra.Command { + return newEnableCmdWithIdentityResolverFactory(newEntireGitIdentityResolver) +} + +func newEnableCmdWithIdentityResolverFactory(identityFactory identityResolverFactory) *cobra.Command { var opts EnableOptions var ignoreUntracked bool var agentName string - var bootstrapOpts GitHubBootstrapOptions + var bootstrapOpts BootstrapOptions var insecureHTTPAuth bool cmd := &cobra.Command{ @@ -821,7 +912,8 @@ If Entire is not yet configured, this runs the full configuration flow. If Entire is already configured but disabled, this re-enables it. If the current directory is not a git repository, Entire can initialize one -for you and (optionally) create a matching GitHub repository via the gh CLI.`, +for you and create an initial commit. It never creates or pushes to a remote — +publish the repository yourself when you're ready.`, RunE: func(cmd *cobra.Command, _ []string) (runErr error) { ctx := cmd.Context() // The destination report needs the choice pointer, not the answer, @@ -831,8 +923,8 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, defer func() { opts.checkpointRemoteChoice.report(cmd.Context(), cmd.OutOrStdout(), runErr) }() // Best-effort: after a successful enable, tell the backend which repo // was enabled so the web onboarding reflects it (and we can warn when - // the GitHub App can't reach it). Runs after any bootstrap finalize that creates the - // GitHub repo and pushes, by which point an origin remote exists. + // the GitHub App can't reach it). A freshly bootstrapped repo has no + // origin yet, so this reports nothing until the user adds one. defer func() { if runErr != nil { return @@ -862,18 +954,20 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, ctx = cmd.Context() // Check if we're in a git repository first. If not, offer to - // bootstrap one (git init + optional GitHub repo). If the user - // declines, fall back to the legacy prerequisite error. + // bootstrap one (git init, local only). If the user declines, + // fall back to the legacy prerequisite error. // - // The bootstrap runs in two phases: phase 1 (git init + identity - // + gather GitHub choices) before agent setup, phase 2 - // (initial commit + gh repo create + push) after agent setup so - // the initial commit captures the .entire/, .claude/, hooks, and + // The bootstrap runs in two phases: phase 1 (git init + the + // initial-commit decision) before agent setup and identity + // recovery, phase 2 (the initial commit itself) after agent setup + // so that commit captures the .entire/, .claude/, hooks, and // settings files that setup writes. var bootstrap *bootstrapState - if _, err := paths.WorktreeRoot(ctx); err != nil { + repoRoot, repoErr := paths.WorktreeRoot(ctx) + repoExisted := repoErr == nil + if repoErr != nil { bootstrapOpts.Yes = opts.Yes - state, bootstrapErr := runGitHubBootstrapInit(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), bootstrapOpts) + state, bootstrapErr := runBootstrapInit(ctx, cmd.OutOrStdout(), bootstrapOpts) if errors.Is(bootstrapErr, errBootstrapDeclined) { fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run 'entire enable' from within a git repository, or pass --init-repo to initialize one here.") return NewSilentError(errors.New("not a git repository")) @@ -890,20 +984,22 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, // "done" summary from the bootstrap finalize step. opts.SuppressDoneMessage = true // Re-check after bootstrap. - if _, err := paths.WorktreeRoot(ctx); err != nil { - return fmt.Errorf("bootstrap finished but no git repository detected: %w", err) + var rootErr error + repoRoot, rootErr = paths.WorktreeRoot(ctx) + if rootErr != nil { + return fmt.Errorf("bootstrap finished but no git repository detected: %w", rootErr) } // Visual separator between bootstrap init and agent setup. printBootstrapSection(cmd.OutOrStdout(), "Enabling Entire") // On the way out (if setup succeeded), create the initial - // commit and push to the GitHub repo. If setup returned an - // error, skip the finalize — the user can fix the issue and - // re-run; any partial state is just untracked files. + // commit. If setup returned an error, skip the finalize — + // the user can fix the issue and re-run; any partial state + // is just untracked files. defer func() { if runErr != nil || bootstrap == nil { return } - if err := runGitHubBootstrapFinalize(ctx, cmd.OutOrStdout(), bootstrap); err != nil { + if err := runBootstrapFinalize(ctx, cmd.OutOrStdout(), bootstrap); err != nil { runErr = err } }() @@ -939,28 +1035,9 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, selectedAgent = ag } - // enable runs before the repo is set up, which is exactly when the - // root pre-run's IsSetUpAny gate declines to build a logger. Placed - // after every check that can still reject this invocation, so a - // rejected enable leaves an untouched repo untouched. - ensureLogger(cmd) - ctx = cmd.Context() - - if selectedAgent != nil { - // --agent is a targeted operation: set up this specific agent without - // affecting other agents. Unlike the interactive path, it does not - // uninstall hooks for other previously-enabled agents. - return setupAgentHooksNonInteractive(ctx, cmd.OutOrStdout(), selectedAgent, opts) - } - - // Any setup-mutating flags should behave like `configure` on repos that - // are already set up. Bare `enable` remains the lightweight re-enable path. - if settings.IsSetUpAny(ctx) { - return runEnableOnConfiguredRepo(ctx, cmd, opts) - } - - // Fresh repo — run full setup flow - return runSetupFlow(ctx, cmd.OutOrStdout(), opts) + needsIdentity := repoExisted || (bootstrap != nil && bootstrap.commit) + return continueEnableAfterAgentValidation(ctx, cmd, opts, selectedAgent, repoRoot, needsIdentity, + identityFactory(cmd.OutOrStdout(), cmd.ErrOrStderr(), insecureHTTPAuth)) }, } @@ -978,24 +1055,17 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, cmd.Flags().BoolVar(&opts.AbsoluteGitHookPath, flagAbsoluteGitHookPath, false, "Embed full binary path in git hooks (for GUI git clients that don't source shell profiles)") cmd.Flags().BoolVar(&opts.SearchSkill, flagSearchSkill, false, "Install the optional Entire search skill for selected agent(s)") cmd.Flags().BoolVar(&opts.AgentHelpSkill, flagAgentHelpSkill, false, "Install the stable Entire agent-help skill (points agents at `entire agent-help`) for selected agent(s)") - cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Accept all defaults without prompting (in a non-repo directory: init git, create private GitHub repo, commit, and push; then enable all agents and accept telemetry). Does not import existing agent history — see --"+flagImportHistory) + cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Accept all defaults without prompting (in a non-repo directory: init git and commit; then enable all agents and accept telemetry). Does not import existing agent history — see --"+flagImportHistory) cmd.Flags().BoolVar(&opts.ImportHistory, flagImportHistory, false, importHistoryFlagUsage) addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) // Bootstrap flags for non-git-repo folders. cmd.Flags().BoolVar(&bootstrapOpts.InitRepo, "init-repo", false, "If not a git repo, initialize one non-interactively") cmd.Flags().BoolVar(&bootstrapOpts.NoInitRepo, "no-init-repo", false, "If not a git repo, exit instead of prompting to initialize one") - cmd.Flags().StringVar(&bootstrapOpts.RepoName, "repo-name", "", "GitHub repository name for the new repo (used when bootstrapping)") - cmd.Flags().StringVar(&bootstrapOpts.RepoOwner, "repo-owner", "", "GitHub user or organization login for the new repo") - cmd.Flags().StringVar(&bootstrapOpts.RepoVisibility, "repo-visibility", "", "GitHub repository visibility: public, private, or internal") - cmd.Flags().BoolVar(&bootstrapOpts.NoGitHub, "no-github", false, "Initialize local git repo only; skip creating a GitHub remote") - cmd.Flags().BoolVar(&bootstrapOpts.Push, "push", false, "When bootstrapping a new repo, push the initial commit to the created GitHub remote (implies creating the remote; without it the repo is created but not pushed)") cmd.Flags().StringVar(&bootstrapOpts.InitialCommitMessage, "initial-commit-message", "", "Commit message for the initial commit when bootstrapping a new repo") cmd.Flags().BoolVar(&bootstrapOpts.SkipInitialCommit, "skip-initial-commit", false, "Don't create the initial commit when bootstrapping a new repo") cmd.MarkFlagsMutuallyExclusive("init-repo", "no-init-repo") cmd.MarkFlagsMutuallyExclusive("initial-commit-message", "skip-initial-commit") - cmd.MarkFlagsMutuallyExclusive("push", "no-github") - cmd.MarkFlagsMutuallyExclusive("push", "skip-initial-commit") // Provide a helpful error when --agent is used without a value defaultFlagErr := cmd.FlagErrorFunc() @@ -1011,6 +1081,51 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, return cmd } +func continueEnableAfterAgentValidation( + ctx context.Context, + cmd *cobra.Command, + opts EnableOptions, + selectedAgent agent.Agent, + repoRoot string, + needsIdentity bool, + resolveIdentity gitIdentityResolver, +) error { + if selectedAgent != nil { + if err := runEnableIdentityPreflight(ctx, cmd, repoRoot, needsIdentity, resolveIdentity); err != nil { + return err + } + ensureLogger(cmd) + return setupAgentHooksNonInteractive(cmd.Context(), cmd.OutOrStdout(), selectedAgent, opts) + } + + if settings.IsSetUpAny(ctx) { + preflight := func() error { + return runEnableIdentityPreflight(ctx, cmd, repoRoot, needsIdentity, resolveIdentity) + } + return runEnableOnConfiguredRepoWithPreflight(ctx, cmd, opts, preflight) + } + + // First-time bare enable owns agent selection here so authentication can be + // placed after selection but before runEnableInteractive mutates hooks or + // settings. + agents, err := selectAgentsForSetup(ctx, cmd.OutOrStdout(), opts) + if err != nil { + return err + } + if err := runEnableIdentityPreflight(ctx, cmd, repoRoot, needsIdentity, resolveIdentity); err != nil { + return err + } + ensureLogger(cmd) + return runEnableInteractive(cmd.Context(), cmd.OutOrStdout(), agents, opts) +} + +func runEnableIdentityPreflight(ctx context.Context, cmd *cobra.Command, repoRoot string, needed bool, resolve gitIdentityResolver) error { + if !needed { + return nil + } + return ensureGitIdentity(ctx, cmd.OutOrStdout(), execRunner{}, repoRoot, resolve) +} + // reportRepoEnabled records the `entire enable` against the backend so the web // onboarding can reflect it, and — as a second, independent, best-effort step // — probes and caches whether trails are enabled for the repo. Both steps are @@ -1183,7 +1298,19 @@ was not fully uninstalled.`, // management) behave like `configure`; a bare re-enable just flips the enabled // flag or reports current status. func runEnableOnConfiguredRepo(ctx context.Context, cmd *cobra.Command, opts EnableOptions) error { + return runEnableOnConfiguredRepoWithPreflight(ctx, cmd, opts, nil) +} + +func runEnableOnConfiguredRepoWithPreflight(ctx context.Context, cmd *cobra.Command, opts EnableOptions, preflight func() error) error { w := cmd.OutOrStdout() + runPreflight := func() error { + if preflight == nil { + return nil + } + fn := preflight + preflight = nil + return fn() + } // This path is by definition not a first run, so it never reaches the // import offer. Say so rather than dropping the flag silently. if opts.ImportHistory { @@ -1191,6 +1318,29 @@ func runEnableOnConfiguredRepo(ctx context.Context, cmd *cobra.Command, opts Ena } usedSetupFlow := enableUsesSetupFlow(cmd, "") if usedSetupFlow { + // Agent management runs before the strategy and checkpoint-backend + // writes below, which reverses the order on main. That is load-bearing, + // not incidental: the identity preflight is invoked from inside + // runManageAgentsWithPreflight, so moving the settings writes back ahead + // of it would persist them before authentication is known to succeed — + // exactly what TestEnableCmd_IdentityFailurePreservesConfiguredSettings + // asserts must not happen. Do not "restore" the original order. + if enableNeedsAgentManagement(cmd) { + var selectFn func(available []string) ([]string, error) + if opts.Yes { + selectFn = selectAllAgents + } + if err := runManageAgentsWithPreflight(ctx, w, opts, selectFn, runPreflight); err != nil { + return err + } + } + // Some noninteractive agent-management paths return without a picker or + // applying agent changes. Ensure the preflight still runs before the + // settings/strategy work below; this is a no-op when the picker path + // already invoked it. + if err := runPreflight(); err != nil { + return err + } if hasStrategyFlags(cmd) { if err := updateStrategyOptions(ctx, w, opts); err != nil { return err @@ -1201,15 +1351,8 @@ func runEnableOnConfiguredRepo(ctx context.Context, cmd *cobra.Command, opts Ena return err } } - if enableNeedsAgentManagement(cmd) { - var selectFn func(available []string) ([]string, error) - if opts.Yes { - selectFn = selectAllAgents - } - if err := runManageAgents(ctx, w, opts, selectFn); err != nil { - return err - } - } + } else if err := runPreflight(); err != nil { + return err } // `entire enable` is an explicit, user-initiated recovery point. A repo diff --git a/cmd/entire/cli/setup_bootstrap.go b/cmd/entire/cli/setup_bootstrap.go new file mode 100644 index 0000000000..7c288867bf --- /dev/null +++ b/cmd/entire/cli/setup_bootstrap.go @@ -0,0 +1,429 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "charm.land/huh/v2" + + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// BootstrapOptions holds flags that let `entire enable` run on a folder +// that isn't yet a git repository. All fields are optional; supplying one +// skips the matching interactive prompt. +// +// Bootstrap is deliberately local-only: it runs `git init` and (optionally) +// an initial commit, and never creates or pushes to a remote. Publishing a +// directory is the user's call to make with their own forge tooling (`gh +// repo create`, `entire repo create`, a web UI), not a side effect of +// enabling Entire. +type BootstrapOptions struct { + // InitRepo is true if --init-repo was passed (accept git init without prompt). + InitRepo bool + // NoInitRepo is true if --no-init-repo was passed (decline without prompt). + NoInitRepo bool + // InitialCommitMessage overrides the default commit message prompt. + InitialCommitMessage string + // SkipInitialCommit leaves the newly-created files unstaged so the + // user can commit themselves. + SkipInitialCommit bool + // Yes accepts all defaults without prompting: init repo and commit with + // the default message. + Yes bool +} + +// bootstrapRunner executes external commands. Tests override this to avoid +// shelling out to git/gh. +type bootstrapRunner interface { + // Run executes the command and returns stdout. Stderr is available on + // the returned *exec.ExitError for error reporting. + Run(ctx context.Context, name string, args ...string) (string, error) + // RunInDir is Run with an explicit working directory. + RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, name, args...).Output() + return string(out), err +} + +func (execRunner) RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.Output() + return string(out), err +} + +// printBootstrapSection writes a small section header so the bootstrap +// output has visual grouping between phases (git init → agent setup → +// initial commit). Kept simple text so it renders correctly in accessible +// mode and non-TTY captures. +func printBootstrapSection(w io.Writer, title string) { + fmt.Fprintf(w, "\n%s\n", title) +} + +// errBootstrapDeclined signals that the user chose not to initialize a +// repo. Returned _before_ `git init` runs; callers fall back to the +// legacy "Not a git repository" error. +var errBootstrapDeclined = errors.New("bootstrap declined") + +// errBootstrapInterrupted signals that the user aborted a prompt _after_ +// `git init` has already run. The local repo is in place but setup +// didn't complete; callers should surface that clearly instead of +// pretending no init happened. +var errBootstrapInterrupted = errors.New("bootstrap interrupted after init") + +const defaultInitialCommitMessage = "Initial commit" + +type bootstrapSetupChoice string + +const ( + bootstrapSetupLocal bootstrapSetupChoice = "local" + bootstrapSetupCustom bootstrapSetupChoice = "custom" + bootstrapSetupDecline bootstrapSetupChoice = "decline" +) + +// bootstrapState carries pre-setup decisions into the post-setup finalize +// step. The caller runs `runBootstrapInit` before agent setup to do +// `git init` + identity + the initial-commit decision, then runs +// `runBootstrapFinalize` afterwards so the initial commit captures +// the `.entire/`, `.claude/`, etc. files written during setup. +type bootstrapState struct { + runner bootstrapRunner + cwd string + commit bool // false means the user opted out of the initial commit + message string // resolved initial commit message (empty when !commit) +} + +// runBootstrapInit handles the pre-setup half of "enable on a non-git +// folder": confirm + `git init`, ensure a git identity, and resolve the +// initial-commit decision up front so all prompts happen before agent setup +// runs. No remote is created or contacted. +// +// Returns errBootstrapDeclined if the user declined the init prompt. +func runBootstrapInit(ctx context.Context, w io.Writer, opts BootstrapOptions) (*bootstrapState, error) { + return runBootstrapInitWith(ctx, w, opts, execRunner{}) +} + +// runBootstrapInitWith is the testable variant that accepts a runner. +func runBootstrapInitWith(ctx context.Context, w io.Writer, opts BootstrapOptions, runner bootstrapRunner) (*bootstrapState, error) { + // paths.RepoRoot is unavailable here — we're bootstrapping _before_ a + // repo exists. Plain cwd is the correct target for `git init`. + cwd, err := os.Getwd() //nolint:forbidigo // no repo yet; git init runs in cwd + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + + // Step 1: decide whether to init here — and, on the bare interactive + // path, how: one select carries both the init consent and the setup + // preset, so the common flow costs a single answer. Explicit flags, + // --yes, and non-interactive runs keep the granular confirm + resolver + // contracts unchanged. + setupChoice := bootstrapSetupCustom + if shouldPromptBootstrapSetupChoice(opts) { + setupChoice, err = promptBootstrapSetupChoice(w, cwd) + if err != nil { + return nil, err + } + if setupChoice == bootstrapSetupDecline { + return nil, errBootstrapDeclined + } + } else { + proceed, confirmErr := confirmInitRepo(cwd, opts) + if confirmErr != nil { + return nil, confirmErr + } + if !proceed { + return nil, errBootstrapDeclined + } + } + + // Step 2: git init. + printBootstrapSection(w, "Setting up git repository") + if err := gitInit(ctx, runner, cwd); err != nil { + return nil, fmt.Errorf("git init: %w", err) + } + // Clear cached worktree root so subsequent paths.WorktreeRoot calls pick + // up the freshly created repo. + paths.ClearWorktreeRootCache() + fmt.Fprintln(w, " ✓ Initialized empty git repository") + + // Step 3: resolve commit message (+ skip decision) and ensure git + // identity. Must run after `git init` so `git config` reads are + // scoped correctly. The identity check is skipped when the user opts + // out of the commit, since nothing will be authored. + message, commit := defaultInitialCommitMessage, true + if setupChoice == bootstrapSetupCustom { + message, commit, err = resolveCommitMessage(opts) + if err != nil { + return nil, err + } + } + // Identity is deliberately NOT resolved here. It is deferred to the enable + // command so agent selection completes before any profile lookup or login + // begins; see runEnableIdentityPreflight. + + return &bootstrapState{ + runner: runner, + cwd: cwd, + commit: commit, + message: message, + }, nil +} + +// runBootstrapFinalize runs the post-setup half: stage + initial commit, +// now including the `.entire/`, agent hook, and settings files written by +// the enable flow. If the user opted out of the initial commit we print +// next-step instructions instead. +func runBootstrapFinalize(ctx context.Context, w io.Writer, s *bootstrapState) error { + if s == nil { + return nil + } + + if s.commit { + printBootstrapSection(w, "Finalizing") + committed, err := doInitialCommit(ctx, s.runner, s.cwd, s.message) + if err != nil { + return fmt.Errorf("initial commit: %w", err) + } + if committed { + fmt.Fprintln(w, " ✓ Created initial commit") + } else { + fmt.Fprintln(w, " ✓ Nothing to commit — the folder has no files yet") + } + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, " Skipped initial commit. When you're ready:") + fmt.Fprintln(w, " git add -A && git commit -m \"Initial commit\"") + } + + fmt.Fprintln(w, "\nDone.") + return nil +} + +// confirmInitRepo returns true if we should proceed with `git init`. It +// respects --init-repo / --no-init-repo; otherwise prompts. In +// non-interactive mode we return false without printing anything so +// the caller (setup.go) owns the "Not a git repository" message and +// doesn't end up with duplicate output on stdout + stderr. +func confirmInitRepo(cwd string, opts BootstrapOptions) (bool, error) { + if opts.NoInitRepo { + return false, nil + } + if opts.InitRepo || opts.Yes { + return true, nil + } + if !interactive.CanPromptInteractively() { + return false, nil + } + + // Default to No: `entire enable` is often run reflexively inside an + // existing project, so a stray run in the wrong (non-repo) directory + // must not initialize a repo just because the user pressed Enter. The + // absolute path is in the title so a wrong-directory mistake is obvious + // in both interactive and accessible modes. + confirmed := false + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Warning: Not a git repository. Initialize a new one in %q?", cwd)). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return false, nil + } + return false, fmt.Errorf("init-repo prompt: %w", err) + } + return confirmed, nil +} + +// shouldPromptBootstrapSetupChoice reports whether this is the bare +// interactive bootstrap path. Any option that expresses a granular choice — +// including --init-repo / --no-init-repo, which answer the init consent the +// merged select carries — keeps the established flag behavior instead of +// being overwritten by a preset. +func shouldPromptBootstrapSetupChoice(opts BootstrapOptions) bool { + return interactive.CanPromptInteractively() && + !opts.Yes && + !opts.InitRepo && + !opts.NoInitRepo && + opts.InitialCommitMessage == "" && + !opts.SkipInitialCommit +} + +// promptBootstrapSetupChoice merges the init consent and the common +// bootstrap decisions into one select, so the bare interactive path costs a +// single answer. It runs _before_ `git init`: declining — including Ctrl-C — +// leaves the folder untouched. The selected commit is still deferred until +// Entire has written its settings and agent configuration. +// +// The wrong-directory guard from the granular confirm (issue #1717) carries +// over: the absolute path stays in the title (the accessible renderer drops +// descriptions), and the menu makes the choice visible before Enter lands on +// the recommended preset. +func promptBootstrapSetupChoice(w io.Writer, cwd string) (bootstrapSetupChoice, error) { + choice := bootstrapSetupLocal + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[bootstrapSetupChoice](). + Title(fmt.Sprintf("No git repository in %q. Set one up?", cwd)). + Options( + huh.NewOption("Yes, with one initial commit (recommended)", bootstrapSetupLocal), + huh.NewOption("Yes, customize...", bootstrapSetupCustom), + huh.NewOption("No", bootstrapSetupDecline), + ). + Value(&choice), + ), + ).WithOutput(w) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return bootstrapSetupDecline, nil + } + return "", fmt.Errorf("git setup prompt: %w", err) + } + return choice, nil +} + +// resolveCommitMessage returns the message to use for the initial +// commit. The second return value is false when the user chose to skip +// the initial commit entirely; callers must skip `doInitialCommit`. +func resolveCommitMessage(opts BootstrapOptions) (string, bool, error) { + if opts.SkipInitialCommit { + return "", false, nil + } + if opts.InitialCommitMessage != "" { + return opts.InitialCommitMessage, true, nil + } + if opts.Yes || !interactive.CanPromptInteractively() { + return defaultInitialCommitMessage, true, nil + } + + const ( + choiceDefault = "default" + choiceCustomize = "custom" + choiceSkip = "skip" + ) + choice := choiceDefault + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Initial commit"). + Options( + huh.NewOption(`Commit with default message "Initial commit"`, choiceDefault), + huh.NewOption("Customize message...", choiceCustomize), + huh.NewOption("Skip — I'll commit manually later", choiceSkip), + ). + Value(&choice), + ), + ) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", false, errBootstrapInterrupted + } + return "", false, fmt.Errorf("commit message prompt: %w", err) + } + + switch choice { + case choiceSkip: + return "", false, nil + case choiceCustomize: + input := defaultInitialCommitMessage + custom := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Initial commit message"). + Value(&input), + ), + ) + if err := custom.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", false, errBootstrapInterrupted + } + return "", false, fmt.Errorf("commit message prompt: %w", err) + } + if strings.TrimSpace(input) == "" { + return defaultInitialCommitMessage, true, nil + } + return input, true, nil + default: + return defaultInitialCommitMessage, true, nil + } +} + +// gitInit runs `git init` in the given directory. +func gitInit(ctx context.Context, runner bootstrapRunner, dir string) error { + if _, err := runner.RunInDir(ctx, dir, "git", "init"); err != nil { + return fmt.Errorf("run git init: %w", err) + } + return nil +} + +// doInitialCommit stages all files and creates a commit. Returns whether a +// commit was actually created (false if there were no files to stage). +func doInitialCommit(ctx context.Context, runner bootstrapRunner, dir, message string) (bool, error) { + if _, err := runner.RunInDir(ctx, dir, "git", "add", "-A"); err != nil { + return false, wrapExecError("git add", err) + } + // Check if the staging area has anything at all. + // --no-optional-locks keeps this a read: a bare `git status` rewrites + // .git/index to refresh its stat cache (issue #2111). + out, err := runner.RunInDir(ctx, dir, "git", "--no-optional-locks", "status", "--porcelain") + if err != nil { + return false, wrapExecError("git status", err) + } + if strings.TrimSpace(out) == "" { + return false, nil + } + // Disable GPG signing for this commit only. Fresh environments often + // have commit.gpgsign=true inherited from a global config but no + // working signer; passing -c keeps the user's global config intact. + if _, err := runner.RunInDir(ctx, dir, "git", "-c", "commit.gpgsign=false", "commit", "-m", message); err != nil { + return false, wrapExecError("git commit", err) + } + return true, nil +} + +// wrapExecError formats err with stderr from *exec.ExitError when available, +// so callers see git's actual complaint instead of an opaque "exit status N". +func wrapExecError(prefix string, err error) error { + var ee *exec.ExitError + if errors.As(err, &ee) { + if stderr := strings.TrimSpace(string(ee.Stderr)); stderr != "" { + return fmt.Errorf("%s: %w: %s", prefix, err, stderr) + } + } + return fmt.Errorf("%s: %w", prefix, err) +} + +// ghCurrentUser returns the authenticated GitHub user's login. Read-only: +// `entire trail list --author me` resolves itself through it. +func ghCurrentUser(ctx context.Context, runner bootstrapRunner) (string, error) { + out, err := runner.Run(ctx, "gh", "api", "user", "--jq", ".login") + if err != nil { + return "", fmt.Errorf("gh api user: %w", err) + } + return strings.TrimSpace(out), nil +} + +// ghAvailable reports whether the gh CLI is installed. +func ghAvailable(ctx context.Context, runner bootstrapRunner) bool { + _, err := runner.Run(ctx, "gh", "--version") + return err == nil +} + +// ghAuthenticated reports whether `gh auth status` succeeds. +func ghAuthenticated(ctx context.Context, runner bootstrapRunner) bool { + _, err := runner.Run(ctx, "gh", "auth", "status") + return err == nil +} diff --git a/cmd/entire/cli/setup_bootstrap_test.go b/cmd/entire/cli/setup_bootstrap_test.go new file mode 100644 index 0000000000..1660623946 --- /dev/null +++ b/cmd/entire/cli/setup_bootstrap_test.go @@ -0,0 +1,684 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +const ( + testUser = "octocat" + cmdGit = "git" + gitCmdCommit = "commit" + gitCmdConfig = "config" +) + +// runBootstrapWith runs the full bootstrap (init + finalize) in one +// call, used by tests that don't need to assert phasing. The real caller +// runs the two phases around agent setup. +func runBootstrapWith(ctx context.Context, w io.Writer, opts BootstrapOptions, runner bootstrapRunner) error { + state, err := runBootstrapInitWith(ctx, w, opts, runner) + if err != nil { + return err + } + return runBootstrapFinalize(ctx, w, state) +} + +// fakeRunner is a test seam for bootstrapRunner. Each (name, args[0]) pair +// maps to a response. +type fakeRunner struct { + mu sync.Mutex + responses map[string]fakeResponse + calls []fakeCall +} + +type fakeResponse struct { + stdout string + err error +} + +type fakeCall struct { + dir string + name string + args []string +} + +func newFakeRunner() *fakeRunner { + return &fakeRunner{ + responses: make(map[string]fakeResponse), + } +} + +func (f *fakeRunner) key(name string, args []string) string { + return name + " " + strings.Join(args, " ") +} + +func (f *fakeRunner) set(name string, args []string, stdout string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.responses[f.key(name, args)] = fakeResponse{stdout: stdout, err: err} +} + +func (f *fakeRunner) lookup(name string, args []string) (fakeResponse, bool) { + f.mu.Lock() + defer f.mu.Unlock() + r, ok := f.responses[f.key(name, args)] + return r, ok +} + +func (f *fakeRunner) record(dir, name string, args []string) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, fakeCall{dir: dir, name: name, args: args}) +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { + f.record("", name, args) + if r, ok := f.lookup(name, args); ok { + return r.stdout, r.err + } + return "", fmt.Errorf("fakeRunner: unexpected call %s %v", name, args) +} + +func (f *fakeRunner) RunInDir(_ context.Context, dir, name string, args ...string) (string, error) { + f.record(dir, name, args) + if r, ok := f.lookup(name, args); ok { + return r.stdout, r.err + } + return "", fmt.Errorf("fakeRunner: unexpected call in %s: %s %v", dir, name, args) +} + +// setIdentityConfigured simulates `git config --get user.name/email` returning +// non-empty values, so ensureGitIdentity treats identity as already set. +func (f *fakeRunner) setIdentityConfigured() { + f.set("git", []string{"config", "--get", "user.name"}, "Test User\n", nil) + f.set("git", []string{"config", "--get", "user.email"}, "test@example.com\n", nil) +} + +// hasCall returns whether any recorded call matches the predicate. +func (f *fakeRunner) hasCall(match func(fakeCall) bool) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if match(c) { + return true + } + } + return false +} + +func TestGhHelpers(t *testing.T) { + t.Parallel() + ctx := context.Background() + r := newFakeRunner() + + r.set("gh", []string{"--version"}, "gh version 2.81.0\n", nil) + r.set("gh", []string{"auth", "status"}, "Logged in", nil) + r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) + + if !ghAvailable(ctx, r) { + t.Fatal("ghAvailable should be true") + } + if !ghAuthenticated(ctx, r) { + t.Fatal("ghAuthenticated should be true") + } + user, err := ghCurrentUser(ctx, r) + if err != nil || user != testUser { + t.Fatalf("ghCurrentUser = %q, %v; want octocat", user, err) + } +} + +func TestGhAvailable_Missing(t *testing.T) { + t.Parallel() + r := newFakeRunner() + r.set("gh", []string{"--version"}, "", errors.New("not found")) + if ghAvailable(context.Background(), r) { + t.Fatal("expected ghAvailable to return false when gh is missing") + } +} + +func TestDoInitialCommit_EmptyFolder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + r := newFakeRunner() + r.set("git", []string{"add", "-A"}, "", nil) + r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, "", nil) + + committed, err := doInitialCommit(context.Background(), r, dir, "msg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if committed { + t.Fatal("expected committed=false for empty folder") + } +} + +func TestDoInitialCommit_WithFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + r := newFakeRunner() + r.set("git", []string{"add", "-A"}, "", nil) + r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M README.md\n", nil) + r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "msg"}, "", nil) + + committed, err := doInitialCommit(context.Background(), r, dir, "msg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !committed { + t.Fatal("expected committed=true") + } + // Verify gpgsign=false was passed to the commit. + if !r.hasCall(func(c fakeCall) bool { + return c.name == cmdGit && len(c.args) >= 3 && c.args[0] == "-c" && c.args[1] == "commit.gpgsign=false" && c.args[2] == gitCmdCommit + }) { + t.Fatal("expected commit to pass -c commit.gpgsign=false") + } +} + +func TestRunBootstrap_DeclinedInNonInteractive(t *testing.T) { + dir := t.TempDir() + restoreCwd(t, dir) + + err := runBootstrapWith(context.Background(), io.Discard, BootstrapOptions{}, newFakeRunner()) + if !errors.Is(err, errBootstrapDeclined) { + t.Fatalf("expected errBootstrapDeclined, got %v", err) + } +} + +func TestRunBootstrap_LocalFlow(t *testing.T) { + dir := t.TempDir() + restoreCwd(t, dir) + + r := newFakeRunner() + r.setIdentityConfigured() + r.set("git", []string{"init"}, "", nil) + r.set("git", []string{"add", "-A"}, "", nil) + r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M file\n", nil) + r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "First!"}, "", nil) + + opts := BootstrapOptions{ + InitRepo: true, + InitialCommitMessage: "First!", + } + err := runBootstrapWith(context.Background(), io.Discard, opts, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify git init ran in the cwd. + if !r.hasCall(func(c fakeCall) bool { + return c.name == cmdGit && len(c.args) == 1 && c.args[0] == "init" + }) { + t.Fatal("expected git init call") + } + // Bootstrap is local-only: it must never shell out to gh. + if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) { + t.Fatal("bootstrap must not invoke gh") + } +} + +func TestResolveCommitMessage_SkipFlag(t *testing.T) { + t.Parallel() + msg, commit, err := resolveCommitMessage(BootstrapOptions{SkipInitialCommit: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if commit { + t.Fatal("commit should be false when SkipInitialCommit is set") + } + if msg != "" { + t.Fatalf("message should be empty when skipping, got %q", msg) + } +} + +func TestResolveCommitMessage_FlagTakesMessage(t *testing.T) { + t.Parallel() + msg, commit, err := resolveCommitMessage(BootstrapOptions{InitialCommitMessage: "custom"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !commit { + t.Fatal("commit should be true with explicit message flag") + } + if msg != "custom" { + t.Fatalf("message = %q, want custom", msg) + } +} + +func TestResolveCommitMessage_NonInteractiveDefault(t *testing.T) { + msg, commit, err := resolveCommitMessage(BootstrapOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !commit { + t.Fatal("commit should default to true non-interactively") + } + if msg != defaultInitialCommitMessage { + t.Fatalf("message = %q, want Initial commit", msg) + } +} + +// TestRunBootstrap_CreatesNoRemote is the guard for the invariant that +// `entire enable` bootstrapping is local-only. Creating a repository on a +// forge and publishing a directory's contents are the user's calls to make +// with their own tooling, so bootstrap must never reach the network: no gh +// invocation, and no `git remote`/`git push`. Any future flag that adds one +// back has to break this test first. +// +// It sweeps every input shape rather than just --yes, because a remote would +// most plausibly return attached to one option rather than to all of them. +func TestRunBootstrap_CreatesNoRemote(t *testing.T) { + cases := map[string]BootstrapOptions{ + "yes": {Yes: true}, + "init-repo": {InitRepo: true}, + "custom message": {InitRepo: true, InitialCommitMessage: "custom"}, + "skipped commit": {InitRepo: true, SkipInitialCommit: true}, + } + for name, opts := range cases { + t.Run(name, func(t *testing.T) { + // No t.Parallel: restoreCwd chdirs, which is process-global. + restoreCwd(t, t.TempDir()) + + r := newFakeRunner() + r.setIdentityConfigured() + r.set("git", []string{"init"}, "", nil) + r.set("git", []string{"add", "-A"}, "", nil) + r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) + r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", defaultInitialCommitMessage}, "", nil) + r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "custom"}, "", nil) + + if err := runBootstrapWith(context.Background(), io.Discard, opts, r); err != nil { + t.Fatalf("bootstrap failed: %v", err) + } + + if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) { + t.Error("bootstrap must not invoke gh") + } + for _, sub := range []string{"remote", "push"} { + if r.hasCall(gitArgsMatch([]string{sub})) { + t.Errorf("bootstrap must not run git %s", sub) + } + } + }) + } +} + +// TestRunBootstrap_InitBeforeFinalize verifies the two-phase split: init +// runs git init up front, finalize creates the commit. A simulated "agent +// setup" step writes a file between the phases; that file must end up in the +// initial commit (i.e. `git add -A` happens after setup, not before). +func TestRunBootstrap_InitBeforeFinalize(t *testing.T) { + dir := t.TempDir() + restoreCwd(t, dir) + + r := newFakeRunner() + r.setIdentityConfigured() + r.set("git", []string{"init"}, "", nil) + r.set("git", []string{"add", "-A"}, "", nil) + r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " A .entire/settings.json\n", nil) + r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "First"}, "", nil) + + opts := BootstrapOptions{ + InitRepo: true, + InitialCommitMessage: "First", + } + + // Phase 1: init. This must NOT stage or commit. + state, err := runBootstrapInitWith(context.Background(), io.Discard, opts, r) + if err != nil { + t.Fatalf("init failed: %v", err) + } + if state == nil { + t.Fatal("expected non-nil state after init") + } + if !r.hasCall(gitArgsMatch([]string{"init"})) { + t.Fatal("expected git init during phase 1") + } + forbidden := [][]string{ + {"add", "-A"}, + {"--no-optional-locks", "status", "--porcelain"}, + {"-c", "commit.gpgsign=false", gitCmdCommit, "-m", "First"}, + } + for _, args := range forbidden { + if r.hasCall(gitArgsMatch(args)) { + t.Fatalf("git %v was called during init; should have been deferred to finalize", args) + } + } + + // Phase 2: finalize. Now the commit lands. + if err := runBootstrapFinalize(context.Background(), io.Discard, state); err != nil { + t.Fatalf("finalize failed: %v", err) + } + if !r.hasCall(gitArgsMatch([]string{"-c", "commit.gpgsign=false", gitCmdCommit, "-m", "First"})) { + t.Fatal("expected commit during finalize") + } +} + +// gitArgsMatch returns a predicate for hasCall that matches a `git` call +// whose args start with the given slice. Bootstrap shells out to nothing +// else, so the command name is not a parameter. +func gitArgsMatch(args []string) func(fakeCall) bool { + return func(c fakeCall) bool { + if c.name != cmdGit || len(c.args) < len(args) { + return false + } + for i, a := range args { + if c.args[i] != a { + return false + } + } + return true + } +} + +// TestBootstrap_FreshMachine_RealGit is an integration-style test that runs +// real git via execRunner on a temp dir isolated from the user's global git +// config. Regression guard for the issue where bootstrap commits failed +// without a configured identity or because of commit.gpgsign=true. +func TestBootstrap_FreshMachine_RealGit(t *testing.T) { + // Isolate from any global git config: point HOME + GIT_CONFIG_* at + // empty/missing locations, and force a broken GPG signing config that + // would fail any commit if we did not pass -c commit.gpgsign=false. + emptyHome := t.TempDir() + t.Setenv("HOME", emptyHome) + t.Setenv("XDG_CONFIG_HOME", "") + // A global config that demands signing with a non-existent program. If + // our bootstrap did not override gpgsign for its commit, git would + // error out here. + globalCfg := filepath.Join(emptyHome, ".gitconfig") + globalContent := "[user]\n\tname = Fresh User\n\temail = fresh@example.com\n[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = /does/not/exist\n" + if err := writeTempFile(globalCfg, globalContent); err != nil { + t.Fatalf("write global gitconfig: %v", err) + } + t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) + // Ensure no system config interferes. + t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") + + projectDir := t.TempDir() + restoreCwd(t, projectDir) + // Create a file to commit. + if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hello\n"); err != nil { + t.Fatalf("write file: %v", err) + } + + opts := BootstrapOptions{ + InitRepo: true, + InitialCommitMessage: "Initial", + } + err := runBootstrapWith(context.Background(), io.Discard, opts, execRunner{}) + if err != nil { + t.Fatalf("bootstrap failed: %v", err) + } + + // Verify a commit actually landed on HEAD. + out, err := execRunner{}.RunInDir(context.Background(), projectDir, "git", "log", "--oneline") + if err != nil { + t.Fatalf("git log failed: %v", err) + } + if !strings.Contains(out, "Initial") { + t.Fatalf("expected 'Initial' commit in log, got: %q", out) + } +} + +func writeTempFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} + +// Bootstrap no longer resolves the git identity: that moved to the enable +// command, which runs the preflight after agent selection and before the +// initial commit (needsIdentity covers the bootstrap-with-commit case). This +// asserts the deferral — init succeeds with no identity configured and leaves +// the commit decision for later — replacing the test that asserted bootstrap +// itself failed here. +func TestBootstrapInit_DefersGitIdentity(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + projectDir := t.TempDir() + restoreCwd(t, projectDir) + if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hi\n"); err != nil { + t.Fatalf("write file: %v", err) + } + + state, err := runBootstrapInitWith(context.Background(), io.Discard, BootstrapOptions{ + InitRepo: true, + InitialCommitMessage: "x", + }, execRunner{}) + if err != nil { + t.Fatalf("bootstrap init: %v", err) + } + if state == nil || !state.commit { + t.Fatalf("bootstrap state = %+v, want deferred initial commit", state) + } +} + +// TestErrSentinels_DistinctPrePostInit documents the contract that the two +// error sentinels signal: errBootstrapDeclined before `git init`, +// errBootstrapInterrupted after. setup.go relies on this to show the +// right user-facing message. +func TestErrSentinels_DistinctPrePostInit(t *testing.T) { + t.Parallel() + if errors.Is(errBootstrapDeclined, errBootstrapInterrupted) { + t.Fatal("errBootstrapDeclined and errBootstrapInterrupted must not match as the same sentinel") + } +} + +func TestEnableCmd_InitCommitMessageFlagsMutuallyExclusive(t *testing.T) { + setupTestRepo(t) + + cmd := newEnableCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetArgs([]string{"--initial-commit-message", "foo", "--skip-initial-commit"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when both --initial-commit-message and --skip-initial-commit are set") + } + if !strings.Contains(err.Error(), "initial-commit-message") || !strings.Contains(err.Error(), "skip-initial-commit") { + t.Fatalf("expected error to mention both flags, got: %v", err) + } +} + +func TestEnableCmd_InitRepoFlagsMutuallyExclusive(t *testing.T) { + setupTestRepo(t) + + cmd := newEnableCmd() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetArgs([]string{"--init-repo", "--no-init-repo"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when both --init-repo and --no-init-repo are set") + } + if !strings.Contains(err.Error(), "init-repo") || !strings.Contains(err.Error(), "no-init-repo") { + t.Fatalf("expected error to mention both flags, got: %v", err) + } +} + +// withInteractivePromptStdin forces interactive, accessible (text-based) +// prompt mode and feeds input to os.Stdin for the duration of the test, so a +// huh prompt reads a scripted answer instead of opening /dev/tty or blocking +// on a real terminal. ENTIRE_TEST_TTY makes CanPromptInteractively report +// true; ACCESSIBLE makes the form read os.Stdin rather than dial the terminal. +func withInteractivePromptStdin(t *testing.T, input string) { + t.Helper() + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv("ACCESSIBLE", "1") + pr, pw, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { pr.Close() }) + go func() { + pw.WriteString(input) //nolint:errcheck // test helper + pw.Close() + }() + old := os.Stdin + os.Stdin = pr + t.Cleanup(func() { os.Stdin = old }) +} + +// TestConfirmInitRepo_DefaultsToNo verifies that pressing Enter (empty +// input) at the init-repo prompt declines. `entire enable` is often run +// reflexively, so a stray run in a non-repo directory must not initialize +// a repo on the user's behalf. Regression guard for issue #1717. +func TestConfirmInitRepo_DefaultsToNo(t *testing.T) { + withInteractivePromptStdin(t, "\n") + + proceed, err := confirmInitRepo(t.TempDir(), BootstrapOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if proceed { + t.Fatal("confirmInitRepo should default to No (decline) on empty input") + } +} + +// TestConfirmInitRepo_ExplicitYesProceeds verifies an explicit "y" still +// opts in, so the safer default doesn't block intentional use. +func TestConfirmInitRepo_ExplicitYesProceeds(t *testing.T) { + withInteractivePromptStdin(t, "y\n") + + proceed, err := confirmInitRepo(t.TempDir(), BootstrapOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !proceed { + t.Fatal("confirmInitRepo should proceed when the user explicitly answers yes") + } +} + +func TestPromptBootstrapSetupChoice_DefaultsToLocalInitialCommit(t *testing.T) { + withInteractivePromptStdin(t, "\n") + + var out bytes.Buffer + choice, err := promptBootstrapSetupChoice(&out, "/tmp/example") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if choice != bootstrapSetupLocal { + t.Fatalf("choice = %q, want %q", choice, bootstrapSetupLocal) + } + if !strings.Contains(out.String(), "Set one up?") { + t.Fatalf("expected merged init+setup prompt, got: %s", out.String()) + } + // The wrong-directory guard: the prompt must show where the repo would + // be created (issue #1717's concern, carried over from the confirm). + if !strings.Contains(out.String(), "/tmp/example") { + t.Fatalf("expected prompt to show the target directory, got: %s", out.String()) + } +} + +func TestPromptBootstrapSetupChoice_OffersCustomizeSecond(t *testing.T) { + withInteractivePromptStdin(t, "2\n") + + choice, err := promptBootstrapSetupChoice(io.Discard, "/tmp/example") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if choice != bootstrapSetupCustom { + t.Fatalf("choice = %q, want %q", choice, bootstrapSetupCustom) + } +} + +func TestPromptBootstrapSetupChoice_OffersDecline(t *testing.T) { + // Options are local(1) / customize(2) / No(3). + withInteractivePromptStdin(t, "3\n") + + choice, err := promptBootstrapSetupChoice(io.Discard, "/tmp/example") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if choice != bootstrapSetupDecline { + t.Fatalf("choice = %q, want %q", choice, bootstrapSetupDecline) + } +} + +func TestRunBootstrapInit_InteractiveLocalPresetUsesOneSetupAnswer(t *testing.T) { + dir := t.TempDir() + restoreCwd(t, dir) + withInteractivePromptStdin(t, "\n") + + r := newFakeRunner() + r.setIdentityConfigured() + r.set("git", []string{"init"}, "", nil) + + var out bytes.Buffer + state, err := runBootstrapInitWith(context.Background(), &out, BootstrapOptions{}, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !state.commit || state.message != defaultInitialCommitMessage { + t.Fatalf("local preset commit = %v, message = %q", state.commit, state.message) + } + if !strings.Contains(out.String(), "Set one up?") { + t.Fatalf("expected merged init+setup prompt, got: %s", out.String()) + } +} + +// TestRunBootstrapInit_InteractiveDeclineRunsNoGit verifies that +// declining the merged prompt leaves the folder untouched: the select runs +// before `git init`, so "No" must not create a repository. +func TestRunBootstrapInit_InteractiveDeclineRunsNoGit(t *testing.T) { + dir := t.TempDir() + restoreCwd(t, dir) + // The option list is local(1) / customize(2) / No(3). + withInteractivePromptStdin(t, "3\n") + + r := newFakeRunner() + _, err := runBootstrapInitWith(context.Background(), io.Discard, BootstrapOptions{}, r) + if !errors.Is(err, errBootstrapDeclined) { + t.Fatalf("err = %v, want errBootstrapDeclined", err) + } + if r.hasCall(gitArgsMatch([]string{"init"})) { + t.Fatal("declining the merged prompt must not run git init") + } +} + +// restoreCwd chdirs into dir for the duration of the test. +func restoreCwd(t *testing.T, dir string) { + t.Helper() + // macOS resolves /tmp → /private/tmp; canonicalize for safety. + canon, err := filepath.EvalSymlinks(dir) + if err != nil { + canon = dir + } + t.Chdir(canon) +} + +func TestRunBootstrap_YesAcceptsAllDefaults(t *testing.T) { + // --yes should init the repo and commit with the default message, + // without any interactive prompts. That it creates no remote is + // TestRunBootstrap_CreatesNoRemote's job, along with every other input. + dir := t.TempDir() + restoreCwd(t, dir) + + r := newFakeRunner() + r.setIdentityConfigured() + r.set("git", []string{"init"}, "", nil) + r.set("git", []string{"add", "-A"}, "", nil) + r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) + r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", defaultInitialCommitMessage}, "", nil) + + var stdout bytes.Buffer + err := runBootstrapWith(context.Background(), &stdout, BootstrapOptions{Yes: true}, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !r.hasCall(gitArgsMatch([]string{"init"})) { + t.Error("expected git init") + } + if !r.hasCall(gitArgsMatch([]string{"-c", "commit.gpgsign=false", gitCmdCommit, "-m", defaultInitialCommitMessage})) { + t.Error("expected commit with default 'Initial commit' message") + } +} diff --git a/cmd/entire/cli/setup_github.go b/cmd/entire/cli/setup_github.go deleted file mode 100644 index c6dddb8f5e..0000000000 --- a/cmd/entire/cli/setup_github.go +++ /dev/null @@ -1,1118 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strings" - - "charm.land/huh/v2" - - "github.com/entireio/cli/cmd/entire/cli/interactive" - "github.com/entireio/cli/cmd/entire/cli/paths" -) - -// GitHubBootstrapOptions holds flags that let `entire enable` run on a folder -// that isn't yet a git repository. All fields are optional; supplying one -// skips the matching interactive prompt. -type GitHubBootstrapOptions struct { - // InitRepo is true if --init-repo was passed (accept git init without prompt). - InitRepo bool - // NoInitRepo is true if --no-init-repo was passed (decline without prompt). - NoInitRepo bool - // RepoName is the GitHub repository name (no owner). - RepoName string - // RepoOwner is the GitHub user or org login. - RepoOwner string - // RepoVisibility is one of "public", "private", "internal". - RepoVisibility string - // NoGitHub skips the GitHub repo creation step. - NoGitHub bool - // InitialCommitMessage overrides the default commit message prompt. - InitialCommitMessage string - // SkipInitialCommit leaves the newly-created files unstaged so the - // user can commit themselves. The GitHub repo (if requested) is - // still created, but nothing is pushed. - SkipInitialCommit bool - // Yes accepts all defaults without prompting: init repo, create GitHub - // repo under the user's account (private), default commit message, and - // push. Explicit flags (--no-github, --repo-owner, etc.) take precedence. - Yes bool - // Push opts into pushing the initial commit to the created GitHub remote - // without prompting. Pushing is otherwise an explicit, separate opt-in - // (interactive "yes" or --yes). Implies creating the remote. - Push bool -} - -// bootstrapRunner executes external commands. Tests override this to avoid -// shelling out to git/gh. -type bootstrapRunner interface { - // Run executes the command and returns stdout. Stderr is available on - // the returned *exec.ExitError for error reporting. - Run(ctx context.Context, name string, args ...string) (string, error) - // RunInDir is Run with an explicit working directory. - RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) -} - -type execRunner struct{} - -func (execRunner) Run(ctx context.Context, name string, args ...string) (string, error) { - out, err := exec.CommandContext(ctx, name, args...).Output() - return string(out), err -} - -func (execRunner) RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir = dir - out, err := cmd.Output() - return string(out), err -} - -// printBootstrapSection writes a small section header so the bootstrap -// output has visual grouping between phases (git init → agent setup → -// commit & push). Kept simple text so it renders correctly in accessible -// mode and non-TTY captures. -func printBootstrapSection(w io.Writer, title string) { - fmt.Fprintf(w, "\n%s\n", title) -} - -// errBootstrapDeclined signals that the user chose not to initialize a -// repo. Returned _before_ `git init` runs; callers fall back to the -// legacy "Not a git repository" error. -var errBootstrapDeclined = errors.New("bootstrap declined") - -// errBootstrapInterrupted signals that the user aborted a prompt _after_ -// `git init` has already run. The local repo is in place but setup -// didn't complete; callers should surface that clearly instead of -// pretending no init happened. -var errBootstrapInterrupted = errors.New("bootstrap interrupted after init") - -// ghRepoNameRe validates GitHub repository names. GitHub allows -// alphanumerics, hyphens, underscores, and periods — including as the -// first character (e.g. `.github`). We don't enforce a leading-char -// restriction here; `validateRepoName` handles the specific names GitHub -// reserves (`.`, `..`). Any other edge case is left to GitHub to reject -// so we don't over-restrict. -var ghRepoNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) - -// allowed visibility values. -const ( - visibilityPublic = "public" - visibilityPrivate = "private" - visibilityInternal = "internal" - defaultInitialCommitMessage = "Initial commit" -) - -type bootstrapSetupChoice string - -const ( - bootstrapSetupLocal bootstrapSetupChoice = "local" - bootstrapSetupGitHub bootstrapSetupChoice = "github" - bootstrapSetupCustom bootstrapSetupChoice = "custom" - bootstrapSetupDecline bootstrapSetupChoice = "decline" -) - -// bootstrapState carries pre-setup decisions into the post-setup finalize -// step. The caller runs `runGitHubBootstrapInit` before agent setup to do -// `git init` + identity + gather GitHub choices, then runs -// `runGitHubBootstrapFinalize` afterwards so the initial commit captures -// the `.entire/`, `.claude/`, etc. files written during setup. -type bootstrapState struct { - runner bootstrapRunner - cwd string - useGitHub bool - fullName string // owner/name, if useGitHub - visibility string // public/private/internal, if useGitHub - commit bool // false means the user opted out of the initial commit - message string // resolved initial commit message (empty when !commit) - push bool // false means create the GitHub repo but don't push to it -} - -// runGitHubBootstrapInit handles the pre-setup half of "enable on a non-git -// folder": confirm + `git init`, ensure git identity, and (if we're going -// to create a GitHub repo) gather owner/name/visibility up front so all -// prompts happen before agent setup runs. -// -// Returns errBootstrapDeclined if the user declined the init prompt. -// Returns nil, nil if the caller is already inside a git repo and no -// bootstrap is needed (defensive; the caller typically gates on this). -func runGitHubBootstrapInit(ctx context.Context, w, errW io.Writer, opts GitHubBootstrapOptions) (*bootstrapState, error) { - return runGitHubBootstrapInitWith(ctx, w, errW, opts, execRunner{}) -} - -// runGitHubBootstrapInitWith is the testable variant that accepts a runner. -func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts GitHubBootstrapOptions, runner bootstrapRunner) (*bootstrapState, error) { - // paths.RepoRoot is unavailable here — we're bootstrapping _before_ a - // repo exists. Plain cwd is the correct target for `git init`. - cwd, err := os.Getwd() //nolint:forbidigo // no repo yet; git init runs in cwd - if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) - } - - // Step 1: decide whether to init here — and, on the bare interactive - // path, how: one select carries both the init consent and the setup - // preset, so the common flow costs a single answer. Explicit flags, - // --yes, and non-interactive runs keep the granular confirm + resolver - // contracts unchanged. - setupChoice := bootstrapSetupCustom - if shouldPromptBootstrapSetupChoice(opts) { - githubReady := ghAvailable(ctx, runner) && ghAuthenticated(ctx, runner) - setupChoice, err = promptBootstrapSetupChoice(w, cwd, githubReady) - if err != nil { - return nil, err - } - if setupChoice == bootstrapSetupDecline { - return nil, errBootstrapDeclined - } - } else { - proceed, confirmErr := confirmInitRepo(w, cwd, opts) - if confirmErr != nil { - return nil, confirmErr - } - if !proceed { - return nil, errBootstrapDeclined - } - } - - // Step 2: git init. - printBootstrapSection(w, "Setting up git repository") - if err := gitInit(ctx, runner, cwd); err != nil { - return nil, fmt.Errorf("git init: %w", err) - } - // Clear cached worktree root so subsequent paths.WorktreeRoot calls pick - // up the freshly created repo. - paths.ClearWorktreeRootCache() - fmt.Fprintln(w, " ✓ Initialized empty git repository") - - // Creating a remote remains an explicit opt-in. Choosing the GitHub preset - // is that consent: its label states that the private repository will be - // created and the initial commit pushed. The local preset never creates or - // pushes a remote. - useGitHub := setupChoice == bootstrapSetupGitHub - if setupChoice == bootstrapSetupCustom && !opts.NoGitHub { - explicit := ghCreateRequested(opts) - // Only probe gh (and warn about a missing/unauthenticated CLI) when the - // user actually wants a GitHub repo — explicitly, or via the confirm - // prompt we're about to show interactively. - if explicit || interactive.CanPromptInteractively() { - switch { - case !ghAvailable(ctx, runner): - fmt.Fprintln(errW, "gh CLI not found. Install it from https://cli.github.com/ and run `gh auth login` to add a GitHub remote.") - fmt.Fprintln(errW, "Continuing with local initialization only.") - case !ghAuthenticated(ctx, runner): - fmt.Fprintln(errW, "gh CLI is not authenticated. Run `gh auth login` to add a GitHub remote.") - fmt.Fprintln(errW, "Continuing with local initialization only.") - case explicit: - useGitHub = true - default: - confirmed, err := confirmCreateGitHubRepo(cwd) - if err != nil { - return nil, err - } - useGitHub = confirmed - } - } - } - - // Step 4: collect GitHub repo details up front so all prompts are - // contiguous. - var fullName, visibility string - if useGitHub { - repoOpts := opts - if setupChoice == bootstrapSetupGitHub { - // The GitHub preset resolves the current user, folder-derived name, - // and private visibility without reopening the granular prompts. - repoOpts.Yes = true - } - owner, name, vis, err := selectGitHubRepo(ctx, w, errW, runner, cwd, repoOpts) - if err != nil { - return nil, err - } - fullName = owner + "/" + name - visibility = vis - } - - // Step 5: resolve commit message (+ skip decision) and ensure git - // identity. Must run after `git init` so `git config` reads are - // scoped correctly. If the user chose to skip the commit we still - // need an identity *if* we're going to create the GitHub repo, - // because gh may read local config; but we can skip the identity - // check when the user is fully opting out of both commit and - // remote to keep the flow minimal. - message, commit := defaultInitialCommitMessage, true - if setupChoice == bootstrapSetupCustom { - message, commit, err = resolveCommitMessage(opts) - if err != nil { - return nil, err - } - } - if commit { - if err := ensureGitIdentity(ctx, w, errW, runner, cwd); err != nil { - return nil, err - } - } - - // Step 6: pushing is also an explicit opt-in, separate from creating the - // repo. Publishing the directory's contents is a distinct outward-facing - // action, so it happens only on an explicit signal (--push or --yes) or an - // interactive "yes". Otherwise the repo is created but left unpushed. Only - // relevant when we'll create a GitHub repo and have a commit to push. - push := false - if useGitHub && commit { - switch { - case setupChoice == bootstrapSetupGitHub: - // The preset label explicitly includes pushing the initial commit. - push = true - case opts.Yes || opts.Push: - push = true - case interactive.CanPromptInteractively(): - confirmed, err := confirmPushToRemote(fullName) - if err != nil { - return nil, err - } - push = confirmed - } - } - - return &bootstrapState{ - runner: runner, - cwd: cwd, - useGitHub: useGitHub, - fullName: fullName, - visibility: visibility, - commit: commit, - message: message, - push: push, - }, nil -} - -// runGitHubBootstrapFinalize runs the post-setup half: stage + initial -// commit (now including any `.entire/`, agent hook, and settings files -// written by the enable flow), then create the GitHub repo and push. -// If the user opted out of the initial commit we still create the -// GitHub repo (if they opted in) but skip the push — there's nothing to -// push — and print next-step instructions. -func runGitHubBootstrapFinalize(ctx context.Context, w io.Writer, s *bootstrapState) error { - if s == nil { - return nil - } - - // Pick a single section title for this phase based on what we'll do. - if s.useGitHub || s.commit { - switch { - case s.useGitHub && s.commit && s.push: - printBootstrapSection(w, "Publishing to GitHub") - case s.useGitHub: - printBootstrapSection(w, "Creating GitHub repository") - default: - printBootstrapSection(w, "Finalizing") - } - } - - var committed bool - if s.commit { - c, err := doInitialCommit(ctx, s.runner, s.cwd, s.message) - if err != nil { - return fmt.Errorf("initial commit: %w", err) - } - committed = c - if committed { - fmt.Fprintln(w, " ✓ Created initial commit") - } else { - fmt.Fprintln(w, " ✓ Nothing to commit — the folder has no files yet") - } - } - // Push only when there's a commit AND the user opted into pushing. - pushed := committed && s.push - if s.useGitHub { - if err := ghRepoCreate(ctx, s.runner, s.cwd, s.fullName, s.visibility, pushed); err != nil { - return fmt.Errorf("gh repo create: %w", err) - } - fmt.Fprintf(w, " ✓ Created %s (%s)\n", s.fullName, s.visibility) - fmt.Fprintf(w, " https://github.com/%s\n", s.fullName) - if pushed { - fmt.Fprintln(w, " ✓ Pushed initial commit to origin") - } else if committed { - // Repo created and origin configured, but the user declined the - // push. Tell them how to publish when ready. - fmt.Fprintln(w) - fmt.Fprintln(w, " Skipped push — nothing was published. When you're ready:") - fmt.Fprintln(w, " git push -u origin HEAD") - } - } - if !s.commit { - fmt.Fprintln(w) - fmt.Fprintln(w, " Skipped initial commit. When you're ready:") - fmt.Fprintln(w, " git add -A && git commit -m \"Initial commit\"") - if s.useGitHub { - fmt.Fprintln(w, " git push -u origin HEAD") - } - } - - fmt.Fprintln(w, "\nDone.") - return nil -} - -// ghFlagsProvided reports whether the caller has already expressed intent -// to create a GitHub repo via any of the gh-specific flags. Used to skip -// the "create on GitHub?" confirm prompt in that case. -func ghFlagsProvided(opts GitHubBootstrapOptions) bool { - return opts.RepoName != "" || opts.RepoOwner != "" || opts.RepoVisibility != "" -} - -// ghCreateRequested reports whether the caller has explicitly opted into -// creating a GitHub repo without an interactive prompt: --yes, --push (which -// needs a remote to push to), or any repo-targeting flag. When false and the -// session is non-interactive, the bootstrap stays local-only. -func ghCreateRequested(opts GitHubBootstrapOptions) bool { - return opts.Yes || opts.Push || ghFlagsProvided(opts) -} - -// confirmCreateGitHubRepo asks the user whether they want to also create -// a matching GitHub repository. Interactive-only; callers gate on -// interactive.CanPromptInteractively. Pushing to the repo is confirmed -// separately (see confirmPushToRemote). -// -// Defaults to No: creating a remote repository on the user's behalf must -// never happen just because the user pressed Enter. The absolute path is in -// the title so it's clear which directory is the source. -func confirmCreateGitHubRepo(cwd string) (bool, error) { - confirmed := false - form := NewAccessibleForm( - huh.NewGroup( - huh.NewConfirm(). - Title(fmt.Sprintf("Create a GitHub repository for %q?", cwd)). - Value(&confirmed), - ), - ) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return false, errBootstrapInterrupted - } - return false, fmt.Errorf("github confirm prompt: %w", err) - } - return confirmed, nil -} - -// confirmPushToRemote asks the user whether to push the initial commit to -// the newly-created GitHub repository. Interactive-only; callers gate on -// interactive.CanPromptInteractively. -// -// Defaults to No: pushing publishes the directory's contents to the remote, -// a distinct outward-facing action from creating the repo, so it must never -// happen just because the user pressed Enter. Declining leaves the repo -// created with origin configured but nothing pushed. -func confirmPushToRemote(fullName string) (bool, error) { - confirmed := false - form := NewAccessibleForm( - huh.NewGroup( - huh.NewConfirm(). - Title(fmt.Sprintf("Push the initial commit to %q?", fullName)). - Value(&confirmed), - ), - ) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return false, errBootstrapInterrupted - } - return false, fmt.Errorf("push confirm prompt: %w", err) - } - return confirmed, nil -} - -// confirmInitRepo returns true if we should proceed with `git init`. It -// respects --init-repo / --no-init-repo; otherwise prompts. In -// non-interactive mode we return false without printing anything so -// the caller (setup.go) owns the "Not a git repository" message and -// doesn't end up with duplicate output on stdout + stderr. -func confirmInitRepo(_ io.Writer, cwd string, opts GitHubBootstrapOptions) (bool, error) { - if opts.NoInitRepo { - return false, nil - } - if opts.InitRepo || opts.Yes { - return true, nil - } - if !interactive.CanPromptInteractively() { - return false, nil - } - - // Default to No: `entire enable` is often run reflexively inside an - // existing project, so a stray run in the wrong (non-repo) directory - // must not initialize a repo just because the user pressed Enter. The - // absolute path is in the title so a wrong-directory mistake is obvious - // in both interactive and accessible modes. - confirmed := false - form := NewAccessibleForm( - huh.NewGroup( - huh.NewConfirm(). - Title(fmt.Sprintf("Warning: Not a git repository. Initialize a new one in %q?", cwd)). - Value(&confirmed), - ), - ) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return false, nil - } - return false, fmt.Errorf("init-repo prompt: %w", err) - } - return confirmed, nil -} - -// shouldPromptBootstrapSetupChoice reports whether this is the bare -// interactive bootstrap path. Any option that expresses a granular choice — -// including --init-repo / --no-init-repo, which answer the init consent the -// merged select carries — keeps the established flag behavior instead of -// being overwritten by a preset. -func shouldPromptBootstrapSetupChoice(opts GitHubBootstrapOptions) bool { - return interactive.CanPromptInteractively() && - !opts.Yes && - !opts.InitRepo && - !opts.NoInitRepo && - !opts.NoGitHub && - !ghFlagsProvided(opts) && - !opts.Push && - opts.InitialCommitMessage == "" && - !opts.SkipInitialCommit -} - -// promptBootstrapSetupChoice merges the init consent and the common -// bootstrap decisions into one select, so the bare interactive path costs a -// single answer. It runs _before_ `git init`: declining — including Ctrl-C — -// leaves the folder untouched. The selected commit is still deferred until -// Entire has written its settings and agent configuration. -// -// The wrong-directory guard from the granular confirm (issue #1717) carries -// over: the absolute path stays in the title (the accessible renderer drops -// descriptions), and the menu makes the choice visible before Enter lands on -// the recommended preset. -func promptBootstrapSetupChoice(w io.Writer, cwd string, githubReady bool) (bootstrapSetupChoice, error) { - options := []huh.Option[bootstrapSetupChoice]{ - huh.NewOption("Yes, with one initial commit (recommended)", bootstrapSetupLocal), - } - if githubReady { - options = append(options, - huh.NewOption("Yes, plus a private GitHub repository (pushed)", bootstrapSetupGitHub), - ) - } - options = append(options, - huh.NewOption("Yes, customize...", bootstrapSetupCustom), - huh.NewOption("No", bootstrapSetupDecline), - ) - - choice := bootstrapSetupLocal - form := NewAccessibleForm( - huh.NewGroup( - huh.NewSelect[bootstrapSetupChoice](). - Title(fmt.Sprintf("No git repository in %q. Set one up?", cwd)). - Options(options...). - Value(&choice), - ), - ).WithOutput(w) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return bootstrapSetupDecline, nil - } - return "", fmt.Errorf("git setup prompt: %w", err) - } - return choice, nil -} - -// selectGitHubRepo gathers owner, repo name, and visibility, respecting -// supplied flags and falling back to interactive prompts. -func selectGitHubRepo(ctx context.Context, w, errW io.Writer, runner bootstrapRunner, cwd string, opts GitHubBootstrapOptions) (owner, name, visibility string, err error) { - currentUser, userErr := ghCurrentUser(ctx, runner) - if userErr != nil { - return "", "", "", fmt.Errorf("query current gh user: %w", userErr) - } - orgs, orgErr := ghListOrgs(ctx, runner) - if orgErr != nil { - // Missing read:org scope is non-fatal — we can still offer the user - // account. Warn and continue. - fmt.Fprintf(errW, "Warning: could not list organizations (%v). Only your user account is available.\n", orgErr) - orgs = nil - } - - owner, err = resolveOwner(w, currentUser, orgs, opts) - if err != nil { - return "", "", "", err - } - - name, err = resolveRepoName(ctx, w, errW, runner, owner, cwd, opts) - if err != nil { - return "", "", "", err - } - - visibility, err = resolveVisibility(owner, currentUser, opts) - if err != nil { - return "", "", "", err - } - - return owner, name, visibility, nil -} - -func resolveOwner(w io.Writer, currentUser string, orgs []string, opts GitHubBootstrapOptions) (string, error) { - owners := append([]string{currentUser}, orgs...) - if opts.RepoOwner != "" { - for _, candidate := range owners { - if candidate == opts.RepoOwner { - return opts.RepoOwner, nil - } - } - // Owner not in known list — allow it anyway; gh repo create will - // error out later if invalid. This supports orgs the token can't - // enumerate (e.g. missing read:org scope). - return opts.RepoOwner, nil - } - if len(owners) == 1 || opts.Yes { - fmt.Fprintf(w, " Using GitHub owner: %s\n", currentUser) - return currentUser, nil - } - if !interactive.CanPromptInteractively() { - return currentUser, nil - } - - options := make([]huh.Option[string], 0, len(owners)) - for _, o := range owners { - options = append(options, huh.NewOption(o, o)) - } - selected := currentUser - form := NewAccessibleForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Choose the GitHub owner for the new repository"). - Options(options...). - Value(&selected), - ), - ) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", errBootstrapInterrupted - } - return "", fmt.Errorf("owner prompt: %w", err) - } - return selected, nil -} - -func resolveRepoName(ctx context.Context, w, errW io.Writer, runner bootstrapRunner, owner, cwd string, opts GitHubBootstrapOptions) (string, error) { - suggested := slugifyRepoName(filepath.Base(cwd)) - - if opts.RepoName != "" { - if err := validateRepoName(opts.RepoName); err != nil { - return "", err - } - exists, checkErr := ghRepoExists(ctx, runner, owner, opts.RepoName) - if checkErr != nil { - fmt.Fprintf(errW, "Warning: could not check if %s/%s already exists (%v).\n", owner, opts.RepoName, checkErr) - } else if exists { - return "", fmt.Errorf("repository %s/%s already exists on GitHub", owner, opts.RepoName) - } - return opts.RepoName, nil - } - - if opts.Yes { - // Check availability before blindly using the suggested name. - exists, checkErr := ghRepoExists(ctx, runner, owner, suggested) - if checkErr != nil { - // Check failed — proceed with the suggested name and let gh - // error later if the name is actually taken. - fmt.Fprintf(errW, "Warning: could not check if %s/%s already exists (%v).\n", owner, suggested, checkErr) - return suggested, nil - } - if !exists { - return suggested, nil - } - // Name taken. If a TTY is available, fall back to the interactive - // prompt so the user can pick a different name instead of failing. - if interactive.CanPromptInteractively() { - fmt.Fprintf(w, " %s/%s already exists on GitHub.\n", owner, suggested) - } else { - return "", fmt.Errorf("repository %s/%s already exists on GitHub (use --repo-name to specify a different name)", owner, suggested) - } - } - if !interactive.CanPromptInteractively() { - return suggested, nil - } - - name := suggested - for { - var input string - form := NewAccessibleForm( - huh.NewGroup( - huh.NewInput(). - Title("Repository name"). - Description(fmt.Sprintf("Press enter to use %q", name)). - Value(&input), - ), - ).WithOutput(w) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", errBootstrapInterrupted - } - return "", fmt.Errorf("repo name prompt: %w", err) - } - if strings.TrimSpace(input) != "" { - name = strings.TrimSpace(input) - } - if err := validateRepoName(name); err != nil { - fmt.Fprintf(errW, "Invalid name: %v\n", err) - continue - } - exists, checkErr := ghRepoExists(ctx, runner, owner, name) - if checkErr != nil { - fmt.Fprintf(errW, "Warning: could not check if %s/%s already exists (%v). Proceeding; gh will error out if it is taken.\n", owner, name, checkErr) - return name, nil - } - if exists { - fmt.Fprintf(w, "%s/%s already exists on GitHub. Pick a different name.\n", owner, name) - continue - } - return name, nil - } -} - -func resolveVisibility(owner, currentUser string, opts GitHubBootstrapOptions) (string, error) { - isOrg := owner != currentUser - - if opts.RepoVisibility != "" { - vis := strings.ToLower(opts.RepoVisibility) - switch vis { - case visibilityPublic, visibilityPrivate: - return vis, nil - case visibilityInternal: - if !isOrg { - return "", errors.New("visibility 'internal' is only available for organization repositories") - } - return vis, nil - default: - return "", fmt.Errorf("invalid visibility %q: must be one of public, private, internal", opts.RepoVisibility) - } - } - if opts.Yes || !interactive.CanPromptInteractively() { - return visibilityPrivate, nil - } - - options := []huh.Option[string]{ - huh.NewOption("Private", visibilityPrivate), - huh.NewOption("Public", visibilityPublic), - } - if isOrg { - options = append(options, huh.NewOption("Internal", visibilityInternal)) - } - selected := visibilityPrivate - form := NewAccessibleForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Repository visibility"). - Options(options...). - Value(&selected), - ), - ) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", errBootstrapInterrupted - } - return "", fmt.Errorf("visibility prompt: %w", err) - } - return selected, nil -} - -// resolveCommitMessage returns the message to use for the initial -// commit. The second return value is false when the user chose to skip -// the initial commit entirely; callers must skip `doInitialCommit` and -// any subsequent push. -func resolveCommitMessage(opts GitHubBootstrapOptions) (string, bool, error) { - if opts.SkipInitialCommit { - return "", false, nil - } - if opts.InitialCommitMessage != "" { - return opts.InitialCommitMessage, true, nil - } - if opts.Yes || !interactive.CanPromptInteractively() { - return defaultInitialCommitMessage, true, nil - } - - const ( - choiceDefault = "default" - choiceCustomize = "custom" - choiceSkip = "skip" - ) - choice := choiceDefault - form := NewAccessibleForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Initial commit"). - Options( - huh.NewOption(`Commit with default message "Initial commit"`, choiceDefault), - huh.NewOption("Customize message...", choiceCustomize), - huh.NewOption("Skip — I'll commit manually later", choiceSkip), - ). - Value(&choice), - ), - ) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", false, errBootstrapInterrupted - } - return "", false, fmt.Errorf("commit message prompt: %w", err) - } - - switch choice { - case choiceSkip: - return "", false, nil - case choiceCustomize: - input := defaultInitialCommitMessage - custom := NewAccessibleForm( - huh.NewGroup( - huh.NewInput(). - Title("Initial commit message"). - Value(&input), - ), - ) - if err := custom.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", false, errBootstrapInterrupted - } - return "", false, fmt.Errorf("commit message prompt: %w", err) - } - if strings.TrimSpace(input) == "" { - return defaultInitialCommitMessage, true, nil - } - return input, true, nil - default: - return defaultInitialCommitMessage, true, nil - } -} - -// gitInit runs `git init` in the given directory. -func gitInit(ctx context.Context, runner bootstrapRunner, dir string) error { - if _, err := runner.RunInDir(ctx, dir, "git", "init"); err != nil { - return fmt.Errorf("run git init: %w", err) - } - return nil -} - -// doInitialCommit stages all files and creates a commit. Returns whether a -// commit was actually created (false if there were no files to stage). -func doInitialCommit(ctx context.Context, runner bootstrapRunner, dir, message string) (bool, error) { - if _, err := runner.RunInDir(ctx, dir, "git", "add", "-A"); err != nil { - return false, wrapExecError("git add", err) - } - // Check if the staging area has anything at all. - // --no-optional-locks keeps this a read: a bare `git status` rewrites - // .git/index to refresh its stat cache (issue #2111). - out, err := runner.RunInDir(ctx, dir, "git", "--no-optional-locks", "status", "--porcelain") - if err != nil { - return false, wrapExecError("git status", err) - } - if strings.TrimSpace(out) == "" { - return false, nil - } - // Disable GPG signing for this commit only. Fresh environments often - // have commit.gpgsign=true inherited from a global config but no - // working signer; passing -c keeps the user's global config intact. - if _, err := runner.RunInDir(ctx, dir, "git", "-c", "commit.gpgsign=false", "commit", "-m", message); err != nil { - return false, wrapExecError("git commit", err) - } - return true, nil -} - -// wrapExecError formats err with stderr from *exec.ExitError when available, -// so callers see git's actual complaint instead of an opaque "exit status N". -func wrapExecError(prefix string, err error) error { - var ee *exec.ExitError - if errors.As(err, &ee) { - if stderr := strings.TrimSpace(string(ee.Stderr)); stderr != "" { - return fmt.Errorf("%s: %w: %s", prefix, err, stderr) - } - } - return fmt.Errorf("%s: %w", prefix, err) -} - -// ensureGitIdentity guarantees the repo has a user.name/user.email set at -// some scope. If neither is configured, we source values from `gh api user` -// when available, otherwise prompt (interactive) or fail with a helpful -// message (non-interactive). Values are written to the local repo config -// only, so the user's global state is never mutated. -func ensureGitIdentity(ctx context.Context, w, _ io.Writer, runner bootstrapRunner, dir string) error { - // `git config --get` exits non-zero when the key isn't set. Treat any - // error as "unset" rather than fatal so we can fall through to sourcing - // the identity from elsewhere. - nameOut, nameErr := runner.RunInDir(ctx, dir, "git", "config", "--get", "user.name") - emailOut, emailErr := runner.RunInDir(ctx, dir, "git", "config", "--get", "user.email") - var existingName, existingEmail string - if nameErr == nil { - existingName = strings.TrimSpace(nameOut) - } - if emailErr == nil { - existingEmail = strings.TrimSpace(emailOut) - } - if existingName != "" && existingEmail != "" { - return nil - } - - // Only try to fill in what's missing. If the user has a name set - // globally but no email, we want to keep their name and just source - // the email. - var ghName, ghEmail string - if ghAvailable(ctx, runner) && ghAuthenticated(ctx, runner) { - if n, e, err := ghUserIdentity(ctx, runner); err == nil { - ghName, ghEmail = n, e - } - } - - name, email, err := resolveGitIdentity(w, existingName, existingEmail, ghName, ghEmail) - if err != nil { - return err - } - - // Write only the fields that were missing. Leaving the already-set - // field alone means we never silently replace the user's globally - // configured name/email. - if existingName == "" { - if _, err := runner.RunInDir(ctx, dir, "git", "config", "user.name", name); err != nil { - return fmt.Errorf("git config user.name: %w", err) - } - } - if existingEmail == "" { - if _, err := runner.RunInDir(ctx, dir, "git", "config", "user.email", email); err != nil { - return fmt.Errorf("git config user.email: %w", err) - } - } - return nil -} - -// resolveGitIdentity returns the name/email to use, given any values -// already configured at a wider scope and any values from `gh api user`. -// Only prompts for fields that are still empty after those fallbacks. -func resolveGitIdentity(w io.Writer, existingName, existingEmail, ghName, ghEmail string) (string, string, error) { - name := existingName - email := existingEmail - if name == "" { - name = ghName - } - if email == "" { - email = ghEmail - } - - if name != "" && email != "" { - // Announce only when we had to fill something in from gh — - // silence is fine when the user's existing config covered both. - if (existingName == "" && ghName != "") || (existingEmail == "" && ghEmail != "") { - fmt.Fprintf(w, " Using git identity: %s <%s>\n", name, email) - } - return name, email, nil - } - - if !interactive.CanPromptInteractively() { - return "", "", errors.New(`git identity not configured. Set it with: - git config --global user.name "Your Name" - git config --global user.email "you@example.com"`) - } - - // Prompt only for the still-missing fields. - var fields []huh.Field - if name == "" { - fields = append(fields, huh.NewInput().Title("Git user.name").Value(&name)) - } - if email == "" { - fields = append(fields, huh.NewInput().Title("Git user.email").Value(&email)) - } - form := NewAccessibleForm(huh.NewGroup(fields...)) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", "", errBootstrapInterrupted - } - return "", "", fmt.Errorf("git identity prompt: %w", err) - } - if strings.TrimSpace(name) == "" || strings.TrimSpace(email) == "" { - return "", "", errors.New("git user.name and user.email are both required") - } - return strings.TrimSpace(name), strings.TrimSpace(email), nil -} - -// ghUserResponse is the subset of `gh api user` fields we care about. -type ghUserResponse struct { - ID int64 `json:"id"` - Login string `json:"login"` - Name string `json:"name"` - Email string `json:"email"` -} - -// ghUserIdentity returns a best-effort (name, email) from `gh api user`. -// Missing name falls back to login; missing email falls back to the GitHub -// no-reply address, which is always accepted by GitHub. -func ghUserIdentity(ctx context.Context, runner bootstrapRunner) (string, string, error) { - out, err := runner.Run(ctx, "gh", "api", "user") - if err != nil { - return "", "", fmt.Errorf("gh api user: %w", err) - } - var resp ghUserResponse - if err := json.Unmarshal([]byte(out), &resp); err != nil { - return "", "", fmt.Errorf("parse gh user response: %w", err) - } - name := resp.Name - if name == "" { - name = resp.Login - } - email := resp.Email - if email == "" && resp.ID != 0 && resp.Login != "" { - email = fmt.Sprintf("%d+%s@users.noreply.github.com", resp.ID, resp.Login) - } - if name == "" || email == "" { - return "", "", errors.New("gh user response missing identity fields") - } - return name, email, nil -} - -// ghAvailable reports whether the gh CLI is installed. -func ghAvailable(ctx context.Context, runner bootstrapRunner) bool { - _, err := runner.Run(ctx, "gh", "--version") - return err == nil -} - -// ghAuthenticated reports whether `gh auth status` succeeds. -func ghAuthenticated(ctx context.Context, runner bootstrapRunner) bool { - _, err := runner.Run(ctx, "gh", "auth", "status") - return err == nil -} - -// ghCurrentUser returns the authenticated GitHub user's login. -func ghCurrentUser(ctx context.Context, runner bootstrapRunner) (string, error) { - out, err := runner.Run(ctx, "gh", "api", "user", "--jq", ".login") - if err != nil { - return "", fmt.Errorf("gh api user: %w", err) - } - return strings.TrimSpace(out), nil -} - -// ghListOrgs returns the orgs the authenticated user belongs to, sorted -// alphabetically. Requires the `read:org` token scope. -func ghListOrgs(ctx context.Context, runner bootstrapRunner) ([]string, error) { - out, err := runner.Run(ctx, "gh", "api", "user/orgs", "--jq", ".[].login") - if err != nil { - return nil, fmt.Errorf("gh api user/orgs: %w", err) - } - var orgs []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - orgs = append(orgs, line) - } - } - sort.Strings(orgs) - return orgs, nil -} - -// ghRepoExists checks whether / exists on GitHub. -func ghRepoExists(ctx context.Context, runner bootstrapRunner, owner, name string) (bool, error) { - _, err := runner.Run(ctx, "gh", "repo", "view", owner+"/"+name, "--json", "name") - if err == nil { - return true, nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - msg := string(exitErr.Stderr) - if strings.Contains(msg, "Could not resolve") || strings.Contains(msg, "not found") || strings.Contains(msg, "Not Found") { - return false, nil - } - } - return false, fmt.Errorf("gh repo view: %w", err) -} - -// ghRepoCreate creates a GitHub repo from the local source directory and -// adds origin as its remote. It pushes only when push is true; callers gate -// this on both having a commit and the user opting into the push. -func ghRepoCreate(ctx context.Context, runner bootstrapRunner, dir, fullName, visibility string, push bool) error { - // Create the remote repo and add origin, but don't push yet. We push - // separately below with --no-verify so the pre-push hook doesn't run - // on this first push: the entire/checkpoints/v1 branch has nothing to - // checkpoint (no sessions yet), and if it's pushed alongside the - // default branch GitHub can pick it as the default. - // - // Capture `gh repo create`'s stdout instead of streaming it — its own - // "✓ Created repository..." / "✓ Added remote..." lines would - // duplicate our own summary in runGitHubBootstrapFinalize. - args := []string{ - "repo", "create", fullName, - "--" + visibility, - "--source=.", - "--remote=origin", - } - if _, err := runner.RunInDir(ctx, dir, "gh", args...); err != nil { - return fmt.Errorf("gh repo create: %w", ghRunnerErr(err)) - } - if push { - // -q silences "Enumerating objects..." etc. --no-verify bypasses - // the pre-push hook so entire/checkpoints/v1 isn't pushed - // alongside the default branch. - if _, err := runner.RunInDir(ctx, dir, "git", "push", "-q", "--no-verify", "-u", "origin", "HEAD"); err != nil { - return fmt.Errorf("git push: %w", ghRunnerErr(err)) - } - } - return nil -} - -// ghRunnerErr extracts an exec.ExitError's stderr into the returned -// error so the user sees a useful diagnostic when gh/git fail under a -// captured-stdout call. -func ghRunnerErr(err error) error { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(exitErr.Stderr))) - } - return err -} - -// slugifyRepoName turns a folder name into a GitHub-safe repo name. Invalid -// characters are replaced with '-', and runs of '-' are collapsed. -func slugifyRepoName(folder string) string { - var b strings.Builder - b.Grow(len(folder)) - for _, r := range folder { - switch { - case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'): - b.WriteRune(r) - case r == '-' || r == '_' || r == '.': - b.WriteRune(r) - default: - b.WriteRune('-') - } - } - slug := b.String() - // Collapse repeated dashes. - for strings.Contains(slug, "--") { - slug = strings.ReplaceAll(slug, "--", "-") - } - slug = strings.Trim(slug, "-.") - if slug == "" { - slug = "my-repo" - } - return slug -} - -// validateRepoName checks whether name is a valid GitHub repo name. -func validateRepoName(name string) error { - if name == "" { - return errors.New("name is required") - } - if len(name) > 100 { - return errors.New("name must be at most 100 characters") - } - if strings.Contains(name, "/") { - return errors.New("name must not contain '/' (pass --repo-owner separately)") - } - if name == "." || name == ".." { - return errors.New("name cannot be '.' or '..'") - } - if !ghRepoNameRe.MatchString(name) { - return errors.New("name may only contain letters, digits, '.', '-', '_'") - } - return nil -} diff --git a/cmd/entire/cli/setup_github_test.go b/cmd/entire/cli/setup_github_test.go deleted file mode 100644 index bae3db045f..0000000000 --- a/cmd/entire/cli/setup_github_test.go +++ /dev/null @@ -1,1530 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - "testing" -) - -const ( - testUser = "octocat" - cmdGit = "git" - ghSubcmdRepo = "repo" - ghActCreate = "create" - gitCmdCommit = "commit" - gitCmdConfig = "config" -) - -// runGitHubBootstrapWith runs the full bootstrap (init + finalize) in one -// call, used by tests that don't need to assert phasing. The real caller -// runs the two phases around agent setup. -func runGitHubBootstrapWith(ctx context.Context, w, errW io.Writer, opts GitHubBootstrapOptions, runner bootstrapRunner) error { - state, err := runGitHubBootstrapInitWith(ctx, w, errW, opts, runner) - if err != nil { - return err - } - return runGitHubBootstrapFinalize(ctx, w, state) -} -func TestSlugifyRepoName(t *testing.T) { - t.Parallel() - cases := map[string]string{ - "my-project": "my-project", - "My Cool Project": "My-Cool-Project", - "weird@@@name!!": "weird-name", - "": "my-repo", - "---": "my-repo", - "foo__bar": "foo__bar", - "a.b.c": "a.b.c", - "leading space": "leading-space", - "double space here": "double-space-here", - } - for in, want := range cases { - if got := slugifyRepoName(in); got != want { - t.Errorf("slugifyRepoName(%q) = %q, want %q", in, got, want) - } - } -} - -func TestValidateRepoName(t *testing.T) { - t.Parallel() - // GitHub accepts leading ".", "-", and "_" (e.g. `.github`), so we - // accept them too. - valid := []string{ - "my-repo", "foo_bar", "a.b.c", "Repo123", "x", - ".github", ".leading", "-leading", "_leading", - } - for _, name := range valid { - if err := validateRepoName(name); err != nil { - t.Errorf("validateRepoName(%q) unexpectedly returned error: %v", name, err) - } - } - // "." and ".." are specifically rejected; anything with / or - // whitespace is rejected; length is capped. - invalid := []string{"", ".", "..", "has/slash", "has space", strings.Repeat("a", 101)} - for _, name := range invalid { - if err := validateRepoName(name); err == nil { - t.Errorf("validateRepoName(%q) = nil, want error", name) - } - } -} - -// fakeRunner is a test seam for bootstrapRunner. Each (name, args[0]) pair -// maps to a response. -type fakeRunner struct { - mu sync.Mutex - responses map[string]fakeResponse - calls []fakeCall -} - -type fakeResponse struct { - stdout string - err error -} - -type fakeCall struct { - dir string - name string - args []string -} - -func newFakeRunner() *fakeRunner { - return &fakeRunner{ - responses: make(map[string]fakeResponse), - } -} - -func (f *fakeRunner) key(name string, args []string) string { - return name + " " + strings.Join(args, " ") -} - -func (f *fakeRunner) set(name string, args []string, stdout string, err error) { - f.mu.Lock() - defer f.mu.Unlock() - f.responses[f.key(name, args)] = fakeResponse{stdout: stdout, err: err} -} - -func (f *fakeRunner) lookup(name string, args []string) (fakeResponse, bool) { - f.mu.Lock() - defer f.mu.Unlock() - r, ok := f.responses[f.key(name, args)] - return r, ok -} - -func (f *fakeRunner) record(dir, name string, args []string) { - f.mu.Lock() - defer f.mu.Unlock() - f.calls = append(f.calls, fakeCall{dir: dir, name: name, args: args}) -} - -func (f *fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { - f.record("", name, args) - if r, ok := f.lookup(name, args); ok { - return r.stdout, r.err - } - return "", fmt.Errorf("fakeRunner: unexpected call %s %v", name, args) -} - -func (f *fakeRunner) RunInDir(_ context.Context, dir, name string, args ...string) (string, error) { - f.record(dir, name, args) - if r, ok := f.lookup(name, args); ok { - return r.stdout, r.err - } - return "", fmt.Errorf("fakeRunner: unexpected call in %s: %s %v", dir, name, args) -} - -// setIdentityConfigured simulates `git config --get user.name/email` returning -// non-empty values, so ensureGitIdentity treats identity as already set. -func (f *fakeRunner) setIdentityConfigured() { - f.set("git", []string{"config", "--get", "user.name"}, "Test User\n", nil) - f.set("git", []string{"config", "--get", "user.email"}, "test@example.com\n", nil) -} - -// hasCall returns whether any recorded call matches the predicate. -func (f *fakeRunner) hasCall(match func(fakeCall) bool) bool { - f.mu.Lock() - defer f.mu.Unlock() - for _, c := range f.calls { - if match(c) { - return true - } - } - return false -} - -func TestGhHelpers(t *testing.T) { - t.Parallel() - ctx := context.Background() - r := newFakeRunner() - - r.set("gh", []string{"--version"}, "gh version 2.81.0\n", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "gamma\nalpha\n\nbeta\n", nil) - - if !ghAvailable(ctx, r) { - t.Fatal("ghAvailable should be true") - } - if !ghAuthenticated(ctx, r) { - t.Fatal("ghAuthenticated should be true") - } - user, err := ghCurrentUser(ctx, r) - if err != nil || user != testUser { - t.Fatalf("ghCurrentUser = %q, %v; want octocat", user, err) - } - orgs, err := ghListOrgs(ctx, r) - if err != nil { - t.Fatalf("ghListOrgs error: %v", err) - } - // Must be sorted, trimmed, and blank-skipped. - want := []string{"alpha", "beta", "gamma"} - if len(orgs) != len(want) { - t.Fatalf("orgs = %v, want %v", orgs, want) - } - for i, o := range orgs { - if o != want[i] { - t.Fatalf("orgs[%d] = %q, want %q", i, o, want[i]) - } - } -} - -func TestGhAvailable_Missing(t *testing.T) { - t.Parallel() - r := newFakeRunner() - r.set("gh", []string{"--version"}, "", errors.New("not found")) - if ghAvailable(context.Background(), r) { - t.Fatal("expected ghAvailable to return false when gh is missing") - } -} - -func TestResolveOwner_FlagAcceptsUnknown(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - owner, err := resolveOwner(&buf, testUser, []string{"acme"}, GitHubBootstrapOptions{RepoOwner: "external-org"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if owner != "external-org" { - t.Fatalf("owner = %q, want external-org", owner) - } -} - -func TestResolveOwner_SingleDefault(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - owner, err := resolveOwner(&buf, testUser, nil, GitHubBootstrapOptions{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if owner != testUser { - t.Fatalf("owner = %q, want octocat", owner) - } - if !strings.Contains(buf.String(), testUser) { - t.Fatalf("expected owner announcement, got %q", buf.String()) - } -} - -func TestResolveVisibility_FlagInternalRequiresOrg(t *testing.T) { - t.Parallel() - _, err := resolveVisibility(testUser, testUser, GitHubBootstrapOptions{RepoVisibility: "internal"}) - if err == nil { - t.Fatal("expected error for internal visibility on user repo") - } -} - -func TestResolveVisibility_FlagValid(t *testing.T) { - t.Parallel() - for _, v := range []string{"public", "private", "internal"} { - owner := testUser - current := testUser - if v == "internal" { - owner = "acme" - } - got, err := resolveVisibility(owner, current, GitHubBootstrapOptions{RepoVisibility: v}) - if err != nil { - t.Fatalf("%s: unexpected error: %v", v, err) - } - if got != v { - t.Fatalf("%s: got %q", v, got) - } - } -} - -func TestResolveVisibility_FlagInvalid(t *testing.T) { - t.Parallel() - _, err := resolveVisibility(testUser, testUser, GitHubBootstrapOptions{RepoVisibility: "weird"}) - if err == nil { - t.Fatal("expected error for invalid visibility") - } -} - -func TestResolveRepoName_FlagValidates(t *testing.T) { - t.Parallel() - r := newFakeRunner() - // Return a non-ExitError; ghRepoExists then bubbles up, and resolveRepoName - // logs a warning but proceeds with the flag-supplied name. - r.set("gh", []string{"repo", "view", "octocat/ok-name", "--json", "name"}, "", errors.New("transient")) - name, err := resolveRepoName(context.Background(), io.Discard, io.Discard, r, testUser, t.TempDir(), GitHubBootstrapOptions{RepoName: "ok-name"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if name != "ok-name" { - t.Fatalf("name = %q", name) - } -} - -func TestResolveRepoName_FlagRejectsInvalid(t *testing.T) { - t.Parallel() - r := newFakeRunner() - _, err := resolveRepoName(context.Background(), io.Discard, io.Discard, r, testUser, t.TempDir(), GitHubBootstrapOptions{RepoName: "has/slash"}) - if err == nil { - t.Fatal("expected error for name containing '/'") - } -} - -func TestGhRepoExists_RealErrorPath(t *testing.T) { - t.Parallel() - // If `gh repo view` succeeds (no error), the repo exists. - r := newFakeRunner() - r.set("gh", []string{"repo", "view", "octocat/real", "--json", "name"}, "{\"name\":\"real\"}", nil) - exists, err := ghRepoExists(context.Background(), r, testUser, "real") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !exists { - t.Fatal("expected exists=true") - } -} - -func TestDoInitialCommit_EmptyFolder(t *testing.T) { - t.Parallel() - dir := t.TempDir() - r := newFakeRunner() - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, "", nil) - - committed, err := doInitialCommit(context.Background(), r, dir, "msg") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if committed { - t.Fatal("expected committed=false for empty folder") - } -} - -func TestDoInitialCommit_WithFiles(t *testing.T) { - t.Parallel() - dir := t.TempDir() - r := newFakeRunner() - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M README.md\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "msg"}, "", nil) - - committed, err := doInitialCommit(context.Background(), r, dir, "msg") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !committed { - t.Fatal("expected committed=true") - } - // Verify gpgsign=false was passed to the commit. - if !r.hasCall(func(c fakeCall) bool { - return c.name == cmdGit && len(c.args) >= 3 && c.args[0] == "-c" && c.args[1] == "commit.gpgsign=false" && c.args[2] == gitCmdCommit - }) { - t.Fatal("expected commit to pass -c commit.gpgsign=false") - } -} - -func TestRunGitHubBootstrap_DeclinedInNonInteractive(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, GitHubBootstrapOptions{}, newFakeRunner()) - if !errors.Is(err, errBootstrapDeclined) { - t.Fatalf("expected errBootstrapDeclined, got %v", err) - } -} - -func TestRunGitHubBootstrap_NoGitHubFlow(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M file\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "First!"}, "", nil) - - opts := GitHubBootstrapOptions{ - InitRepo: true, - NoGitHub: true, - InitialCommitMessage: "First!", - } - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify git init ran in the cwd. - if !r.hasCall(func(c fakeCall) bool { - return c.name == cmdGit && len(c.args) == 1 && c.args[0] == "init" - }) { - t.Fatal("expected git init call") - } - // Verify no gh calls were made. - if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) { - t.Fatal("did not expect gh calls with --no-github") - } -} - -func TestRunGitHubBootstrap_GhMissingFallsBackToLocal(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("gh", []string{"--version"}, "", errors.New("not found")) - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, "", nil) - - // A repo flag is an explicit GitHub request, so gh is probed; since it's - // missing we warn and fall back to local-only. - opts := GitHubBootstrapOptions{InitRepo: true, RepoName: "wanted"} - var errBuf bytes.Buffer - err := runGitHubBootstrapWith(context.Background(), io.Discard, &errBuf, opts, r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(errBuf.String(), "gh CLI not found") { - t.Fatalf("expected hint about installing gh, got %q", errBuf.String()) - } -} - -func TestRunGitHubBootstrap_FullNonInteractive(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("gh", []string{"--version"}, "gh 2.81.0", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - // Name availability check: repo does not exist yet. - r.set("gh", []string{"repo", "view", "octocat/my-new", "--json", "name"}, "", errors.New("not found")) - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Seed"}, "", nil) - r.set("gh", []string{ - "repo", "create", "octocat/my-new", - "--private", - "--source=.", - "--remote=origin", - }, "", nil) - r.set("git", []string{"push", "-q", "--no-verify", "-u", "origin", "HEAD"}, "", nil) - - opts := GitHubBootstrapOptions{ - InitRepo: true, - RepoName: "my-new", - RepoVisibility: "private", - InitialCommitMessage: "Seed", - Push: true, - } - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !r.hasCall(func(c fakeCall) bool { - return c.name == "gh" && len(c.args) > 3 && c.args[0] == ghSubcmdRepo && c.args[1] == ghActCreate - }) { - t.Fatal("expected gh repo create call") - } - // The initial push must bypass hooks (--no-verify) and be quiet (-q). - if !r.hasCall(argsMatch("git", []string{"push", "-q", "--no-verify", "-u", "origin", "HEAD"})) { - t.Fatal("expected git push -q --no-verify after repo create") - } -} - -func TestRunGitHubBootstrap_RepoExistsFails(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - // The name is already taken. Since we aren't returning an *exec.ExitError, - // ghRepoExists returns (false, err) and ghRepoExists wraps. To avoid - // plumbing ExitError into the test, use the "already exists" path directly - // by returning success — meaning the repo was found. - r.set("gh", []string{"repo", "view", "octocat/taken", "--json", "name"}, "{\"name\":\"taken\"}", nil) - r.set("git", []string{"init"}, "", nil) - - opts := GitHubBootstrapOptions{ - InitRepo: true, - RepoName: "taken", - } - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r) - if err == nil { - t.Fatal("expected error when repo already exists") - } - if !strings.Contains(err.Error(), "already exists") { - t.Fatalf("expected 'already exists' error, got %v", err) - } -} - -func TestResolveCommitMessage_SkipFlag(t *testing.T) { - t.Parallel() - msg, commit, err := resolveCommitMessage(GitHubBootstrapOptions{SkipInitialCommit: true}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if commit { - t.Fatal("commit should be false when SkipInitialCommit is set") - } - if msg != "" { - t.Fatalf("message should be empty when skipping, got %q", msg) - } -} - -func TestResolveCommitMessage_FlagTakesMessage(t *testing.T) { - t.Parallel() - msg, commit, err := resolveCommitMessage(GitHubBootstrapOptions{InitialCommitMessage: "custom"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !commit { - t.Fatal("commit should be true with explicit message flag") - } - if msg != "custom" { - t.Fatalf("message = %q, want custom", msg) - } -} - -func TestResolveCommitMessage_NonInteractiveDefault(t *testing.T) { - msg, commit, err := resolveCommitMessage(GitHubBootstrapOptions{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !commit { - t.Fatal("commit should default to true non-interactively") - } - if msg != defaultInitialCommitMessage { - t.Fatalf("message = %q, want Initial commit", msg) - } -} - -// TestRunGitHubBootstrap_SkipCommitKeepsGitHub verifies that passing -// --skip-initial-commit still creates the GitHub repo (if requested) but -// skips both commit and push. The local repo's files remain unstaged. -func TestRunGitHubBootstrap_SkipCommitKeepsGitHub(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "ok", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - r.set("gh", []string{"repo", "view", "octocat/skipme", "--json", "name"}, "", errors.New("not found")) - r.set("git", []string{"init"}, "", nil) - r.set("gh", []string{ - "repo", "create", "octocat/skipme", - "--private", - "--source=.", - "--remote=origin", - }, "", nil) - - opts := GitHubBootstrapOptions{ - InitRepo: true, - RepoName: "skipme", - RepoVisibility: "private", - SkipInitialCommit: true, - } - var out bytes.Buffer - if err := runGitHubBootstrapWith(context.Background(), &out, io.Discard, opts, r); err != nil { - t.Fatalf("bootstrap failed: %v", err) - } - - if r.hasCall(argsMatch("git", []string{"add", "-A"})) { - t.Fatal("git add should not run when SkipInitialCommit is set") - } - if r.hasCall(func(c fakeCall) bool { - return c.name == cmdGit && len(c.args) >= 1 && (c.args[0] == gitCmdCommit || (len(c.args) >= 3 && c.args[2] == gitCmdCommit)) - }) { - t.Fatal("git commit should not run when SkipInitialCommit is set") - } - if r.hasCall(argsMatch("git", []string{"push"})) { - t.Fatal("git push should not run when commit was skipped") - } - // gh repo create should still have run. - if !r.hasCall(argsMatch("gh", []string{"repo", "create"})) { - t.Fatal("gh repo create should still run when only the commit is skipped") - } - // Output should mention how to commit manually. - if !strings.Contains(out.String(), "git add -A") { - t.Fatalf("expected manual-commit hint in output, got: %s", out.String()) - } -} - -func TestGhFlagsProvided(t *testing.T) { - t.Parallel() - cases := []struct { - name string - opts GitHubBootstrapOptions - want bool - }{ - {"none", GitHubBootstrapOptions{}, false}, - {"repo-name", GitHubBootstrapOptions{RepoName: "foo"}, true}, - {"repo-owner", GitHubBootstrapOptions{RepoOwner: "octocat"}, true}, - {"repo-visibility", GitHubBootstrapOptions{RepoVisibility: "private"}, true}, - // NoGitHub intentionally does NOT count as "provided" — it's the - // opposite signal. It's handled separately upstream. - {"no-github", GitHubBootstrapOptions{NoGitHub: true}, false}, - {"init-repo only", GitHubBootstrapOptions{InitRepo: true}, false}, - } - for _, tc := range cases { - if got := ghFlagsProvided(tc.opts); got != tc.want { - t.Errorf("%s: ghFlagsProvided = %v, want %v", tc.name, got, tc.want) - } - } -} - -// TestRunGitHubBootstrap_NonInteractive_NoFlagsStaysLocal confirms that a -// non-interactive bootstrap with no explicit GitHub signal stays local-only: -// it does not probe gh, create a repo, or push. Creating and pushing are -// explicit opt-ins (--repo-*, --push, --yes, or an interactive "yes"). -func TestRunGitHubBootstrap_NonInteractive_NoFlagsStaysLocal(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("git", []string{"init"}, "", nil) - - state, err := runGitHubBootstrapInitWith(context.Background(), io.Discard, io.Discard, GitHubBootstrapOptions{InitRepo: true}, r) - if err != nil { - t.Fatalf("init failed: %v", err) - } - if state.useGitHub { - t.Fatal("non-interactive bootstrap with no explicit signal must stay local-only") - } - if state.push { - t.Fatal("push must be false when staying local-only") - } - // gh must never be probed when no GitHub repo was requested. - if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) { - t.Fatal("must not invoke gh when no GitHub repo was requested") - } -} - -// TestRunGitHubBootstrap_RepoFlagsCreateButDoNotPush confirms that repo flags -// opt into creating the GitHub repo but NOT into pushing. Non-interactively, -// the repo is created and origin configured, but nothing is pushed unless -// --push or --yes is also given; the user is told how to publish manually. -func TestRunGitHubBootstrap_RepoFlagsCreateButDoNotPush(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("gh", []string{"--version"}, "gh 2.81.0", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - r.set("gh", []string{"repo", "view", "octocat/create-only", "--json", "name"}, "", errors.New("not found")) - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Seed"}, "", nil) - r.set("gh", []string{ - "repo", "create", "octocat/create-only", - "--private", - "--source=.", - "--remote=origin", - }, "", nil) - - opts := GitHubBootstrapOptions{ - InitRepo: true, - RepoName: "create-only", - RepoVisibility: "private", - InitialCommitMessage: "Seed", - } - var out bytes.Buffer - if err := runGitHubBootstrapWith(context.Background(), &out, io.Discard, opts, r); err != nil { - t.Fatalf("bootstrap failed: %v", err) - } - - if !r.hasCall(argsMatch("gh", []string{"repo", "create"})) { - t.Fatal("expected gh repo create when repo flags are given") - } - if r.hasCall(argsMatch("git", []string{"push"})) { - t.Fatal("must not push without --push or --yes") - } - if !strings.Contains(out.String(), "Skipped push") { - t.Fatalf("expected 'Skipped push' guidance, got: %s", out.String()) - } -} - -// TestRunGitHubBootstrap_InitBeforeFinalize verifies the two-phase split: -// init runs git init up front, finalize creates the commit + pushes. A -// simulated "agent setup" step writes a file between the phases; that -// file must end up in the initial commit (i.e. `git add -A` happens -// after setup, not before). -func TestRunGitHubBootstrap_InitBeforeFinalize(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "ok", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - r.set("gh", []string{"repo", "view", "octocat/phased", "--json", "name"}, "", errors.New("not found")) - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " A .entire/settings.json\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "First"}, "", nil) - r.set("gh", []string{ - "repo", "create", "octocat/phased", - "--private", - "--source=.", - "--remote=origin", - }, "", nil) - r.set("git", []string{"push", "-q", "--no-verify", "-u", "origin", "HEAD"}, "", nil) - - opts := GitHubBootstrapOptions{ - InitRepo: true, - RepoName: "phased", - RepoVisibility: "private", - InitialCommitMessage: "First", - Push: true, - } - - // Phase 1: init. This must NOT call git add/commit/ gh repo create. - state, err := runGitHubBootstrapInitWith(context.Background(), io.Discard, io.Discard, opts, r) - if err != nil { - t.Fatalf("init failed: %v", err) - } - if state == nil { - t.Fatal("expected non-nil state after init") - } - forbidden := [][]string{ - {"add", "-A"}, - {"--no-optional-locks", "status", "--porcelain"}, - {"-c", "commit.gpgsign=false", "commit", "-m", "First"}, - } - for _, args := range forbidden { - if r.hasCall(argsMatch("git", args)) { - t.Fatalf("git %v was called during init; should have been deferred to finalize", args) - } - } - if r.hasCall(func(c fakeCall) bool { - return c.name == "gh" && len(c.args) >= 2 && c.args[0] == ghSubcmdRepo && c.args[1] == ghActCreate - }) { - t.Fatal("gh repo create was called during init; should have been deferred to finalize") - } - - // Phase 2: finalize. Now commit + push. - if err := runGitHubBootstrapFinalize(context.Background(), io.Discard, state); err != nil { - t.Fatalf("finalize failed: %v", err) - } - if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "First"})) { - t.Fatal("expected commit during finalize") - } - if !r.hasCall(func(c fakeCall) bool { - return c.name == "gh" && len(c.args) >= 2 && c.args[0] == ghSubcmdRepo && c.args[1] == ghActCreate - }) { - t.Fatal("expected gh repo create during finalize") - } -} - -// argsMatch returns a predicate for hasCall that matches when c.name == name -// and c.args starts with the given args slice. -func argsMatch(name string, args []string) func(fakeCall) bool { - return func(c fakeCall) bool { - if c.name != name || len(c.args) < len(args) { - return false - } - for i, a := range args { - if c.args[i] != a { - return false - } - } - return true - } -} - -func TestEnsureGitIdentity_AlreadyConfigured(t *testing.T) { - t.Parallel() - r := newFakeRunner() - r.setIdentityConfigured() - - err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // No git config writes should have occurred. - if r.hasCall(func(c fakeCall) bool { - return c.name == cmdGit && len(c.args) >= 2 && c.args[0] == gitCmdConfig && (c.args[1] == "user.name" || c.args[1] == "user.email") - }) { - t.Fatal("did not expect identity writes when already configured") - } -} - -func TestEnsureGitIdentity_SourcedFromGh(t *testing.T) { - t.Parallel() - r := newFakeRunner() - // Identity missing locally (empty stdout). - r.set("git", []string{"config", "--get", "user.name"}, "", errors.New("not set")) - r.set("git", []string{"config", "--get", "user.email"}, "", errors.New("not set")) - // gh available and authenticated. - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "ok", nil) - r.set("gh", []string{"api", "user"}, `{"id":42,"login":"octo","name":"Octo Cat","email":"octo@example.com"}`, nil) - // Expect writes with values from gh. - r.set("git", []string{"config", "user.name", "Octo Cat"}, "", nil) - r.set("git", []string{"config", "user.email", "octo@example.com"}, "", nil) - - err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestEnsureGitIdentity_GhNoreplyFallback(t *testing.T) { - t.Parallel() - r := newFakeRunner() - r.set("git", []string{"config", "--get", "user.name"}, "", errors.New("not set")) - r.set("git", []string{"config", "--get", "user.email"}, "", errors.New("not set")) - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "ok", nil) - // email is null/missing: should fall back to id+login noreply. - r.set("gh", []string{"api", "user"}, `{"id":42,"login":"octo","name":"","email":null}`, nil) - r.set("git", []string{"config", "user.name", "octo"}, "", nil) - r.set("git", []string{"config", "user.email", "42+octo@users.noreply.github.com"}, "", nil) - - err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// TestEnsureGitIdentity_PreservesExistingName covers the partial-config -// case: `user.name` is set globally but `user.email` is missing. We must -// source only the email (from gh) and leave the name untouched — we -// never want to silently replace the user's configured name with a -// gh-derived login. -func TestEnsureGitIdentity_PreservesExistingName(t *testing.T) { - t.Parallel() - r := newFakeRunner() - // Name is set globally, email is not. - r.set("git", []string{"config", "--get", "user.name"}, "John Doe\n", nil) - r.set("git", []string{"config", "--get", "user.email"}, "", errors.New("not set")) - // gh available and returns both values. - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "ok", nil) - r.set("gh", []string{"api", "user"}, `{"id":42,"login":"johndoe","name":"Johnny Dough","email":"john@example.com"}`, nil) - // Only the email should be written locally — the name must stay - // at the user's global value. - r.set("git", []string{"config", "user.email", "john@example.com"}, "", nil) - - err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // No `git config user.name ...` call should have been made. - if r.hasCall(func(c fakeCall) bool { - return c.name == cmdGit && len(c.args) >= 2 && c.args[0] == gitCmdConfig && c.args[1] == "user.name" - }) { - t.Fatal("ensureGitIdentity should not write user.name when it's already set globally") - } -} - -// TestEnsureGitIdentity_PreservesExistingEmail mirrors the above for the -// other direction: email set, name missing. -func TestEnsureGitIdentity_PreservesExistingEmail(t *testing.T) { - t.Parallel() - r := newFakeRunner() - r.set("git", []string{"config", "--get", "user.name"}, "", errors.New("not set")) - r.set("git", []string{"config", "--get", "user.email"}, "john@example.com\n", nil) - r.set("gh", []string{"--version"}, "gh", nil) - r.set("gh", []string{"auth", "status"}, "ok", nil) - r.set("gh", []string{"api", "user"}, `{"id":42,"login":"johndoe","name":"Johnny","email":"other@example.com"}`, nil) - r.set("git", []string{"config", "user.name", "Johnny"}, "", nil) - - err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if r.hasCall(func(c fakeCall) bool { - return c.name == cmdGit && len(c.args) >= 2 && c.args[0] == gitCmdConfig && c.args[1] == "user.email" - }) { - t.Fatal("ensureGitIdentity should not write user.email when it's already set globally") - } -} - -func TestEnsureGitIdentity_NonInteractiveNoGh_Errors(t *testing.T) { - r := newFakeRunner() - r.set("git", []string{"config", "--get", "user.name"}, "", errors.New("not set")) - r.set("git", []string{"config", "--get", "user.email"}, "", errors.New("not set")) - r.set("gh", []string{"--version"}, "", errors.New("not found")) - - err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir()) - if err == nil { - t.Fatal("expected error when identity missing and gh unavailable") - } - if !strings.Contains(err.Error(), "git config --global user.name") { - t.Fatalf("expected guidance to set git config, got %v", err) - } -} - -func TestGhUserIdentity_NameFallsBackToLogin(t *testing.T) { - t.Parallel() - r := newFakeRunner() - r.set("gh", []string{"api", "user"}, `{"id":7,"login":"dev","name":"","email":"dev@example.com"}`, nil) - name, email, err := ghUserIdentity(context.Background(), r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if name != "dev" { - t.Fatalf("name = %q", name) - } - if email != "dev@example.com" { - t.Fatalf("email = %q", email) - } -} - -// TestBootstrap_FreshMachine_RealGit is an integration-style test that runs -// real git via execRunner on a temp dir isolated from the user's global git -// config. Regression guard for the issue where bootstrap commits failed -// without a configured identity or because of commit.gpgsign=true. -func TestBootstrap_FreshMachine_RealGit(t *testing.T) { - // Isolate from any global git config: point HOME + GIT_CONFIG_* at - // empty/missing locations, and force a broken GPG signing config that - // would fail any commit if we did not pass -c commit.gpgsign=false. - emptyHome := t.TempDir() - t.Setenv("HOME", emptyHome) - t.Setenv("XDG_CONFIG_HOME", "") - // A global config that demands signing with a non-existent program. If - // our bootstrap did not override gpgsign for its commit, git would - // error out here. - globalCfg := filepath.Join(emptyHome, ".gitconfig") - globalContent := "[user]\n\tname = Fresh User\n\temail = fresh@example.com\n[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = /does/not/exist\n" - if err := writeTempFile(globalCfg, globalContent); err != nil { - t.Fatalf("write global gitconfig: %v", err) - } - t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) - // Ensure no system config interferes. - t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") - - projectDir := t.TempDir() - restoreCwd(t, projectDir) - // Create a file to commit. - if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hello\n"); err != nil { - t.Fatalf("write file: %v", err) - } - - opts := GitHubBootstrapOptions{ - InitRepo: true, - NoGitHub: true, - InitialCommitMessage: "Initial", - } - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, execRunner{}) - if err != nil { - t.Fatalf("bootstrap failed: %v", err) - } - - // Verify a commit actually landed on HEAD. - out, err := execRunner{}.RunInDir(context.Background(), projectDir, "git", "log", "--oneline") - if err != nil { - t.Fatalf("git log failed: %v", err) - } - if !strings.Contains(out, "Initial") { - t.Fatalf("expected 'Initial' commit in log, got: %q", out) - } -} - -func writeTempFile(path, content string) error { - return os.WriteFile(path, []byte(content), 0o600) -} - -// ghFailingRunner wraps another bootstrapRunner and forces all `gh` -// invocations to fail, while letting real `git` calls through. This -// lets tests deterministically exercise the "gh unavailable" path -// regardless of whether `gh` is installed/authenticated on the host. -type ghFailingRunner struct { - inner bootstrapRunner -} - -func (r ghFailingRunner) Run(ctx context.Context, name string, args ...string) (string, error) { - if name == "gh" { - return "", errors.New("gh not available (test)") - } - return r.inner.Run(ctx, name, args...) -} - -func (r ghFailingRunner) RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) { - if name == "gh" { - return "", errors.New("gh not available (test)") - } - return r.inner.RunInDir(ctx, dir, name, args...) -} - -// TestBootstrap_FreshMachine_NoIdentity_RealGit verifies that a fresh -// machine without any git identity configured fails cleanly in -// non-interactive mode with a helpful error message, instead of letting -// `git commit` fail with a confusing "please tell me who you are" stderr. -// -// Uses a gh-failing runner wrapper rather than PATH manipulation so the -// test isn't sensitive to whether `gh` + GH_TOKEN/GITHUB_TOKEN are set -// on the host. -func TestBootstrap_FreshMachine_NoIdentity_RealGit(t *testing.T) { - emptyHome := t.TempDir() - t.Setenv("HOME", emptyHome) - t.Setenv("XDG_CONFIG_HOME", "") - // Empty global config: no user.name/user.email. - globalCfg := filepath.Join(emptyHome, ".gitconfig") - if err := writeTempFile(globalCfg, ""); err != nil { - t.Fatalf("write global gitconfig: %v", err) - } - t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) - t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") - // Belt-and-suspenders: unset any GitHub tokens so a wrapper bypass - // would still not find credentials. - t.Setenv("GH_TOKEN", "") - t.Setenv("GITHUB_TOKEN", "") - - projectDir := t.TempDir() - restoreCwd(t, projectDir) - if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hi\n"); err != nil { - t.Fatalf("write file: %v", err) - } - - opts := GitHubBootstrapOptions{ - InitRepo: true, - NoGitHub: true, - InitialCommitMessage: "x", - } - runner := ghFailingRunner{inner: execRunner{}} - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, runner) - if err == nil { - t.Fatal("expected error when identity missing and gh unavailable") - } - if !strings.Contains(err.Error(), "git config --global user.name") { - t.Fatalf("expected guidance to set git config, got: %v", err) - } -} - -// TestErrSentinels_DistinctPrePostInit documents the contract that the two -// error sentinels signal: errBootstrapDeclined before `git init`, -// errBootstrapInterrupted after. setup.go relies on this to show the -// right user-facing message. -func TestErrSentinels_DistinctPrePostInit(t *testing.T) { - t.Parallel() - if errors.Is(errBootstrapDeclined, errBootstrapInterrupted) { - t.Fatal("errBootstrapDeclined and errBootstrapInterrupted must not match as the same sentinel") - } -} - -func TestEnableCmd_PushNoGitHubMutuallyExclusive(t *testing.T) { - setupTestRepo(t) - - cmd := newEnableCmd() - var stderr bytes.Buffer - cmd.SetErr(&stderr) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetArgs([]string{"--push", "--no-github"}) - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when both --push and --no-github are set") - } - if !strings.Contains(err.Error(), "push") || !strings.Contains(err.Error(), "no-github") { - t.Fatalf("expected error to mention both flags, got: %v", err) - } -} - -func TestEnableCmd_InitCommitMessageFlagsMutuallyExclusive(t *testing.T) { - setupTestRepo(t) - - cmd := newEnableCmd() - var stderr bytes.Buffer - cmd.SetErr(&stderr) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetArgs([]string{"--initial-commit-message", "foo", "--skip-initial-commit"}) - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when both --initial-commit-message and --skip-initial-commit are set") - } - if !strings.Contains(err.Error(), "initial-commit-message") || !strings.Contains(err.Error(), "skip-initial-commit") { - t.Fatalf("expected error to mention both flags, got: %v", err) - } -} - -func TestEnableCmd_InitRepoFlagsMutuallyExclusive(t *testing.T) { - setupTestRepo(t) - - cmd := newEnableCmd() - var stderr bytes.Buffer - cmd.SetErr(&stderr) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetArgs([]string{"--init-repo", "--no-init-repo"}) - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when both --init-repo and --no-init-repo are set") - } - if !strings.Contains(err.Error(), "init-repo") || !strings.Contains(err.Error(), "no-init-repo") { - t.Fatalf("expected error to mention both flags, got: %v", err) - } -} - -// withInteractivePromptStdin forces interactive, accessible (text-based) -// prompt mode and feeds input to os.Stdin for the duration of the test, so a -// huh prompt reads a scripted answer instead of opening /dev/tty or blocking -// on a real terminal. ENTIRE_TEST_TTY makes CanPromptInteractively report -// true; ACCESSIBLE makes the form read os.Stdin rather than dial the terminal. -func withInteractivePromptStdin(t *testing.T, input string) { - t.Helper() - t.Setenv("ENTIRE_TEST_TTY", "1") - t.Setenv("ACCESSIBLE", "1") - pr, pw, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { pr.Close() }) - go func() { - pw.WriteString(input) //nolint:errcheck // test helper - pw.Close() - }() - old := os.Stdin - os.Stdin = pr - t.Cleanup(func() { os.Stdin = old }) -} - -// TestConfirmInitRepo_DefaultsToNo verifies that pressing Enter (empty -// input) at the init-repo prompt declines. `entire enable` is often run -// reflexively, so a stray run in a non-repo directory must not initialize -// a repo on the user's behalf. Regression guard for issue #1717. -func TestConfirmInitRepo_DefaultsToNo(t *testing.T) { - withInteractivePromptStdin(t, "\n") - - proceed, err := confirmInitRepo(io.Discard, t.TempDir(), GitHubBootstrapOptions{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if proceed { - t.Fatal("confirmInitRepo should default to No (decline) on empty input") - } -} - -// TestConfirmInitRepo_ExplicitYesProceeds verifies an explicit "y" still -// opts in, so the safer default doesn't block intentional use. -func TestConfirmInitRepo_ExplicitYesProceeds(t *testing.T) { - withInteractivePromptStdin(t, "y\n") - - proceed, err := confirmInitRepo(io.Discard, t.TempDir(), GitHubBootstrapOptions{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !proceed { - t.Fatal("confirmInitRepo should proceed when the user explicitly answers yes") - } -} - -func TestPromptBootstrapSetupChoice_DefaultsToLocalInitialCommit(t *testing.T) { - withInteractivePromptStdin(t, "\n") - - var out bytes.Buffer - choice, err := promptBootstrapSetupChoice(&out, "/tmp/example", true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if choice != bootstrapSetupLocal { - t.Fatalf("choice = %q, want %q", choice, bootstrapSetupLocal) - } - if !strings.Contains(out.String(), "Set one up?") { - t.Fatalf("expected merged init+setup prompt, got: %s", out.String()) - } - // The wrong-directory guard: the prompt must show where the repo would - // be created (issue #1717's concern, carried over from the confirm). - if !strings.Contains(out.String(), "/tmp/example") { - t.Fatalf("expected prompt to show the target directory, got: %s", out.String()) - } -} - -func TestPromptBootstrapSetupChoice_SelectsGitHubPreset(t *testing.T) { - withInteractivePromptStdin(t, "2\n") - - choice, err := promptBootstrapSetupChoice(io.Discard, "/tmp/example", true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if choice != bootstrapSetupGitHub { - t.Fatalf("choice = %q, want %q", choice, bootstrapSetupGitHub) - } -} - -func TestPromptBootstrapSetupChoice_WithoutGitHubOffersCustomizeSecond(t *testing.T) { - withInteractivePromptStdin(t, "2\n") - - choice, err := promptBootstrapSetupChoice(io.Discard, "/tmp/example", false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if choice != bootstrapSetupCustom { - t.Fatalf("choice = %q, want %q", choice, bootstrapSetupCustom) - } -} - -func TestPromptBootstrapSetupChoice_OffersDecline(t *testing.T) { - withInteractivePromptStdin(t, "4\n") - - choice, err := promptBootstrapSetupChoice(io.Discard, "/tmp/example", true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if choice != bootstrapSetupDecline { - t.Fatalf("choice = %q, want %q", choice, bootstrapSetupDecline) - } -} - -func TestRunGitHubBootstrapInit_InteractiveLocalPresetUsesOneSetupAnswer(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - withInteractivePromptStdin(t, "\n") - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("git", []string{"init"}, "", nil) - r.set("gh", []string{"--version"}, "gh 2.81.0", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - - var out bytes.Buffer - state, err := runGitHubBootstrapInitWith( - context.Background(), &out, io.Discard, - GitHubBootstrapOptions{}, r, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if state.useGitHub { - t.Fatal("local preset should not create a GitHub repository") - } - if !state.commit || state.message != defaultInitialCommitMessage { - t.Fatalf("local preset commit = %v, message = %q", state.commit, state.message) - } - if state.push { - t.Fatal("local preset should not push") - } - if !strings.Contains(out.String(), "Set one up?") { - t.Fatalf("expected merged init+setup prompt, got: %s", out.String()) - } -} - -func TestRunGitHubBootstrapInit_InteractiveGitHubPresetUsesOneSetupAnswer(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - withInteractivePromptStdin(t, "2\n") - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("git", []string{"init"}, "", nil) - r.set("gh", []string{"--version"}, "gh 2.81.0", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - repoName := filepath.Base(dir) - r.set("gh", []string{"repo", "view", "octocat/" + repoName, "--json", "name"}, "", errors.New("not found")) - - state, err := runGitHubBootstrapInitWith( - context.Background(), io.Discard, io.Discard, - GitHubBootstrapOptions{}, r, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !state.useGitHub || state.fullName != "octocat/"+repoName { - t.Fatalf("GitHub preset repository = %q, useGitHub = %v", state.fullName, state.useGitHub) - } - if state.visibility != visibilityPrivate { - t.Fatalf("visibility = %q, want %q", state.visibility, visibilityPrivate) - } - if !state.commit || state.message != defaultInitialCommitMessage { - t.Fatalf("GitHub preset commit = %v, message = %q", state.commit, state.message) - } - if !state.push { - t.Fatal("GitHub preset should push") - } -} - -// TestRunGitHubBootstrapInit_InteractiveDeclineRunsNoGit verifies that -// declining the merged prompt leaves the folder untouched: the select runs -// before `git init`, so "No" must not create a repository. -func TestRunGitHubBootstrapInit_InteractiveDeclineRunsNoGit(t *testing.T) { - dir := t.TempDir() - restoreCwd(t, dir) - // gh is not stubbed: ghAvailable reports false, so the option list is - // local(1) / customize(2) / No(3). - withInteractivePromptStdin(t, "3\n") - - r := newFakeRunner() - _, err := runGitHubBootstrapInitWith( - context.Background(), io.Discard, io.Discard, - GitHubBootstrapOptions{}, r, - ) - if !errors.Is(err, errBootstrapDeclined) { - t.Fatalf("err = %v, want errBootstrapDeclined", err) - } - if r.hasCall(argsMatch("git", []string{"init"})) { - t.Fatal("declining the merged prompt must not run git init") - } -} - -// TestConfirmCreateGitHubRepo_DefaultsToNo verifies that pressing Enter at -// the GitHub-repo prompt declines. Creating and pushing a remote repository -// publishes the directory's contents, so it must never happen just because -// the user pressed Enter. Regression guard for issue #1717. -func TestConfirmCreateGitHubRepo_DefaultsToNo(t *testing.T) { - withInteractivePromptStdin(t, "\n") - - confirmed, err := confirmCreateGitHubRepo(t.TempDir()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if confirmed { - t.Fatal("confirmCreateGitHubRepo should default to No on empty input") - } -} - -// TestConfirmPushToRemote_DefaultsToNo verifies that pressing Enter at the -// push prompt declines. Pushing publishes the directory's contents, so it -// must never happen just because the user pressed Enter, even after they -// opted into creating the repo. Regression guard for issue #1717. -func TestConfirmPushToRemote_DefaultsToNo(t *testing.T) { - withInteractivePromptStdin(t, "\n") - - confirmed, err := confirmPushToRemote("octocat/example") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if confirmed { - t.Fatal("confirmPushToRemote should default to No on empty input") - } -} - -// TestRunGitHubBootstrapFinalize_HonorsPushFalse verifies that finalize -// respects state.push == false: the GitHub repo is still created and origin -// configured, but nothing is pushed and the user is told how to publish -// manually. The push *decision* (default No on Enter) is covered separately -// by TestConfirmPushToRemote_DefaultsToNo; this test covers finalize honoring -// that decision. -func TestRunGitHubBootstrapFinalize_HonorsPushFalse(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - r := newFakeRunner() - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Seed"}, "", nil) - r.set("gh", []string{ - "repo", "create", "octocat/no-push", - "--private", - "--source=.", - "--remote=origin", - }, "", nil) - - s := &bootstrapState{ - runner: r, - cwd: dir, - useGitHub: true, - fullName: "octocat/no-push", - visibility: "private", - commit: true, - message: "Seed", - push: false, - } - - var out bytes.Buffer - if err := runGitHubBootstrapFinalize(context.Background(), &out, s); err != nil { - t.Fatalf("finalize failed: %v", err) - } - - // The repo is still created (create guard was accepted)... - if !r.hasCall(argsMatch("gh", []string{"repo", "create"})) { - t.Fatal("expected gh repo create to run") - } - // ...but the push guard was declined, so nothing is pushed. - if r.hasCall(argsMatch("git", []string{"push"})) { - t.Fatal("git push must not run when the push guard was declined") - } - if !strings.Contains(out.String(), "Skipped push") { - t.Fatalf("expected 'Skipped push' guidance in output, got: %s", out.String()) - } -} - -// restoreCwd chdirs into dir for the duration of the test. -func restoreCwd(t *testing.T, dir string) { - t.Helper() - // macOS resolves /tmp → /private/tmp; canonicalize for safety. - canon, err := filepath.EvalSymlinks(dir) - if err != nil { - canon = dir - } - t.Chdir(canon) -} - -func TestRunGitHubBootstrap_YesAcceptsAllDefaults(t *testing.T) { - // --yes should init repo, create GitHub repo under user's account (private), - // and use default commit message — without any interactive prompts. - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("gh", []string{"--version"}, "gh 2.81.0", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "myuser\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "myorg\n", nil) - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", defaultInitialCommitMessage}, "", nil) - - // Expect repo created under the user's account (not org), private - repoName := filepath.Base(dir) - fullName := "myuser/" + repoName - r.set("gh", []string{ - "repo", "create", fullName, - "--private", - "--source=.", - "--remote=origin", - }, "", nil) - r.set("git", []string{"push", "-q", "--no-verify", "-u", "origin", "HEAD"}, "", nil) - - opts := GitHubBootstrapOptions{Yes: true} - var stdout bytes.Buffer - err := runGitHubBootstrapWith(context.Background(), &stdout, io.Discard, opts, r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should have used user's account, not org - output := stdout.String() - if !strings.Contains(output, "Using GitHub owner: myuser") { - t.Errorf("expected owner to be user's account, got: %s", output) - } - // Should have committed with default message - if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", defaultInitialCommitMessage})) { - t.Error("expected commit with default 'Initial commit' message") - } - // Should have created the repo - if !r.hasCall(func(c fakeCall) bool { - return c.name == "gh" && len(c.args) > 3 && c.args[0] == ghSubcmdRepo && c.args[1] == ghActCreate - }) { - t.Error("expected gh repo create call") - } -} - -func TestRunGitHubBootstrap_YesRepoExistsNoTTY_Fails(t *testing.T) { - // When --yes is set, the repo name is taken, and there's no TTY, - // we should get a clear error instead of a silent gh failure. - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("gh", []string{"--version"}, "gh 2.81.0", nil) - r.set("gh", []string{"auth", "status"}, "Logged in", nil) - r.set("gh", []string{"api", "user", "--jq", ".login"}, "myuser\n", nil) - r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil) - r.set("git", []string{"init"}, "", nil) - - // The suggested repo name already exists. - repoName := filepath.Base(dir) - r.set("gh", []string{"repo", "view", "myuser/" + repoName, "--json", "name"}, `{"name":"`+repoName+`"}`, nil) - - opts := GitHubBootstrapOptions{Yes: true} - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r) - if err == nil { - t.Fatal("expected error when repo name exists and no TTY") - } - if !strings.Contains(err.Error(), "already exists") { - t.Errorf("expected 'already exists' in error, got: %v", err) - } -} - -func TestResolveRepoName_YesRepoExistsWithTTY_FallsBackToPrompt(t *testing.T) { - // When --yes is set, the name is taken, and a TTY is available, - // resolveRepoName should print a conflict message and fall through - // to the interactive prompt. We verify the conflict message was - // printed (proving the fallback path was taken). Pipe a unique name so - // the form completes with it instead of blocking. - withInteractivePromptStdin(t, "unique-test-repo\n") - - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - repoName := filepath.Base(dir) - // The suggested name exists. - r.set("gh", []string{"repo", "view", "myuser/" + repoName, "--json", "name"}, `{"name":"`+repoName+`"}`, nil) - // The unique name typed at the prompt does not exist (fakeRunner returns - // an error for unknown calls, which ghRepoExists treats as "proceed"). - - var stdout bytes.Buffer - opts := GitHubBootstrapOptions{Yes: true} - name, err := resolveRepoName(context.Background(), &stdout, io.Discard, r, "myuser", dir, opts) - - output := stdout.String() - if !strings.Contains(output, "already exists on GitHub") { - t.Errorf("expected conflict message in output, got: %s", output) - } - // The form should complete with the unique name (fakeRunner can't verify - // the name, so resolveRepoName proceeds with a warning). - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if name != "unique-test-repo" { - t.Errorf("expected name %q, got %q", "unique-test-repo", name) - } -} - -func TestRunGitHubBootstrap_YesWithNoGitHub(t *testing.T) { - // --yes combined with --no-github should skip GitHub but still init + commit. - dir := t.TempDir() - restoreCwd(t, dir) - - r := newFakeRunner() - r.setIdentityConfigured() - r.set("git", []string{"init"}, "", nil) - r.set("git", []string{"add", "-A"}, "", nil) - r.set("git", []string{"--no-optional-locks", "status", "--porcelain"}, " M f\n", nil) - r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", defaultInitialCommitMessage}, "", nil) - - opts := GitHubBootstrapOptions{Yes: true, NoGitHub: true} - err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should NOT have called gh at all - if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) { - t.Error("expected no gh calls with --no-github") - } - // Should have committed - if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", defaultInitialCommitMessage})) { - t.Error("expected commit with default message") - } -} diff --git a/cmd/entire/cli/setup_identity.go b/cmd/entire/cli/setup_identity.go new file mode 100644 index 0000000000..a86026f01e --- /dev/null +++ b/cmd/entire/cli/setup_identity.go @@ -0,0 +1,283 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/entireio/auth-go/tokenmanager" + "github.com/entireio/cli/cmd/entire/cli/api" + cliauth "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/internal/entireclient/contexts" +) + +var ( + errEntireLoginRequired = errors.New("entire login required") + errEntireEnvTokenRejected = errors.New("entire token rejected") +) + +type identityGuidanceError string + +func (e identityGuidanceError) Error() string { return string(e) } + +// gitConfigIdentityGuidance names the fix that needs no Entire account at all. +// Every guidance string below offers it: the underlying problem is an unset +// local git config value, and a caller who cannot authenticate here can still +// set it directly. The implementation this replaced printed exactly this pair +// on its non-interactive path, and dropping it would leave a failed CI run +// telling the user to authenticate in order to set two local config values. +const gitConfigIdentityGuidance = "Or set the identity directly:\n" + + " git config --global user.name \"Your Name\"\n" + + " git config --global user.email \"you@example.com\"" + +const unattendedIdentityGuidance = "Git identity is missing, and Entire authentication is required.\n" + + "This environment has no interactive terminal, so sign-in cannot complete here.\n" + + "Run `entire login` in an interactive shell, then rerun `entire enable`.\n" + + "For unattended use, provide a valid user token in ENTIRE_TOKEN.\n" + + gitConfigIdentityGuidance + +const envTokenIdentityGuidance = "ENTIRE_TOKEN could not authenticate an Entire user profile.\n" + + "ENTIRE_TOKEN overrides stored logins, so automatic sign-in cannot repair this session.\n" + + "Fix or unset ENTIRE_TOKEN, then rerun `entire enable`.\n" + + gitConfigIdentityGuidance + +type gitIdentityResolver func(context.Context) (*authProfile, error) +type identityResolverFactory func(io.Writer, io.Writer, bool) gitIdentityResolver + +// activeContextProvider resolves the acting login context. It is the seam for +// cliauth.ActiveContext, which already applies the `--context`/$ENTIRE_CONTEXT +// selection and rejects a context carrying no CoreURL — so this path neither +// re-finds the active context by name nor re-derives that guard. +type activeContextProvider func() (*contexts.Context, bool, error) + +type identityProfileDependencies struct { + lookupEnv func(string) (string, bool) + activeContext activeContextProvider + resolveLogin loginTokenResolver + fetchProfile profileFetcher + allowInsecure bool +} + +type identityProfileResult struct { + profile *authProfile + loginServer string +} + +type identityRecoveryDependencies struct { + resolve func(context.Context) (identityProfileResult, error) + login func(context.Context, io.Writer, io.Writer, string, bool) error + canPrompt func() bool +} + +func defaultIdentityProfileDependencies(insecure bool) identityProfileDependencies { + return identityProfileDependencies{ + lookupEnv: os.LookupEnv, + activeContext: cliauth.ActiveContext, + resolveLogin: cliauth.RefreshedLoginToken, + fetchProfile: defaultFetchProfile, + allowInsecure: insecure, + } +} + +func defaultIdentityRecoveryDependencies(insecure bool) identityRecoveryDependencies { + profileDeps := defaultIdentityProfileDependencies(insecure) + return identityRecoveryDependencies{ + resolve: func(ctx context.Context) (identityProfileResult, error) { + return resolveEntireIdentityProfile(ctx, profileDeps) + }, + login: func(ctx context.Context, outW, errW io.Writer, server string, insecure bool) error { + return runLoginCommand(ctx, outW, errW, server, insecure, false) + }, + canPrompt: interactive.CanPromptInteractively, + } +} + +func newEntireGitIdentityResolver(outW, errW io.Writer, insecure bool) gitIdentityResolver { + deps := defaultIdentityRecoveryDependencies(insecure) + return func(ctx context.Context) (*authProfile, error) { + applyInsecureHTTPAuth(insecure) + return recoverGitIdentity(ctx, outW, errW, insecure, deps) + } +} + +func gitIdentityFromEntireProfile(profile *authProfile, existingName, existingEmail string) (string, string, error) { + if profile == nil { + return "", "", errors.New("entire profile does not contain a verified Git name and email") + } + + name := strings.TrimSpace(existingName) + handle := strings.TrimSpace(profile.Handle) + if name == "" { + name = strings.TrimSpace(profile.DisplayName) + if name == "" { + name = handle + } + } + + email := strings.TrimSpace(existingEmail) + provider := strings.TrimSpace(profile.Provider) + providerUserID := strings.TrimSpace(profile.ProviderUserID) + if email == "" { + email = strings.TrimSpace(profile.Email) + if email == "" && provider == "github" && providerUserID != "" && handle != "" { + email = fmt.Sprintf("%s+%s@users.noreply.github.com", providerUserID, handle) + } + } + + if name == "" || email == "" { + return "", "", errors.New("entire profile does not contain a verified Git name and email") + } + return name, email, nil +} + +func ensureGitIdentity( + ctx context.Context, + w io.Writer, + runner bootstrapRunner, + dir string, + resolve gitIdentityResolver, +) error { + existingName, nameSet, err := readGitIdentityField(ctx, runner, dir, "user.name") + if err != nil { + return err + } + existingEmail, emailSet, err := readGitIdentityField(ctx, runner, dir, "user.email") + if err != nil { + return err + } + if nameSet && emailSet { + return nil + } + + profile, err := resolve(ctx) + if err != nil { + return err + } + name, email, err := gitIdentityFromEntireProfile(profile, existingName, existingEmail) + if err != nil { + return err + } + + if !nameSet { + if _, err := runner.RunInDir(ctx, dir, "git", "config", "user.name", name); err != nil { + return wrapExecError("git config user.name", err) + } + } + if !emailSet { + if _, err := runner.RunInDir(ctx, dir, "git", "config", "user.email", email); err != nil { + return wrapExecError("git config user.email", err) + } + } + fmt.Fprintf(w, " Using git identity: %s <%s>\n", name, email) + return nil +} + +func readGitIdentityField(ctx context.Context, runner bootstrapRunner, dir, key string) (string, bool, error) { + out, err := runner.RunInDir(ctx, dir, "git", "config", "--get", key) + if err != nil { + var exitErr interface{ ExitCode() int } + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return "", false, nil + } + return "", false, wrapExecError("read git config "+key, err) + } + value := strings.TrimSpace(out) + return value, value != "", nil +} + +func resolveEntireIdentityProfile(ctx context.Context, deps identityProfileDependencies) (identityProfileResult, error) { + if raw, ok := deps.lookupEnv(cliauth.EnvTokenVar); ok { + target, err := resolveEnvTokenStatusTarget(raw) + if err != nil { + return identityProfileResult{}, fmt.Errorf("%w: %w", errEntireEnvTokenRejected, err) + } + profile, err := deps.fetchProfile(ctx, target.coreURL, target.token) + if err != nil { + if isKeychainTokenRejected(err) { + return identityProfileResult{loginServer: target.coreURL}, fmt.Errorf("%w: %w", errEntireEnvTokenRejected, err) + } + return identityProfileResult{loginServer: target.coreURL}, err + } + return identityProfileResult{profile: profile, loginServer: target.coreURL}, nil + } + + active, ok, err := deps.activeContext() + if err != nil { + return identityProfileResult{}, err + } + result := identityProfileResult{loginServer: api.DefaultAuthBaseURL} + if !ok { + return result, errEntireLoginRequired + } + result.loginServer = active.CoreURL + if !deps.allowInsecure { + if err := api.RequireSecureURL(active.CoreURL); err != nil { + return result, fmt.Errorf("context login server URL check: %w", err) + } + } + token, err := deps.resolveLogin(ctx, active) + if err != nil { + if errors.Is(err, cliauth.ErrNotLoggedIn) || errors.Is(err, tokenmanager.ErrReauthRequired) { + return result, fmt.Errorf("%w: %w", errEntireLoginRequired, err) + } + return result, err + } + if strings.TrimSpace(token) == "" { + return result, errEntireLoginRequired + } + profile, err := deps.fetchProfile(ctx, active.CoreURL, token) + if err != nil { + if isKeychainTokenRejected(err) { + return result, fmt.Errorf("%w: %w", errEntireLoginRequired, err) + } + return result, err + } + result.profile = profile + return result, nil +} + +func recoverGitIdentity( + ctx context.Context, + outW, errW io.Writer, + insecure bool, + deps identityRecoveryDependencies, +) (*authProfile, error) { + result, err := deps.resolve(ctx) + if err == nil { + return result.profile, nil + } + if errors.Is(err, errEntireEnvTokenRejected) { + return nil, identityGuidanceError(envTokenIdentityGuidance) + } + if !errors.Is(err, errEntireLoginRequired) { + return nil, err + } + // Refuse rather than start a login nobody can finish. The gate is "can a + // human answer here", not "is this known to be unattended": IsKnownUnattended + // is deliberately permissive (CLAUDECODE is not on its list, and Codex sets + // none of the names on it), so using it here let every agent subprocess and + // every headless non-CI context — a `docker build` RUN step, say — fall + // through to deps.login. With no terminal that takes the device-code flow, + // which prints a code and then blocks in waitForApproval for up to + // maxExpiresIn (15 minutes) on nothing. `entire enable` must not turn into + // that; a headless human can run `entire login` deliberately, which is what + // the guidance says. + if !deps.canPrompt() { + return nil, identityGuidanceError(unattendedIdentityGuidance) + } + if err := deps.login(ctx, outW, errW, result.loginServer, insecure); err != nil { + return nil, err + } + result, err = deps.resolve(ctx) + if err != nil { + if errors.Is(err, errEntireEnvTokenRejected) { + return nil, identityGuidanceError(envTokenIdentityGuidance) + } + return nil, fmt.Errorf("resolve Entire profile after login: %w", err) + } + return result.profile, nil +} diff --git a/cmd/entire/cli/setup_identity_test.go b/cmd/entire/cli/setup_identity_test.go new file mode 100644 index 0000000000..b0a7812fa2 --- /dev/null +++ b/cmd/entire/cli/setup_identity_test.go @@ -0,0 +1,657 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + cliapi "github.com/entireio/cli/cmd/entire/cli/api" + cliauth "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/internal/entireclient/contexts" +) + +type testExitError struct{ code int } + +func (e testExitError) Error() string { return fmt.Sprintf("exit status %d", e.code) } +func (e testExitError) ExitCode() int { return e.code } + +func TestGitIdentityFromEntireProfile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + profile authProfile + wantName string + wantEmail string + wantErrText string + }{ + { + name: "profile email", + profile: authProfile{ + DisplayName: " Octo Cat ", + Handle: "octo", + Email: " octo@example.com ", + Provider: "github", + ProviderUserID: "42", + }, + wantName: "Octo Cat", + wantEmail: "octo@example.com", + }, + { + name: "github private email from sparse foreign-region profile", + profile: authProfile{ + Handle: " octo ", + Provider: " github ", + ProviderUserID: " 42 ", + ForeignRegion: true, + }, + wantName: "octo", + wantEmail: "42+octo@users.noreply.github.com", + }, + { + name: "insufficient verified profile", + profile: authProfile{Provider: "github", ProviderUserID: "42"}, + wantErrText: "entire profile does not contain", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + name, email, err := gitIdentityFromEntireProfile(&tt.profile, "", "") + if tt.wantErrText != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErrText) { + t.Fatalf("error = %v, want text %q", err, tt.wantErrText) + } + return + } + if err != nil { + t.Fatalf("gitIdentityFromEntireProfile: %v", err) + } + if name != tt.wantName || email != tt.wantEmail { + t.Fatalf("identity = %q <%s>, want %q <%s>", name, email, tt.wantName, tt.wantEmail) + } + }) + } +} + +func TestEnsureGitIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configuredName string + configuredEmail string + nameReadErr error + emailReadErr error + emailWriteErr error + profile *authProfile + wantResolverCall bool + wantNameWrite bool + wantEmailWrite bool + wantErrText string + }{ + { + name: "complete identity is a local no-op", + configuredName: "Existing User", + configuredEmail: "existing@example.com", + }, + { + name: "both fields missing", + nameReadErr: testExitError{code: 1}, + emailReadErr: testExitError{code: 1}, + wantResolverCall: true, + wantNameWrite: true, + wantEmailWrite: true, + }, + { + name: "preserves configured name", + configuredName: "Existing User", + emailReadErr: testExitError{code: 1}, + wantResolverCall: true, + wantEmailWrite: true, + }, + { + name: "preserves configured email", + configuredEmail: "existing@example.com", + nameReadErr: testExitError{code: 1}, + wantResolverCall: true, + wantNameWrite: true, + }, + { + name: "operational read failure does not authenticate", + nameReadErr: testExitError{code: 2}, + wantErrText: "read git config user.name", + }, + { + name: "second write failure preserves the first local write", + nameReadErr: testExitError{code: 1}, + emailReadErr: testExitError{code: 1}, + emailWriteErr: errors.New("config locked"), + wantResolverCall: true, + wantNameWrite: true, + wantEmailWrite: true, + wantErrText: "git config user.email", + }, + { + name: "configured email only requires a profile name", + configuredEmail: "existing@example.com", + nameReadErr: testExitError{code: 1}, + profile: &authProfile{DisplayName: "Entire User"}, + wantResolverCall: true, + wantNameWrite: true, + }, + { + name: "configured name only requires a profile email", + configuredName: "Existing User", + emailReadErr: testExitError{code: 1}, + profile: &authProfile{Email: "entire@example.com"}, + wantResolverCall: true, + wantEmailWrite: true, + }, + { + name: "incomplete verified profile writes nothing", + nameReadErr: testExitError{code: 1}, + emailReadErr: testExitError{code: 1}, + profile: &authProfile{Handle: "entire-user"}, + wantResolverCall: true, + wantErrText: "entire profile does not contain", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runner := newFakeRunner() + runner.set("git", []string{"config", "--get", "user.name"}, tt.configuredName, tt.nameReadErr) + runner.set("git", []string{"config", "--get", "user.email"}, tt.configuredEmail, tt.emailReadErr) + if tt.wantNameWrite { + runner.set("git", []string{"config", "user.name", "Entire User"}, "", nil) + } + if tt.wantEmailWrite { + runner.set("git", []string{"config", "user.email", "entire@example.com"}, "", tt.emailWriteErr) + } + + resolverCalls := 0 + resolve := func(context.Context) (*authProfile, error) { + resolverCalls++ + if tt.profile != nil { + return tt.profile, nil + } + return &authProfile{DisplayName: "Entire User", Email: "entire@example.com"}, nil + } + err := ensureGitIdentity(t.Context(), io.Discard, runner, t.TempDir(), resolve) + if tt.wantErrText != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErrText) { + t.Fatalf("error = %v, want text %q", err, tt.wantErrText) + } + } else if err != nil { + t.Fatalf("ensureGitIdentity: %v", err) + } + if got := resolverCalls; got != boolInt(tt.wantResolverCall) { + t.Fatalf("resolver calls = %d, want %d", got, boolInt(tt.wantResolverCall)) + } + if got := runner.hasCall(argsMatch("git", []string{"config", "user.name", "Entire User"})); got != tt.wantNameWrite { + t.Errorf("user.name write = %v, want %v", got, tt.wantNameWrite) + } + if got := runner.hasCall(argsMatch("git", []string{"config", "user.email", "entire@example.com"})); got != tt.wantEmailWrite { + t.Errorf("user.email write = %v, want %v", got, tt.wantEmailWrite) + } + }) + } +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func TestResolveEntireIdentityProfile(t *testing.T) { + t.Parallel() + + profile := &authProfile{DisplayName: "Entire User", Email: "entire@example.com"} + ctxEntry := &contexts.Context{Name: "work", CoreURL: "https://core.example.test"} + + t.Run("stored context", func(t *testing.T) { + t.Parallel() + deps := identityProfileDependencies{ + lookupEnv: func(string) (string, bool) { return "", false }, + activeContext: func() (*contexts.Context, bool, error) { return ctxEntry, true, nil }, + resolveLogin: func(context.Context, *contexts.Context) (string, error) { return "stored-token", nil }, + fetchProfile: func(_ context.Context, coreURL, token string) (*authProfile, error) { + if coreURL != ctxEntry.CoreURL || token != "stored-token" { + t.Fatalf("profile target = %q token %q", coreURL, token) + } + return profile, nil + }, + } + got, err := resolveEntireIdentityProfile(t.Context(), deps) + if err != nil || got.profile != profile || got.loginServer != ctxEntry.CoreURL { + t.Fatalf("result = %+v, error = %v", got, err) + } + }) + + t.Run("stored insecure context is rejected before profile fetch", func(t *testing.T) { + t.Parallel() + insecureContext := &contexts.Context{Name: "local", CoreURL: "http://127.0.0.1:8787"} + fetchCalls := 0 + deps := identityProfileDependencies{ + lookupEnv: func(string) (string, bool) { return "", false }, + activeContext: func() (*contexts.Context, bool, error) { return insecureContext, true, nil }, + resolveLogin: func(context.Context, *contexts.Context) (string, error) { return "stored-token", nil }, + fetchProfile: func(context.Context, string, string) (*authProfile, error) { + fetchCalls++ + return profile, nil + }, + } + _, err := resolveEntireIdentityProfile(t.Context(), deps) + if !errors.Is(err, cliapi.ErrInsecureHTTP) { + t.Fatalf("error = %v, want ErrInsecureHTTP", err) + } + if fetchCalls != 0 { + t.Fatalf("profile fetch calls = %d, want 0", fetchCalls) + } + }) + + t.Run("valid env token bypasses stored contexts", func(t *testing.T) { + t.Parallel() + raw := makeJWT(t, `{"alg":"RS256"}`, `{"aud":"https://env-core.example.test"}`) + deps := identityProfileDependencies{ + lookupEnv: func(name string) (string, bool) { + if name != cliauth.EnvTokenVar { + t.Fatalf("lookup %q", name) + } + return raw, true + }, + activeContext: func() (*contexts.Context, bool, error) { + t.Fatal("stored contexts must not be read in ENTIRE_TOKEN mode") + return nil, false, nil + }, + fetchProfile: func(_ context.Context, coreURL, token string) (*authProfile, error) { + if coreURL != "https://env-core.example.test" || token != raw { + t.Fatalf("profile target = %q token %q", coreURL, token) + } + return profile, nil + }, + } + got, err := resolveEntireIdentityProfile(t.Context(), deps) + if err != nil || got.profile != profile { + t.Fatalf("result = %+v, error = %v", got, err) + } + }) + + t.Run("refresh failure remains operational", func(t *testing.T) { + t.Parallel() + refreshErr := errors.New("credential store unavailable") + deps := identityProfileDependencies{ + lookupEnv: func(string) (string, bool) { return "", false }, + activeContext: func() (*contexts.Context, bool, error) { return ctxEntry, true, nil }, + resolveLogin: func(context.Context, *contexts.Context) (string, error) { return "", refreshErr }, + fetchProfile: func(context.Context, string, string) (*authProfile, error) { + t.Fatal("profile must not be fetched after refresh failure") + return nil, errors.New("unexpected profile fetch") + }, + } + _, err := resolveEntireIdentityProfile(t.Context(), deps) + if !errors.Is(err, refreshErr) || errors.Is(err, errEntireLoginRequired) { + t.Fatalf("error = %v, want original operational failure", err) + } + }) + + // cliauth.ActiveContext reports a context carrying no CoreURL as "none + // acting" rather than returning an unusable pointer. Asserted here because + // this path used to find the active context by name itself and had no such + // guard: it would go on to mint a token against an empty core and surface + // the resulting transport error instead of sending the user to log in. + t.Run("active context without a core URL asks for login", func(t *testing.T) { + t.Parallel() + deps := identityProfileDependencies{ + lookupEnv: func(string) (string, bool) { return "", false }, + activeContext: func() (*contexts.Context, bool, error) { return nil, false, nil }, + resolveLogin: func(context.Context, *contexts.Context) (string, error) { + t.Fatal("must not resolve a token without an acting context") + return "", nil + }, + fetchProfile: func(context.Context, string, string) (*authProfile, error) { + t.Fatal("must not fetch a profile without an acting context") + return nil, errors.New("unexpected profile fetch") + }, + } + got, err := resolveEntireIdentityProfile(t.Context(), deps) + if !errors.Is(err, errEntireLoginRequired) { + t.Fatalf("error = %v, want errEntireLoginRequired", err) + } + if got.loginServer != cliapi.DefaultAuthBaseURL { + t.Fatalf("loginServer = %q, want the default so the login prompt names a real server", got.loginServer) + } + }) + + // The env-token branch has no RequireSecureURL call, which reads like a + // missing TLS check next to the stored-context branch that has one. It is + // not: the core URL comes from the token's own aud claim, and + // CoreURLFromEnvToken -> validateCoreAudience hard-rejects any scheme but + // https before this code ever sees it (auth/env_token.go). The stored + // branch needs its own check because contexts.json is user-editable and may + // legitimately hold an http dev core — which is what --insecure-http-auth + // is for. No such escape exists for an env token, at any call site. + // + // Asserted here because the "missing check" reading has been reported + // twice; if validateCoreAudience is ever relaxed, this fails rather than + // the reviewers being right the third time. + t.Run("http env token is refused before the bearer is sent", func(t *testing.T) { + t.Parallel() + raw := makeJWT(t, `{"alg":"RS256"}`, `{"aud":"http://insecure-core.example.test"}`) + fetchCalls := 0 + deps := identityProfileDependencies{ + lookupEnv: func(string) (string, bool) { return raw, true }, + fetchProfile: func(context.Context, string, string) (*authProfile, error) { + fetchCalls++ + return nil, errors.New("must not be reached") + }, + // Even with the insecure opt-in: an http env token is never allowed. + allowInsecure: true, + } + _, err := resolveEntireIdentityProfile(t.Context(), deps) + if !errors.Is(err, errEntireEnvTokenRejected) { + t.Fatalf("error = %v, want errEntireEnvTokenRejected", err) + } + if !strings.Contains(err.Error(), "must use https") { + t.Fatalf("error = %v, want the https refusal from validateCoreAudience", err) + } + if fetchCalls != 0 { + t.Fatalf("profile fetch calls = %d, want 0 — the bearer must never leave", fetchCalls) + } + }) + + t.Run("rejected env token is distinguished from stored login expiry", func(t *testing.T) { + t.Parallel() + raw := makeJWT(t, `{"alg":"RS256"}`, `{"aud":"https://env-core.example.test"}`) + deps := identityProfileDependencies{ + lookupEnv: func(string) (string, bool) { return raw, true }, + fetchProfile: func(context.Context, string, string) (*authProfile, error) { + return nil, &cliapi.HTTPError{StatusCode: 401} + }, + } + _, err := resolveEntireIdentityProfile(t.Context(), deps) + if !errors.Is(err, errEntireEnvTokenRejected) || errors.Is(err, errEntireLoginRequired) { + t.Fatalf("error = %v, want env-token rejection", err) + } + }) +} + +func TestRecoverGitIdentity(t *testing.T) { + t.Parallel() + + // Spelled out rather than referencing the constants: these are the exact + // words a blocked user reads, so an accidental edit should fail a test + // rather than pass one that compares a constant against itself. + const gitConfigTail = "Or set the identity directly:\n" + + " git config --global user.name \"Your Name\"\n" + + " git config --global user.email \"you@example.com\"" + const unattendedGuidance = "Git identity is missing, and Entire authentication is required.\n" + + "This environment has no interactive terminal, so sign-in cannot complete here.\n" + + "Run `entire login` in an interactive shell, then rerun `entire enable`.\n" + + "For unattended use, provide a valid user token in ENTIRE_TOKEN.\n" + + gitConfigTail + const envTokenGuidance = "ENTIRE_TOKEN could not authenticate an Entire user profile.\n" + + "ENTIRE_TOKEN overrides stored logins, so automatic sign-in cannot repair this session.\n" + + "Fix or unset ENTIRE_TOKEN, then rerun `entire enable`.\n" + + gitConfigTail + + t.Run("login once then retry same target", func(t *testing.T) { + t.Parallel() + calls := 0 + loginCalls := 0 + profile := &authProfile{DisplayName: "Entire User", Email: "entire@example.com"} + deps := identityRecoveryDependencies{ + resolve: func(context.Context) (identityProfileResult, error) { + calls++ + if calls == 1 { + return identityProfileResult{loginServer: "https://work.example.test"}, errEntireLoginRequired + } + return identityProfileResult{profile: profile, loginServer: "https://work.example.test"}, nil + }, + login: func(_ context.Context, _, _ io.Writer, server string, insecure bool) error { + loginCalls++ + if server != "https://work.example.test" || !insecure { + t.Fatalf("login target = %q insecure=%v", server, insecure) + } + return nil + }, + canPrompt: func() bool { return true }, + } + got, err := recoverGitIdentity(t.Context(), io.Discard, io.Discard, true, deps) + if err != nil || got != profile || calls != 2 || loginCalls != 1 { + t.Fatalf("profile=%+v err=%v resolve=%d login=%d", got, err, calls, loginCalls) + } + }) + + t.Run("no interactive terminal fails with exact guidance", func(t *testing.T) { + t.Parallel() + deps := identityRecoveryDependencies{ + resolve: func(context.Context) (identityProfileResult, error) { + return identityProfileResult{}, errEntireLoginRequired + }, + login: func(context.Context, io.Writer, io.Writer, string, bool) error { + t.Fatal("login must not run without an interactive terminal") + return nil + }, + canPrompt: func() bool { return false }, + } + _, err := recoverGitIdentity(t.Context(), io.Discard, io.Discard, false, deps) + if err == nil || err.Error() != unattendedGuidance { + t.Fatalf("error = %q, want %q", err, unattendedGuidance) + } + }) + + // Regression: the gate used to be IsKnownUnattended, which is deliberately + // permissive — CLAUDECODE is not on its list and Codex sets none of the + // names on it. Every agent subprocess and every headless non-CI context + // therefore reached deps.login, which with no terminal prints a device code + // and blocks for up to 15 minutes on nobody. + t.Run("agent subprocess with no terminal refuses instead of starting a login", func(t *testing.T) { + t.Parallel() + deps := identityRecoveryDependencies{ + resolve: func(context.Context) (identityProfileResult, error) { + return identityProfileResult{}, errEntireLoginRequired + }, + login: func(context.Context, io.Writer, io.Writer, string, bool) error { + t.Fatal("login must not start where it cannot be completed") + return nil + }, + // What IsKnownUnattended reported for Claude Code and Codex. + canPrompt: func() bool { return false }, + } + _, err := recoverGitIdentity(t.Context(), io.Discard, io.Discard, false, deps) + if err == nil || err.Error() != unattendedGuidance { + t.Fatalf("error = %q, want %q", err, unattendedGuidance) + } + }) + + // The guidance must keep offering the fix that needs no Entire account. + t.Run("guidance names the direct git config fix", func(t *testing.T) { + t.Parallel() + for _, guidance := range []string{unattendedIdentityGuidance, envTokenIdentityGuidance} { + if !strings.Contains(guidance, "git config --global user.name") || + !strings.Contains(guidance, "git config --global user.email") { + t.Errorf("guidance omits the direct git config fix:\n%s", guidance) + } + } + }) + + t.Run("rejected env token fails with exact guidance", func(t *testing.T) { + t.Parallel() + deps := identityRecoveryDependencies{ + resolve: func(context.Context) (identityProfileResult, error) { + return identityProfileResult{}, errEntireEnvTokenRejected + }, + login: func(context.Context, io.Writer, io.Writer, string, bool) error { + t.Fatal("login cannot repair ENTIRE_TOKEN") + return nil + }, + canPrompt: func() bool { return true }, + } + _, err := recoverGitIdentity(t.Context(), io.Discard, io.Discard, false, deps) + if err == nil || err.Error() != envTokenGuidance { + t.Fatalf("error = %q, want %q", err, envTokenGuidance) + } + }) + + t.Run("network error is preserved without login", func(t *testing.T) { + t.Parallel() + networkErr := errors.New("dial core: connection refused") + deps := identityRecoveryDependencies{ + resolve: func(context.Context) (identityProfileResult, error) { return identityProfileResult{}, networkErr }, + login: func(context.Context, io.Writer, io.Writer, string, bool) error { + t.Fatal("network errors must not start login") + return nil + }, + canPrompt: func() bool { return true }, + } + _, err := recoverGitIdentity(t.Context(), io.Discard, io.Discard, false, deps) + if !errors.Is(err, networkErr) { + t.Fatalf("error = %v, want original network error", err) + } + }) + + t.Run("login cancellation is preserved without retry", func(t *testing.T) { + t.Parallel() + loginErr := errors.New("login cancelled") + resolveCalls := 0 + deps := identityRecoveryDependencies{ + resolve: func(context.Context) (identityProfileResult, error) { + resolveCalls++ + return identityProfileResult{loginServer: "https://work.example.test"}, errEntireLoginRequired + }, + login: func(context.Context, io.Writer, io.Writer, string, bool) error { return loginErr }, + canPrompt: func() bool { return true }, + } + _, err := recoverGitIdentity(t.Context(), io.Discard, io.Discard, false, deps) + if !errors.Is(err, loginErr) || resolveCalls != 1 { + t.Fatalf("error = %v, resolve calls = %d; want cancellation and no retry", err, resolveCalls) + } + }) +} + +// argsMatch builds a predicate over recorded fakeRunner calls. It lived in the +// bootstrap test file until that file's git-identity half moved here. +func argsMatch(name string, args []string) func(fakeCall) bool { + return func(c fakeCall) bool { + if c.name != name || len(c.args) < len(args) { + return false + } + for i, a := range args { + if c.args[i] != a { + return false + } + } + return true + } +} + +// The identity preflight has to cover the setup flow, not just `entire enable`: +// bare `entire` (root.go) and `entire agent` (runAgentMenu) both reach it for +// the same "existing repo, not set up yet" case, install hooks and settings, +// and would otherwise leave commits attributed to an unknown author. +func TestRunSetupFlow_PreflightRunsBeforeAnyWrite(t *testing.T) { + repoDir := setupTestRepo(t) + clearLocalGitIdentity(t, repoDir) + + preflightErr := errors.New("identity unavailable") + called := 0 + err := runSetupFlowWithPreflight(t.Context(), io.Discard, EnableOptions{}, func() error { + called++ + return preflightErr + }) + if !errors.Is(err, preflightErr) { + t.Fatalf("error = %v, want the preflight's error", err) + } + if called != 1 { + t.Fatalf("preflight calls = %d, want 1", called) + } + // A failed preflight must leave the repo untouched — same guarantee + // TestEnableCmd_IdentityFailureLeavesSetupAbsent makes for enable. + if _, statErr := os.Stat(filepath.Join(repoDir, ".entire")); !os.IsNotExist(statErr) { + t.Fatalf(".entire exists after a failed preflight (stat err = %v)", statErr) + } +} + +// The dependency structs above are injected in every other test in this file, +// which means nothing here exercises what production actually wires into them. +// That gap is not theoretical: reverting canPrompt to the old +// IsKnownUnattended-based gate — reintroducing the headless device-login hang — +// left the entire identity and enable test surface passing. +// +// Compare by function pointer rather than behaviour: the point is to pin which +// function is wired, and a behavioural check would pass for any function that +// happens to agree in the test environment (which IsKnownUnattended does). +func TestDefaultIdentityDependencies_WireTheRealFunctions(t *testing.T) { + t.Parallel() + + recovery := defaultIdentityRecoveryDependencies(false) + if got, want := funcPointer(recovery.canPrompt), funcPointer(interactive.CanPromptInteractively); got != want { + t.Errorf("canPrompt is not interactive.CanPromptInteractively; a login must never start where it cannot be completed") + } + + profile := defaultIdentityProfileDependencies(false) + if got, want := funcPointer(profile.activeContext), funcPointer(cliauth.ActiveContext); got != want { + t.Errorf("activeContext is not cliauth.ActiveContext; the CoreURL guard lives there") + } + if got, want := funcPointer(profile.resolveLogin), funcPointer(cliauth.RefreshedLoginToken); got != want { + t.Errorf("resolveLogin is not cliauth.RefreshedLoginToken") + } +} + +func funcPointer(fn any) uintptr { + return reflect.ValueOf(fn).Pointer() +} + +// runSetupFlow must pass a real preflight, not nil. Injecting one (as +// TestRunSetupFlow_PreflightRunsBeforeAnyWrite does) cannot catch a +// runSetupFlow that stopped supplying it, which would silently restore the +// unknown-author bug for bare `entire` and `entire agent`. +// +// Drives the default wiring end to end without a network: under `go test` +// CanPromptInteractively is false, so recoverGitIdentity refuses with the +// guidance before any profile fetch or login is attempted. +func TestRunSetupFlow_UsesTheRealPreflight(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + repoDir := setupTestRepo(t) + clearLocalGitIdentity(t, repoDir) + + err := runSetupFlow(t.Context(), io.Discard, EnableOptions{}) + if err == nil { + t.Fatal("runSetupFlow succeeded with no git identity; the preflight did not run") + } + if !strings.Contains(err.Error(), "git config --global user.name") { + t.Fatalf("error = %v, want the identity guidance", err) + } + if _, statErr := os.Stat(filepath.Join(repoDir, ".entire")); !os.IsNotExist(statErr) { + t.Errorf(".entire exists after a refused preflight (stat err = %v)", statErr) + } +} + +// A configured identity must not reach the resolver at all — this is what keeps +// the preflight free on the overwhelmingly common path. +func TestDefaultIdentityPreflight_NoOpWhenIdentityConfigured(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + repoDir := setupTestRepo(t) + testutil.RunGit(t, repoDir, "config", "--local", "user.name", "Configured User") + testutil.RunGit(t, repoDir, "config", "--local", "user.email", "configured@example.com") + + if err := defaultIdentityPreflight(t.Context(), io.Discard)(); err != nil { + t.Fatalf("preflight with a configured identity = %v, want nil", err) + } +} diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 4c51715678..a51cff4fa2 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -144,6 +145,28 @@ func copyExecutable(src, dst string) error { return os.WriteFile(dst, data, info.Mode()) } +func clearLocalGitIdentity(t *testing.T, repoDir string) { + t.Helper() + testutil.RunGit(t, repoDir, "config", "--local", "--unset-all", "user.name") + testutil.RunGit(t, repoDir, "config", "--local", "--unset-all", "user.email") +} + +func localGitConfig(t *testing.T, repoDir, key string) (string, bool) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", "config", "--local", "--get", key) + cmd.Dir = repoDir + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)), true + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return "", false + } + t.Fatalf("read local git config %s: %v", key, err) + return "", false +} + func writeExternalAgentBinary(t *testing.T, dir, name string) { t.Helper() writeExternalAgentBinaryEx(t, dir, name, false) @@ -2455,6 +2478,174 @@ func TestEnableCmd_AgentFlagEmptyValue(t *testing.T) { } } +func TestEnableCmd_ExistingRepoRepairsGitIdentityFromEntire(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + repoDir := setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + clearLocalGitIdentity(t, repoDir) + + resolveCalls := 0 + cmd := newEnableCmdWithIdentityResolverFactory(func(io.Writer, io.Writer, bool) gitIdentityResolver { + return func(context.Context) (*authProfile, error) { + resolveCalls++ + return &authProfile{DisplayName: "Octo Cat", Handle: "octo", Provider: "github", ProviderUserID: "42"}, nil + } + }) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + if err := cmd.Execute(); err != nil { + t.Fatalf("enable existing repo: %v", err) + } + if resolveCalls != 1 { + t.Fatalf("profile resolver calls = %d, want 1", resolveCalls) + } + if got, ok := localGitConfig(t, repoDir, "user.name"); !ok || got != "Octo Cat" { + t.Fatalf("local user.name = %q, configured %v", got, ok) + } + if got, ok := localGitConfig(t, repoDir, "user.email"); !ok || got != "42+octo@users.noreply.github.com" { + t.Fatalf("local user.email = %q, configured %v", got, ok) + } +} + +func TestEnableCmd_IdentityPreflightOrdering(t *testing.T) { + tests := []struct { + name string + newRepo bool + args []string + wantResolve int + wantErrText string + }{ + {name: "invalid agent fails before identity", args: []string{"--agent", "definitely-not-an-agent"}, wantErrText: "wrong agent name"}, + { + name: "new repo skip initial commit does not need identity", + newRepo: true, + args: []string{"--init-repo", "--skip-initial-commit", "--agent", "claude-code"}, + wantResolve: 0, + }, + {name: "existing repo validates agent then resolves identity", args: []string{"--agent", "claude-code"}, wantResolve: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + if tt.newRepo { + setupTestDir(t) + } else { + repoDir := setupTestRepo(t) + clearLocalGitIdentity(t, repoDir) + } + resolveCalls := 0 + cmd := newEnableCmdWithIdentityResolverFactory(func(io.Writer, io.Writer, bool) gitIdentityResolver { + return func(context.Context) (*authProfile, error) { + resolveCalls++ + return &authProfile{DisplayName: "Entire User", Email: "entire@example.com"}, nil + } + }) + var stderr bytes.Buffer + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&stderr) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantErrText != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErrText) { + t.Fatalf("error = %v, stderr = %q, want %q", err, stderr.String(), tt.wantErrText) + } + } else if err != nil { + t.Fatalf("enable: %v; stderr=%s", err, stderr.String()) + } + if resolveCalls != tt.wantResolve { + t.Fatalf("profile resolver calls = %d, want %d", resolveCalls, tt.wantResolve) + } + }) + } +} + +func TestEnableCmd_IdentityFailureLeavesSetupAbsent(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + repoDir := setupTestRepo(t) + clearLocalGitIdentity(t, repoDir) + + cmd := newEnableCmdWithIdentityResolverFactory(func(io.Writer, io.Writer, bool) gitIdentityResolver { + return func(context.Context) (*authProfile, error) { return nil, errors.New("profile unavailable") } + }) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--agent", "claude-code"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "profile unavailable") { + t.Fatalf("error = %v, want profile failure", err) + } + for _, path := range []string{ + EntireSettingsFile, + EntireSettingsLocalFile, + filepath.Join(paths.EntireDir, "logs"), + filepath.Join(".claude", "settings.json"), + } { + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Errorf("setup artifact %s exists or could not be checked: %v", path, statErr) + } + } +} + +func TestRunManageAgents_PreflightFollowsSelection(t *testing.T) { + setupTestRepo(t) + events := make([]string, 0, 2) + selectFn := func(available []string) ([]string, error) { + events = append(events, "select") + if len(available) == 0 { + return nil, errors.New("no available agents") + } + return []string{available[0]}, nil + } + preflight := func() error { + events = append(events, "identity") + return errors.New("stop before apply") + } + err := runManageAgentsWithPreflight(t.Context(), io.Discard, EnableOptions{}, selectFn, preflight) + if err == nil || !strings.Contains(err.Error(), "stop before apply") { + t.Fatalf("error = %v, want preflight error", err) + } + if got := strings.Join(events, " -> "); got != "select -> identity" { + t.Fatalf("events = %q, want selection before identity", got) + } +} + +func TestEnableCmd_IdentityFailurePreservesConfiguredSettings(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "direct settings flow", args: []string{"--checkpoint-backend", "git-refs"}}, + {name: "noninteractive agent-management fallback", args: []string{"--telemetry=false"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + repoDir := setupTestRepo(t) + clearLocalGitIdentity(t, repoDir) + original := `{"enabled":true,"strategy":"manual-commit","strategy_options":{"push_sessions":true}}` + writeSettings(t, original) + + cmd := newEnableCmdWithIdentityResolverFactory(func(io.Writer, io.Writer, bool) gitIdentityResolver { + return func(context.Context) (*authProfile, error) { return nil, errors.New("profile unavailable") } + }) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(tt.args) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "profile unavailable") { + t.Fatalf("error = %v, want profile failure", err) + } + raw, err := os.ReadFile(EntireSettingsFile) + if err != nil { + t.Fatalf("read settings: %v", err) + } + if string(raw) != original { + t.Fatalf("settings changed on identity failure:\n got: %s\nwant: %s", raw, original) + } + }) + } +} + func TestEnableUsesSetupFlow(t *testing.T) { t.Parallel() @@ -4441,7 +4632,7 @@ func TestConfigureCmd_SummarizeProvider_InvalidProvider(t *testing.T) { cmd := newSetupCmd() cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--summarize-provider", "opencode"}) + cmd.SetArgs([]string{"--summarize-provider", "factoryai-droid"}) err := cmd.Execute() if err == nil { diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 012f6a9c99..92e52a00f1 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -1178,7 +1178,11 @@ func applyBackfilledSessionTokenUsage(ctx context.Context, ag agent.Agent, state // sessionStateBackfillTokenUsage returns the best session-level token usage to // persist in session state after condensation. func sessionStateBackfillTokenUsage(ctx context.Context, ag agent.Agent, agentType types.AgentType, transcript []byte, checkpointUsage *agent.TokenUsage) *agent.TokenUsage { - if agentType == agent.AgentTypeCopilotCLI && len(transcript) > 0 { + if agentType != agent.AgentTypeCopilotCLI { + return nil + } + + if len(transcript) > 0 { fullSessionUsage := agent.CalculateTokenUsage(ctx, ag, transcript, 0, "") if hasTokenUsageData(fullSessionUsage) { return fullSessionUsage @@ -1186,11 +1190,7 @@ func sessionStateBackfillTokenUsage(ctx context.Context, ag agent.Agent, agentTy logging.Debug(ctx, "copilot-cli: full-session token read produced no data, falling back to checkpoint usage") } - if agentType == agent.AgentTypeCopilotCLI && hasTokenUsageData(checkpointUsage) { - return checkpointUsage - } - - if checkpointUsage != nil && checkpointUsage.InputTokens > 0 { + if hasTokenUsageData(checkpointUsage) { return checkpointUsage } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index 7915b10af7..f3308fed72 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -258,6 +258,97 @@ func TestCountTranscriptItems_CursorEmpty(t *testing.T) { } } +func TestNonCopilotCondensationPreservesSessionTokenUsage(t *testing.T) { + t.Parallel() + + sessionUsage := &agent.TokenUsage{ + InputTokens: 10_000, + OutputTokens: 999, + CacheReadTokens: 2_000, + CacheCreationTokens: 500, + APICallCount: 42, + } + state := &SessionState{ + SessionID: "s1", + AgentType: agent.AgentTypeClaudeCode, + TokenUsage: sessionUsage, + } + checkpointUsage := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 10, + CacheReadTokens: 20, + CacheCreationTokens: 5, + APICallCount: 1, + } + + applyBackfilledSessionTokenUsage(t.Context(), nil, state, nil, checkpointUsage) + + require.Equal(t, sessionUsage, state.TokenUsage) +} + +func TestNonCopilotCondensationDoesNotPromoteCheckpointUsage(t *testing.T) { + t.Parallel() + + state := &SessionState{ + SessionID: "s1", + AgentType: agent.AgentTypeClaudeCode, + } + checkpointUsage := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 10, + APICallCount: 1, + } + + applyBackfilledSessionTokenUsage(t.Context(), nil, state, nil, checkpointUsage) + + require.Nil(t, state.TokenUsage) +} + +func TestCondenseSessionByID_NonCopilotPreservesSessionTokenUsage(t *testing.T) { //nolint:paralleltest // uses t.Chdir + dir := setupGitRepo(t) + t.Chdir(dir) + + s := &ManualCommitStrategy{} + sessionID := "non-copilot-token-usage" + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + + transcript := strings.Join([]string{ + `{"type":"human","uuid":"u1","message":{"content":"hello"}}`, + `{"type":"assistant","uuid":"u2","message":{"id":"msg_001","usage":{"input_tokens":100,"output_tokens":10}}}`, + }, "\n") + "\n" + testutil.WriteFile(t, dir, filepath.Join(metadataDir, paths.TranscriptFileName), transcript) + testutil.WriteFile(t, dir, "test.txt", "agent content") + + sessionUsage := &agent.TokenUsage{ + InputTokens: 10_000, + OutputTokens: 999, + CacheReadTokens: 2_000, + CacheCreationTokens: 500, + APICallCount: 42, + } + require.NoError(t, s.SaveStep(t.Context(), StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{"test.txt"}, + MetadataDir: metadataDir, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + AgentType: agent.AgentTypeClaudeCode, + TokenUsage: sessionUsage, + })) + + state, err := s.loadSessionState(t.Context(), sessionID) + require.NoError(t, err) + require.Equal(t, sessionUsage, state.TokenUsage) + + require.NoError(t, s.CondenseSessionByID(t.Context(), sessionID)) + + state, err = s.loadSessionState(t.Context(), sessionID) + require.NoError(t, err) + require.Equal(t, sessionUsage, state.TokenUsage) + require.Nil(t, state.CheckpointTokenUsage) +} + func TestSessionStateBackfillTokenUsage_CopilotUsesZeroInputSessionAggregate(t *testing.T) { t.Parallel() @@ -284,6 +375,52 @@ func TestSessionStateBackfillTokenUsage_CopilotUsesZeroInputSessionAggregate(t * require.Equal(t, 3, backfillUsage.APICallCount) } +func TestSessionStateBackfillTokenUsage_CopilotFallsBackToCheckpointUsage(t *testing.T) { + t.Parallel() + + checkpointUsage := &agent.TokenUsage{ + OutputTokens: 25, + APICallCount: 1, + } + + backfillUsage := sessionStateBackfillTokenUsage( + t.Context(), nil, agent.AgentTypeCopilotCLI, nil, checkpointUsage, + ) + + require.Same(t, checkpointUsage, backfillUsage) +} + +func TestApplyBackfilledSessionTokenUsage_CopilotPreservesSubagentTotal(t *testing.T) { + t.Parallel() + + checkpointUsage := &agent.TokenUsage{ + OutputTokens: 25, + APICallCount: 1, + } + state := &SessionState{ + AgentType: agent.AgentTypeCopilotCLI, + TokenUsage: &agent.TokenUsage{ + InputTokens: 1_000, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 50, + APICallCount: 2, + }, + }, + } + + applyBackfilledSessionTokenUsage(t.Context(), nil, state, nil, checkpointUsage) + + require.Equal(t, 25, state.TokenUsage.OutputTokens) + require.Equal(t, 1, state.TokenUsage.APICallCount) + require.Equal(t, &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 50, + APICallCount: 2, + }, state.TokenUsage.SubagentTokens) + require.Nil(t, checkpointUsage.SubagentTokens) +} + func TestSessionStateBackfillModel_PiReadsModelFromTranscript(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/strategy/subagent_tokens_test.go b/cmd/entire/cli/strategy/subagent_tokens_test.go index 86cd25e96d..98559f1024 100644 --- a/cmd/entire/cli/strategy/subagent_tokens_test.go +++ b/cmd/entire/cli/strategy/subagent_tokens_test.go @@ -518,9 +518,6 @@ func TestCalculateLiveTranscriptTokenUsage_RescopesSubagentCumulativeTotal(t *te require.Equal(t, 200, state.TokenUsage.SubagentTokens.InputTokens, "session state must retain the cumulative snapshot for the next baseline") - applyBackfilledSessionTokenUsage(t.Context(), ag, state, mainTranscript, usage) - require.Equal(t, 200, state.TokenUsage.SubagentTokens.InputTokens, - "main-token backfill must not replace the cumulative snapshot with the checkpoint delta") state.RebaselineSubagentTokens() require.NoError(t, os.WriteFile(subagentPath, []byte(`{"type":"assistant","uuid":"a-sub","message":{"id":"msg_sub","type":"message","role":"assistant","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":260,"output_tokens":35}}} `), 0o644)) @@ -627,15 +624,8 @@ func TestCondenseSessionByID_CapturesSubagentBaselineViaRealResetPath(t *testing metadataDir := ".entire/metadata/" + sessionID metadataDirAbs := filepath.Join(dir, metadataDir) require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) - // The assistant line carries real usage data (message.id + usage). Real - // Claude Code transcripts always do, which makes sessionStateBackfillTokenUsage - // fire during condensation (its InputTokens > 0 branch) and overwrite - // state.TokenUsage with the transcript-recomputed value — which is computed - // with subagentsDir="" and therefore drops SubagentTokens. This is what makes - // this test guard the REAL condensation path: without preserving the - // cumulative subagent total across the backfill, resetCheckpointWindow would - // snapshot a nil baseline and the next checkpoint would re-report the full - // cumulative subagent total (finding 019f5ebf-a57e). + // Checkpoint-scoped transcript usage must not replace the cumulative session + // total here, so the reset can retain the subagent baseline. transcript := `{"type":"human","message":{"content":"do the thing"}} {"type":"assistant","uuid":"a1","message":{"id":"m1","usage":{"input_tokens":300,"output_tokens":150}}} ` @@ -715,11 +705,8 @@ func TestCondenseSessionByID_CapturesSubagentBaselineViaRealResetPath(t *testing require.Equal(t, 60, summary.TokenUsage.SubagentTokens.OutputTokens) } -// TestWithSubagentTokensFrom_DoesNotMutateInput guards the copy semantics directly. -// The condensation tests cannot: applyBackfilledSessionTokenUsage already hands back -// a copy on that path, so a mutate-in-place implementation passes them. Mutating -// would overwrite the session-wide cumulative with a window delta and make -// resetCheckpointWindow snapshot a too-small baseline for the next window. +// Session and checkpoint token snapshots can share pointers, so replacement must +// not mutate either input. func TestSubagentCoverageSurvivesBackfill(t *testing.T) { t.Parallel() incomplete := false diff --git a/go.mod b/go.mod index 04ad0eef8a..e7c738d532 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.56.0 golang.org/x/mod v0.41.0 diff --git a/go.sum b/go.sum index 4cdc3931b6..ab1513619a 100644 --- a/go.sum +++ b/go.sum @@ -289,6 +289,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= diff --git a/internal/coreapi/oas_client_gen.go b/internal/coreapi/oas_client_gen.go index 4c007c8092..5e04fc2529 100644 --- a/internal/coreapi/oas_client_gen.go +++ b/internal/coreapi/oas_client_gen.go @@ -3983,6 +3983,26 @@ func (c *Client) sendGetRepo(ctx context.Context, params GetRepoParams) (res *Re } uri.AddPathParts(u, pathParts[:]...) + q := uri.NewQueryEncoder() + { + // Encode "authoritative" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "authoritative", + Style: uri.QueryStyleForm, + Explode: false, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.Authoritative.Get(); ok { + return e.EncodeValue(conv.BoolToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + u.RawQuery = q.Values().Encode() + r, err := ht.NewRequest(ctx, "GET", u) if err != nil { return res, errors.Wrap(err, "create request") diff --git a/internal/coreapi/oas_parameters_gen.go b/internal/coreapi/oas_parameters_gen.go index b0796d2a1d..a415024feb 100644 --- a/internal/coreapi/oas_parameters_gen.go +++ b/internal/coreapi/oas_parameters_gen.go @@ -128,6 +128,10 @@ type GetProjectParams struct { // GetRepoParams is parameters of getRepo operation. type GetRepoParams struct { RepoId string + // Require the repo's regional lifecycle state. A local repo is unaffected. A repo homed in another + // jurisdiction redirects with 421. This core reports 503 when it cannot route the read. Provisioning + // and failed states still return 200. + Authoritative OptBool `json:",omitempty,omitzero"` } // GetRepoVisibilityParams is parameters of getRepoVisibility operation. diff --git a/internal/coreapi/oas_schemas_gen.go b/internal/coreapi/oas_schemas_gen.go index 651c89d248..55a2096c00 100644 --- a/internal/coreapi/oas_schemas_gen.go +++ b/internal/coreapi/oas_schemas_gen.go @@ -11756,9 +11756,10 @@ type Repo struct { ProvisionAttempts OptInt64 `json:"provisionAttempts"` ProvisionReason OptString `json:"provisionReason"` RepoGroupId OptString `json:"repoGroupId"` - State OptString `json:"state"` - Visibility OptString `json:"visibility"` - AdditionalProps RepoAdditional + // Provisioning lifecycle. A mirror is active from creation, before its initial clone completes. + State OptString `json:"state"` + Visibility OptString `json:"visibility"` + AdditionalProps RepoAdditional } // GetSchema returns the value of Schema. diff --git a/internal/coreapi/repo_readiness_test.go b/internal/coreapi/repo_readiness_test.go new file mode 100644 index 0000000000..ac0ce8145d --- /dev/null +++ b/internal/coreapi/repo_readiness_test.go @@ -0,0 +1,107 @@ +package coreapi + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/entireio/auth-go/crossjuris" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Not parallel: ENTIRE_TOKEN is process-global. The only transport substitution +// trusts httptest's TLS certificate; URL validation, federation validation, +// redirect following, exchange and generated request encoding are real. +func TestRepoAuthoritativeCrossRegion(t *testing.T) { + for _, mode := range []string{"bearer", "environment", "environment cluster"} { + t.Run(mode, func(t *testing.T) { + var creates, reads, redirects, exchanges atomic.Int32 + var subjects authRecorder + const id = "01HZX7QABCDEFGHJKMNPQRSTVW" + home := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == crossjuris.TokenPath { + exchanges.Add(1) + if err := r.ParseForm(); err != nil { + t.Error(err) + } + subjects.add(r.PostForm.Get("subject_token")) + fmt.Fprint(w, `{"access_token":"home-exchanged-jwt","token_type":"Bearer","expires_in":300}`) + return + } + if r.Header.Get("Authorization") != bearerHomeExchanged { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"invalid token"}`) + return + } + state := "provisioning" + switch r.Method { + case http.MethodPost: + creates.Add(1) + assert.Equal(t, "/api/v1/repos", r.URL.Path) + w.WriteHeader(http.StatusCreated) + case http.MethodGet: + assert.Equal(t, "/api/v1/repos/"+id, r.URL.Path) + assert.Equal(t, "true", r.URL.Query().Get("authoritative")) + if reads.Add(1) == 2 { + state = "active" + } + default: + t.Errorf("unexpected %s", r.Method) + } + fmt.Fprintf(w, `{"id":%q,"name":"web","owningProjectId":%q,"provider":"entire","state":%q,"capabilities":{"canManage":true,"canPush":true,"canPull":true}}`, id, id, state) + })) + defer home.Close() + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == crossjuris.WellKnownPath { + writeTestFederation(w, []string{home.URL}) + return + } + redirects.Add(1) + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusMisdirectedRequest) + fmt.Fprintf(w, `{"error":%q,"home_core_url":%q,"jurisdiction":"eu"}`, `cluster is in jurisdiction "eu"; retry against the home core`, home.URL) + })) + defer origin.Close() + token := makeAudJWT(origin.URL) + var c *Client + var err error + switch mode { + case "environment": + t.Setenv(auth.EnvTokenVar, token) + c, err = New() + case "environment cluster": + t.Setenv(auth.EnvTokenVar, token) + c, err = NewForCluster(t.Context(), "unused.example") + default: + c, err = NewWithBearer(origin.URL, token) + } + require.NoError(t, err) + require.Equal(t, origin.URL, c.CoreOrigin()) + rt, err := newCrossJurisRoundTripper(home.Client().Transport, false) + require.NoError(t, err) + c.cfg.Client = &http.Client{Transport: rt} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + created, err := c.CreateRepo(ctx, &CreateRepoInputBody{Name: "web", ProjectId: id}) + require.NoError(t, err) + require.Equal(t, "provisioning", created.State.Or("")) + for _, state := range []string{"provisioning", "active"} { + snapshot, err := c.GetRepo(ctx, GetRepoParams{RepoId: created.ID, Authoritative: NewOptBool(true)}) + require.NoError(t, err) + require.Equal(t, state, snapshot.State.Or("")) + } + require.EqualValues(t, 1, creates.Load()) + require.EqualValues(t, 2, reads.Load()) + require.EqualValues(t, 3, redirects.Load(), "the initial core is revisited for every logical call") + require.EqualValues(t, 1, exchanges.Load(), "exchange token is cached across polls") + require.Equal(t, []string{token}, subjects.snapshot()) + }) + } +} diff --git a/internal/coreapi/spec/core.gen.json b/internal/coreapi/spec/core.gen.json index a17b2eb2ab..c0a692c71f 100644 --- a/internal/coreapi/spec/core.gen.json +++ b/internal/coreapi/spec/core.gen.json @@ -3437,6 +3437,7 @@ "type": "string" }, "state": { + "description": "Provisioning lifecycle. A mirror is active from creation, before its initial clone completes.", "type": "string" }, "visibility": { @@ -7555,6 +7556,16 @@ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "type": "string" } + }, + { + "description": "Require the repo's regional lifecycle state. A local repo is unaffected. A repo homed in another jurisdiction redirects with 421. This core reports 503 when it cannot route the read. Provisioning and failed states still return 200.", + "explode": false, + "in": "query", + "name": "authoritative", + "schema": { + "description": "Require the repo's regional lifecycle state. A local repo is unaffected. A repo homed in another jurisdiction redirects with 421. This core reports 503 when it cannot route the read. Provisioning and failed states still return 200.", + "type": "boolean" + } } ], "responses": { diff --git a/internal/coreapi/spec/core.openapi.json b/internal/coreapi/spec/core.openapi.json index 85380d5d25..2fcca285f3 100644 --- a/internal/coreapi/spec/core.openapi.json +++ b/internal/coreapi/spec/core.openapi.json @@ -3448,6 +3448,7 @@ "type": "string" }, "state": { + "description": "Provisioning lifecycle. A mirror is active from creation, before its initial clone completes.", "enum": [ "provisioning", "active", @@ -10901,6 +10902,16 @@ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "type": "string" } + }, + { + "description": "Require the repo's regional lifecycle state. A local repo is unaffected. A repo homed in another jurisdiction redirects with 421. This core reports 503 when it cannot route the read. Provisioning and failed states still return 200.", + "explode": false, + "in": "query", + "name": "authoritative", + "schema": { + "description": "Require the repo's regional lifecycle state. A local repo is unaffected. A repo homed in another jurisdiction redirects with 421. This core reports 503 when it cannot route the read. Provisioning and failed states still return 200.", + "type": "boolean" + } } ], "responses": { @@ -10954,6 +10965,16 @@ }, "description": "Not Found" }, + "421": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Misdirected Request" + }, "422": { "content": { "application/problem+json": { @@ -10973,6 +10994,16 @@ } }, "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable" } }, "security": [