Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 193 additions & 2 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -27,6 +28,7 @@ import (
const (
configDisplayProject = ".entire/settings.json"
configDisplayLocal = ".entire/settings.local.json"
vercelBranchPattern = "entire/*"
)

// Flag names used across setup commands.
Expand Down Expand Up @@ -400,6 +402,12 @@ func applyAgentChanges(ctx context.Context, w io.Writer, selectedAgentNames []st
}
}

if len(installedAgents) > 0 {
if err := maybePromptVercelDeploymentDisable(ctx, w, nil); err != nil {
errs = append(errs, err)
}
}

return errors.Join(errs...)
}

Expand Down Expand Up @@ -639,10 +647,15 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent
}

// Setup agent hooks for all selected agents
enabledAnyAgent := false
for _, ag := range agents {
if _, err := setupAgentHooks(ctx, ag, opts.LocalDev, opts.ForceHooks); err != nil {
installedHooks, err := setupAgentHooks(ctx, ag, opts.LocalDev, opts.ForceHooks)
if err != nil {
return fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err)
}
if installedHooks > 0 {
enabledAnyAgent = true
}
}

// Setup .entire directory
Expand Down Expand Up @@ -713,8 +726,13 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent
}
fmt.Fprintf(w, "✓ Project configured (%s)\n", configDisplay)

if enabledAnyAgent {
if err := maybePromptVercelDeploymentDisable(ctx, w, nil); err != nil {
return err
}
}

// Ask about telemetry consent (only if not already asked)
fmt.Fprintln(w)
if err := promptTelemetryConsent(settings, opts.Telemetry); err != nil {
return fmt.Errorf("telemetry consent: %w", err)
}
Expand Down Expand Up @@ -1200,6 +1218,12 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag

fmt.Fprintf(w, "✓ Project configured (%s)\n", configDisplayProject)

if installedHooks > 0 {
if err := maybePromptVercelDeploymentDisable(ctx, w, nil); err != nil {
return err
}
}

if err := strategy.EnsureSetup(ctx); err != nil {
return fmt.Errorf("failed to setup strategy: %w", err)
}
Expand Down Expand Up @@ -1480,6 +1504,173 @@ func promptTelemetryConsent(settings *EntireSettings, telemetryFlag bool) error
return nil
}

func maybePromptVercelDeploymentDisable(ctx context.Context, w io.Writer, promptFn func() (bool, error)) error {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return nil
}

vercelJSONPath := filepath.Join(repoRoot, "vercel.json")
hasVercelJSON := false
if _, err := os.Stat(vercelJSONPath); err == nil {
hasVercelJSON = true
} else if !os.IsNotExist(err) {
fmt.Fprintf(w, "Note: Skipping Vercel deployment update: could not check vercel.json: %v\n", err)
return nil
}

hasVercelProject := hasVercelJSON
if !hasVercelProject {
for _, path := range []string{
filepath.Join(repoRoot, ".vercel"),
filepath.Join(repoRoot, "vercel.ts"),
} {
if _, err := os.Stat(path); err == nil {
hasVercelProject = true
break
} else if !os.IsNotExist(err) {
fmt.Fprintf(w, "Note: Skipping Vercel deployment update: could not check %s: %v\n", path, err)
return nil
}
}
}

if !hasVercelProject {
return nil
}

config, alreadyDisabled, err := loadVercelConfig(vercelJSONPath, hasVercelJSON)
if err != nil {
fmt.Fprintf(w, "Note: Skipping Vercel branch deployment update: %v\n", err)
return nil
}
if alreadyDisabled {
return nil
}

if promptFn == nil {
if !canPromptInteractively() {
fmt.Fprintf(
w,
"Note: Vercel detected. To disable deployments for `%s` branches, manually update `vercel.json` to ignore those branches.\n",
vercelBranchPattern,
)
return nil
}
promptFn = promptVercelDeploymentDisable
}

disableDeployments, err := promptFn()
if err != nil {
return fmt.Errorf("vercel prompt: %w", err)
}
if !disableDeployments {
return nil
}

if err := mergeVercelDeploymentDisabled(config); err != nil {
fmt.Fprintf(w, "Note: Skipping Vercel branch deployment update: %v\n", err)
return nil
}

output, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshal vercel.json: %w", err)
}
output = append(output, '\n')

if err := os.WriteFile(vercelJSONPath, output, 0o644); err != nil {
return fmt.Errorf("write vercel.json: %w", err)
}

fmt.Fprintf(w, "✓ Updated vercel.json to disable deployments for `%s` branches\n", vercelBranchPattern)
return nil
}

func loadVercelConfig(vercelJSONPath string, hasVercelJSON bool) (map[string]any, bool, error) {
if !hasVercelJSON {
return make(map[string]any), false, nil
}

data, err := os.ReadFile(vercelJSONPath) //nolint:gosec // path is repo-root relative, not user input
if err != nil {
return nil, false, fmt.Errorf("read vercel.json: %w", err)
}

var config map[string]any
if err := json.Unmarshal(data, &config); err != nil {
return nil, false, fmt.Errorf("parse vercel.json: %w", err)
}
if config == nil {
config = make(map[string]any)
}

return config, vercelDeploymentDisabled(config), nil
}

func vercelDeploymentDisabled(config map[string]any) bool {
gitConfig, ok := config["git"].(map[string]any)
if !ok {
return false
}
deploymentEnabled, ok := gitConfig["deploymentEnabled"].(map[string]any)
if !ok {
return false
}
enabled, ok := deploymentEnabled[vercelBranchPattern].(bool)
return ok && !enabled
}

func mergeVercelDeploymentDisabled(config map[string]any) error {
var gitConfig map[string]any
if existingGitConfig, exists := config["git"]; exists {
var ok bool
gitConfig, ok = existingGitConfig.(map[string]any)
if !ok {
return fmt.Errorf("vercel.json git config must be an object")
}
} else {
gitConfig = make(map[string]any)
config["git"] = gitConfig
}

var deploymentEnabled map[string]any
if existingDeploymentEnabled, exists := gitConfig["deploymentEnabled"]; exists {
var ok bool
deploymentEnabled, ok = existingDeploymentEnabled.(map[string]any)
if !ok {
return fmt.Errorf("vercel.json git.deploymentEnabled config must be an object")
}
} else {
deploymentEnabled = make(map[string]any)
gitConfig["deploymentEnabled"] = deploymentEnabled
}

deploymentEnabled[vercelBranchPattern] = false

return nil
}

func promptVercelDeploymentDisable() (bool, error) {
disableDeployments := true
form := NewAccessibleForm(
huh.NewGroup(
huh.NewConfirm().
Title("Disable Vercel deployments for Entire branches?").
Description("Prevent preview deployments for `entire/*` branches by writing to vercel.json.").
Affirmative("Yes").
Negative("No").
Value(&disableDeployments),
),
)

if err := form.Run(); err != nil {
return false, err
}

return disableDeployments, nil
}

// runUninstall completely removes Entire from the repository.
func runUninstall(ctx context.Context, w, errW io.Writer, force bool) error {
// Check if we're in a git repository
Expand Down
122 changes: 122 additions & 0 deletions cmd/entire/cli/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,128 @@ func TestManageAgents_AddAndRemove(t *testing.T) {
}
}

func TestMaybePromptVercelDeploymentDisable_MergesExistingConfig(t *testing.T) {
setupTestRepo(t)

requireWriteFile := func(path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

requireWriteFile("vercel.json", `{
"cleanUrls": true,
"git": {
"deploymentEnabled": {
"main": true
}
}
}`)

var prompted bool
var buf bytes.Buffer
err := maybePromptVercelDeploymentDisable(context.Background(), &buf, func() (bool, error) {
prompted = true
return true, nil
})
if err != nil {
t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
}
if !prompted {
t.Fatal("expected Vercel prompt to run")
}

data, err := os.ReadFile("vercel.json")
if err != nil {
t.Fatalf("read vercel.json: %v", err)
}

var config map[string]any
if err := json.Unmarshal(data, &config); err != nil {
t.Fatalf("parse vercel.json: %v", err)
}

if config["cleanUrls"] != true {
t.Fatalf("expected cleanUrls to be preserved, got %#v", config["cleanUrls"])
}

gitConfig, ok := config["git"].(map[string]any)
if !ok {
t.Fatalf("expected git object, got %#v", config["git"])
}
deploymentEnabled, ok := gitConfig["deploymentEnabled"].(map[string]any)
if !ok {
t.Fatalf("expected deploymentEnabled object, got %#v", gitConfig["deploymentEnabled"])
}
if deploymentEnabled["main"] != true {
t.Fatalf("expected existing main rule to be preserved, got %#v", deploymentEnabled["main"])
}
if deploymentEnabled[vercelBranchPattern] != false {
t.Fatalf("expected %s to be disabled, got %#v", vercelBranchPattern, deploymentEnabled[vercelBranchPattern])
}
}

func TestMaybePromptVercelDeploymentDisable_CreatesConfigWhenVercelDetected(t *testing.T) {
setupTestRepo(t)

if err := os.MkdirAll(".vercel", 0o755); err != nil {
t.Fatalf("mkdir .vercel: %v", err)
}

var buf bytes.Buffer
err := maybePromptVercelDeploymentDisable(context.Background(), &buf, func() (bool, error) {
return true, nil
})
if err != nil {
t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
}

data, err := os.ReadFile("vercel.json")
if err != nil {
t.Fatalf("read vercel.json: %v", err)
}

var config map[string]any
if err := json.Unmarshal(data, &config); err != nil {
t.Fatalf("parse vercel.json: %v", err)
}

if !vercelDeploymentDisabled(config) {
t.Fatalf("expected generated vercel.json to disable %s branches: %s", vercelBranchPattern, string(data))
}
}

func TestMaybePromptVercelDeploymentDisable_SkipsWhenAlreadyDisabled(t *testing.T) {
setupTestRepo(t)

if err := os.WriteFile("vercel.json", []byte(`{
"git": {
"deploymentEnabled": {
"entire/*": false
}
}
}`), 0o644); err != nil {
t.Fatalf("write vercel.json: %v", err)
}

promptCalled := false
var buf bytes.Buffer
err := maybePromptVercelDeploymentDisable(context.Background(), &buf, func() (bool, error) {
promptCalled = true
return true, nil
})
if err != nil {
t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
}
if promptCalled {
t.Fatal("expected Vercel prompt to be skipped when already configured")
}
if buf.Len() != 0 {
t.Fatalf("expected no output when already configured, got %q", buf.String())
}
}

func TestConfigureCmd_RemoveFlag_StillWorks(t *testing.T) {
// Cannot use t.Parallel() because we use t.Chdir
setupTestRepo(t)
Expand Down