Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
168 changes: 166 additions & 2 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -27,6 +28,7 @@
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 @@
}
}

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 @@
}

// 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 @@
}
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 @@ -898,7 +916,7 @@

// setupAgentHooks sets up hooks for a given agent.
// Returns the number of hooks installed (0 if already installed).
func setupAgentHooks(ctx context.Context, ag agent.Agent, localDev, forceHooks bool) (int, error) { //nolint:unparam // count useful for callers that want to report installed hook count

Check failure on line 919 in cmd/entire/cli/setup.go

View workflow job for this annotation

GitHub Actions / lint

directive `//nolint:unparam // count useful for callers that want to report installed hook count` is unused for linter "unparam" (nolintlint)
hookAgent, ok := agent.AsHookSupport(ag)
if !ok {
return 0, fmt.Errorf("agent %s does not support hooks", ag.Name())
Expand Down Expand Up @@ -1200,6 +1218,12 @@

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,146 @@
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

Check failure on line 1510 in cmd/entire/cli/setup.go

View workflow job for this annotation

GitHub Actions / lint

error is not nil (line 1508) but it returns nil (nilerr)
}

vercelJSONPath := filepath.Join(repoRoot, "vercel.json")
hasVercelJSON := false
if _, err := os.Stat(vercelJSONPath); err == nil {
hasVercelJSON = true
}

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
}
}
}

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. Run `entire configure` interactively to disable deployments for `%s` branches.\n", vercelBranchPattern)
return nil
}
promptFn = promptVercelDeploymentDisable
}

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

mergeVercelDeploymentDisabled(config)

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 {

Check failure on line 1569 in cmd/entire/cli/setup.go

View workflow job for this annotation

GitHub Actions / lint

G306: Expect WriteFile permissions to be 0600 or less (gosec)
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) {
gitConfig, ok := config["git"].(map[string]any)
if !ok {
gitConfig = make(map[string]any)
config["git"] = gitConfig
}

deploymentEnabled, ok := gitConfig["deploymentEnabled"].(map[string]any)
if !ok {
deploymentEnabled = make(map[string]any)
gitConfig["deploymentEnabled"] = deploymentEnabled
}

deploymentEnabled[vercelBranchPattern] = false
}

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

Check failure on line 1641 in cmd/entire/cli/setup.go

View workflow job for this annotation

GitHub Actions / lint

error returned from external package is unwrapped: sig: func (*github.com/charmbracelet/huh.Form).Run() error (wrapcheck)
}

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
Loading