diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 6060c1b..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - # Enable version updates for Go - - package-ecosystem: "gomod" - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..f68b690 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "schedule:weekly", + "group:allNonMajor" + ], + "minimumReleaseAge": "24 hours", + "postUpdateOptions": ["gomodTidy"], + "packageRules": [ + { + "matchManagers": ["github-actions"], + "groupName": "github-actions" + } + ] +} diff --git a/.github/workflows/build-and-release.yaml b/.github/workflows/build-and-release.yaml index 65b3cb1..75d13c5 100644 --- a/.github/workflows/build-and-release.yaml +++ b/.github/workflows/build-and-release.yaml @@ -12,24 +12,21 @@ concurrency: permissions: contents: write -env: - GO_VERSION: "1.25" - jobs: release: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: go.mod cache: true - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: '~> v2' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b6d32b9..b4d7370 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,25 +10,29 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - GO_VERSION: "1.25" - jobs: lint-and-test: name: Lint & Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: go.mod cache: true - - name: Lint + - name: Lint (linux) + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + + - name: Lint (darwin) uses: golangci/golangci-lint-action@v9 with: - version: v2.7.2 + version: v2.12.2 + env: + GOOS: darwin - name: Unit tests run: go test -race ./internal/... @@ -37,11 +41,11 @@ jobs: name: E2E Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: go.mod cache: true - name: Build devbox diff --git a/.golangci.yml b/.golangci.yml index 56697e4..455acb7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,26 +3,151 @@ version: "2" linters: enable: - errcheck - # - wrapcheck # TODO - govet - - ineffassign - staticcheck - - goconst - # - gocyclo # TODO + - ineffassign + - bodyclose + - nilerr + - nilnil + - forcetypeassert + - contextcheck + - containedctx + - unparam + - wastedassign + - exhaustive + - errorlint + - wrapcheck - misspell - whitespace - nolintlint + - revive + - unconvert + - predeclared + - usestdlibvars + - dupword + - goconst + - gocritic + - gosec + - prealloc + - perfsprint + - intrange + settings: - gocyclo: - min-complexity: 15 + errcheck: + check-blank: false + govet: + enable-all: true + disable: + - fieldalignment + staticcheck: + checks: + - all + - -ST1000 + errorlint: + errorf: true + errorf-multi: true + asserts: true + comparison: true + nilnil: + checked-types: + - ptr + - func + - iface + - map + - chan + gocritic: + enabled-tags: + - diagnostic + - style + - performance + disabled-checks: + - hugeParam + - rangeValCopy + - ifElseChain + - unlambda + wrapcheck: + ignore-sigs: + - .Errorf( + - errors.New( + - errors.Join( + perfsprint: + int-conversion: true + err-error: true + errorf: true + sprintf1: true + goconst: + ignore-tests: true + gosec: + excludes: + - G104 + - G204 + - G304 + - G301 + - G302 + - G306 + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: if-return + - name: increment-decrement + - name: var-declaration + - name: range + - name: time-naming + - name: indent-error-flow + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + exclusions: + generated: lax paths: - tmp + rules: + - linters: [gosec] + path: _test\.go + - linters: [dupword] + path: _test\.go + - linters: [errcheck] + path: _test\.go + source: "Close\\(\\)" + - linters: [bodyclose] + path: _test\.go + - linters: [wrapcheck] + path: _test\.go + - linters: [containedctx] + path: _test\.go + - linters: [goconst] + path: _test\.go + - linters: [errcheck] + source: "defer .+\\.Close\\(" + - linters: [govet] + text: 'shadow: declaration of "err" shadows' + - linters: [unparam] + source: "^func \\(.+\\) .+\\(.*\\) .+ \\{$" + - linters: [wrapcheck] + source: "ctx\\.Err\\(\\)" formatters: enable: - - gofmt - - goimports + - gofumpt + - gci + - golines + settings: + gofumpt: + module-path: github.com/pilat/devbox + gci: + sections: + - standard + - default + - prefix(github.com/pilat/devbox) + golines: + max-len: 120 exclusions: paths: - tmp diff --git a/Makefile b/Makefile index f88520c..bbe7190 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -.PHONY: all tidy vendor mocks lint test test-e2e +.PHONY: all build tidy vendor mocks fmt lint test test-e2e clean all: tidy vendor lint test +build: + go build -o devbox ./cmd/devbox/ + mocks: mockery @@ -11,11 +14,17 @@ tidy: vendor: go mod vendor +fmt: + golangci-lint fmt + lint: - golangci-lint run + golangci-lint run ./... test: go test -race ./internal/... test-e2e: go test -v ./tests/e2e/ -timeout 10m -count=1 + +clean: + rm -f devbox diff --git a/README.md b/README.md index 7006b84..52b3427 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ brew install devbox 1. Download the appropriate binary from the [Releases Page](https://github.com/pilat/devbox/releases) 2. Make the binary executable and add it to your PATH +> **macOS:** the release binaries are unsigned, so Gatekeeper blocks a downloaded +> binary until you clear its quarantine bit: `xattr -dr com.apple.quarantine ./devbox` + ### System Requirements - Docker Engine 20.10.0 or later - Git 2.28 or later diff --git a/cmd/devbox/destroy.go b/cmd/devbox/destroy.go index 305ebbd..3e45684 100644 --- a/cmd/devbox/destroy.go +++ b/cmd/devbox/destroy.go @@ -4,8 +4,9 @@ import ( "context" "fmt" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -13,13 +14,15 @@ func init() { Use: "destroy", Short: "Destroy devbox project", Long: "That command will destroy devbox project", - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runDown(ctx, p, true); err != nil { diff --git a/cmd/devbox/devbox.go b/cmd/devbox/devbox.go index 36b3ba3..5975ac3 100644 --- a/cmd/devbox/devbox.go +++ b/cmd/devbox/devbox.go @@ -11,18 +11,26 @@ import ( "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" "github.com/moby/moby/client" - "github.com/pilat/devbox/internal/manager" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/manager" +) + +const ( + binName = "devbox" + nameFlag = "--name" ) var root = &cobra.Command{} var projectName string -var dockerCLI *command.DockerCli -var dockerClient client.APIClient -var apiService api.Compose -var mgr *manager.Manager +var ( + dockerCLI *command.DockerCli + dockerClient client.APIClient + apiService api.Compose + mgr *manager.Manager +) func main() { for _, fn := range []func() error{ @@ -36,17 +44,24 @@ func main() { } func initCobra() error { - root.Use = "devbox" + root.Use = binName root.SetErrPrefix("Error has occurred while executing the command:") root.PersistentFlags().StringVarP(&projectName, "name", "n", "", "Project name") - _ = root.RegisterFlagCompletionFunc("name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - projects, _ := mgr.List(toComplete) - return projects, cobra.ShellCompDirectiveNoFileComp - }) + _ = root.RegisterFlagCompletionFunc( + "name", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + projects, _ := mgr.List(toComplete) + return projects, cobra.ShellCompDirectiveNoFileComp + }, + ) + + if err := root.Execute(); err != nil { + return fmt.Errorf("failed to execute command: %w", err) + } - return root.Execute() + return nil } func initDocker() error { @@ -92,7 +107,9 @@ func newProgressCompose() (api.Compose, error) { return svc, nil } -func validArgsWrapper(f func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { +func validArgsWrapper( + f func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective), +) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() @@ -108,7 +125,9 @@ func validArgsWrapper(f func(ctx context.Context, cmd *cobra.Command, args []str } } -func runWrapper(f func(ctx context.Context, cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { +func runWrapper( + f func(ctx context.Context, cmd *cobra.Command, args []string) error, +) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { ctx := context.Background() cmd.SilenceUsage = true diff --git a/cmd/devbox/down.go b/cmd/devbox/down.go index 37dd8a6..d17c6df 100644 --- a/cmd/devbox/down.go +++ b/cmd/devbox/down.go @@ -4,8 +4,9 @@ import ( "context" "fmt" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -13,13 +14,15 @@ func init() { Use: "down", Short: "Stop devbox project", Long: "That command will stop devbox project", - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runDown(ctx, p, false); err != nil { diff --git a/cmd/devbox/env.go b/cmd/devbox/env.go index 7f0903b..9a2054c 100644 --- a/cmd/devbox/env.go +++ b/cmd/devbox/env.go @@ -7,8 +7,9 @@ import ( "os/exec" "path/filepath" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -22,9 +23,11 @@ func init() { Use: "env", Short: "Edit the environment configuration", Long: "Opens the project's .env file in the default editor for editing", - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { project, err := mgr.AutodetectProject(ctx, projectName) if err != nil { diff --git a/cmd/devbox/info.go b/cmd/devbox/info.go index 839b502..fb08765 100644 --- a/cmd/devbox/info.go +++ b/cmd/devbox/info.go @@ -6,11 +6,12 @@ import ( "path/filepath" "strings" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/app" "github.com/pilat/devbox/internal/git" "github.com/pilat/devbox/internal/project" "github.com/pilat/devbox/internal/table" - "github.com/spf13/cobra" ) func init() { @@ -19,13 +20,15 @@ func init() { Short: "Info devbox projects", Long: "That command returns an info about a particular devbox project", Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runInfo(ctx, p); err != nil { diff --git a/cmd/devbox/init.go b/cmd/devbox/init.go index 18f90c7..08f78cf 100644 --- a/cmd/devbox/init.go +++ b/cmd/devbox/init.go @@ -10,7 +10,7 @@ import ( ) func init() { - var projectName string + var name string var branch string cmd := &cobra.Command{ @@ -26,11 +26,11 @@ func init() { RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { gitURL := args[0] - if projectName == "" { - projectName = guessName(gitURL) + if name == "" { + name = guessName(gitURL) } - if err := runInit(ctx, projectName, gitURL, branch); err != nil { + if err := runInit(ctx, name, gitURL, branch); err != nil { return fmt.Errorf("failed to initialize project: %w", err) } @@ -38,7 +38,7 @@ func init() { }), } - cmd.Flags().StringVarP(&projectName, "name", "n", "", "Project name") + cmd.Flags().StringVarP(&name, "name", "n", "", "Project name") cmd.Flags().StringVarP(&branch, "branch", "b", "", "Branch to clone") root.AddCommand(cmd) diff --git a/cmd/devbox/install_ca.go b/cmd/devbox/install_ca.go index 5031dd6..d03ab46 100644 --- a/cmd/devbox/install_ca.go +++ b/cmd/devbox/install_ca.go @@ -12,13 +12,15 @@ func init() { Use: "install-ca", Hidden: true, Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runCertUpdate(p, false); err != nil { diff --git a/cmd/devbox/list.go b/cmd/devbox/list.go index ccce39b..6b28657 100644 --- a/cmd/devbox/list.go +++ b/cmd/devbox/list.go @@ -4,9 +4,10 @@ import ( "context" "fmt" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/git" "github.com/pilat/devbox/internal/table" - "github.com/spf13/cobra" ) func init() { diff --git a/cmd/devbox/logs.go b/cmd/devbox/logs.go index 3f51a76..888f45f 100644 --- a/cmd/devbox/logs.go +++ b/cmd/devbox/logs.go @@ -7,8 +7,9 @@ import ( "github.com/docker/cli/cli/streams" "github.com/docker/compose/v5/cmd/formatter" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -17,23 +18,25 @@ func init() { Short: "Show logs of services in devbox project", Long: "That command will show logs of services in devbox project", Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(ctx, projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(ctx, projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } - results, err := getRunningServices(ctx, apiService, p, true, toComplete) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } + results, err := getRunningServices(ctx, apiService, p, true, toComplete) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } - return results, cobra.ShellCompDirectiveNoFileComp - }), + return results, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runLogs(ctx, p, args); err != nil { diff --git a/cmd/devbox/mount.go b/cmd/devbox/mount.go index cedba8e..c202b8f 100644 --- a/cmd/devbox/mount.go +++ b/cmd/devbox/mount.go @@ -4,9 +4,10 @@ import ( "context" "fmt" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/manager" "github.com/pilat/devbox/internal/project" - "github.com/spf13/cobra" ) func init() { @@ -18,28 +19,36 @@ func init() { Short: "Mount source code", Long: "That command will mount source code to the project", Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(ctx, projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } - - if sourceName == "" { - if result, _ := mgr.AutodetectSource(ctx, p, "", manager.AutodetectSourceForMount); result != nil && len(result.Sources) > 0 { + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(ctx, projectName) + if err != nil { return []string{}, cobra.ShellCompDirectiveNoFileComp } - } - if sourceName == "" && !cmd.Flags().Changed("source") { - return []string{"--source"}, cobra.ShellCompDirectiveNoFileComp - } + if sourceName == "" { + if result, _ := mgr.AutodetectSource( + ctx, + p, + "", + manager.AutodetectSourceForMount, + ); result != nil && + len(result.Sources) > 0 { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + } - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + if sourceName == "" && !cmd.Flags().Changed("source") { + return []string{"--source"}, cobra.ShellCompDirectiveNoFileComp + } + + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } result, err := mgr.AutodetectSource(ctx, p, sourceName, manager.AutodetectSourceForMount) @@ -71,14 +80,17 @@ func init() { cmd.PersistentFlags().StringVarP(&sourceName, "source", "s", "", "Source name") cmd.PersistentFlags().StringVarP(&targetPath, "path", "p", "", "Path to mount") - _ = cmd.RegisterFlagCompletionFunc("source", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(context.Background(), projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } + _ = cmd.RegisterFlagCompletionFunc( + "source", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(context.Background(), projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } - return mgr.GetLocalMountCandidates(p, toComplete), cobra.ShellCompDirectiveNoFileComp - }) + return mgr.GetLocalMountCandidates(p, toComplete), cobra.ShellCompDirectiveNoFileComp + }, + ) root.AddCommand(cmd) } diff --git a/cmd/devbox/ps.go b/cmd/devbox/ps.go index d97df01..197e3a4 100644 --- a/cmd/devbox/ps.go +++ b/cmd/devbox/ps.go @@ -5,9 +5,10 @@ import ( "fmt" "time" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/project" "github.com/pilat/devbox/internal/table" - "github.com/spf13/cobra" ) func init() { @@ -15,13 +16,15 @@ func init() { Use: "ps", Short: "List services in devbox project", Long: "That command will list services in devbox project", - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runPs(ctx, p); err != nil { diff --git a/cmd/devbox/restart.go b/cmd/devbox/restart.go index 608abdb..519683e 100644 --- a/cmd/devbox/restart.go +++ b/cmd/devbox/restart.go @@ -6,8 +6,9 @@ import ( "strings" "github.com/docker/compose/v5/pkg/api" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -22,7 +23,7 @@ func init() { RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runProjectUpdate(ctx, p); err != nil { @@ -55,19 +56,27 @@ func init() { cmd.PersistentFlags().StringSliceVarP(&profiles, "profile", "p", []string{}, "Profile to use") - _ = cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(context.Background(), projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } + _ = cmd.RegisterFlagCompletionFunc( + "profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(context.Background(), projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } - return getProfileCompletions(p, toComplete) - }) + return getProfileCompletions(p, toComplete) + }, + ) root.AddCommand(cmd) } -func suggestRunningServices(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { +func suggestRunningServices( + ctx context.Context, + cmd *cobra.Command, + args []string, + toComplete string, +) ([]string, cobra.ShellCompDirective) { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { return []string{}, cobra.ShellCompDirectiveNoFileComp @@ -123,14 +132,16 @@ func runRestart(ctx context.Context, p *project.Project, services []string, noDe return err } - if err := runUp(ctx, p); err != nil { - return err - } - - return nil + return runUp(ctx, p) } -func getRunningServices(ctx context.Context, a api.Compose, p *project.Project, all bool, filter string) ([]string, error) { +func getRunningServices( + ctx context.Context, + a api.Compose, + p *project.Project, + all bool, + filter string, +) ([]string, error) { opts := project.PsOptions{ Project: p.Project, All: all, diff --git a/cmd/devbox/run.go b/cmd/devbox/run.go index 8ac73a4..d3dee14 100644 --- a/cmd/devbox/run.go +++ b/cmd/devbox/run.go @@ -5,8 +5,9 @@ import ( "fmt" "github.com/docker/compose/v5/pkg/api" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -17,23 +18,25 @@ func init() { Short: "Run scenario defined in devbox project", Long: "You can pass additional arguments to the scenario", Args: cobra.ExactArgs(1), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(ctx, projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } - - // If a scenario is already provided (or project is not detected), disallow further completions - if len(args) > 0 || p == nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } - - return p.GetScenarios(toComplete), cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(ctx, projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + + // If a scenario is already provided (or project is not detected), disallow further completions + if len(args) > 0 || p == nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + + return p.GetScenarios(toComplete), cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } command := args[0] @@ -72,7 +75,7 @@ func runRun(ctx context.Context, p *project.Project, command string, args []stri return fmt.Errorf("scenario %q not found", command) } - commands := []string{} + commands := make([]string, 0, len(scenario.Command)+len(args)) commands = append(commands, scenario.Command...) commands = append(commands, args...) diff --git a/cmd/devbox/shell.go b/cmd/devbox/shell.go index 43cd380..be1ca08 100644 --- a/cmd/devbox/shell.go +++ b/cmd/devbox/shell.go @@ -3,14 +3,16 @@ package main import ( "bytes" "context" + "errors" "fmt" "os" "strings" "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/client" - "github.com/pilat/devbox/internal/project" "github.com/spf13/cobra" + + "github.com/pilat/devbox/internal/project" ) func init() { @@ -21,16 +23,18 @@ func init() { Short: "Run interactive shell in one of the services", Long: "That command will run interactive shell in one of the services", Args: cobra.ExactArgs(1), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - return suggestRunningServices(ctx, cmd, args, toComplete) - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return suggestRunningServices(ctx, cmd, args, toComplete) + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runShell(ctx, p, args[0], noTty); err != nil { @@ -73,7 +77,7 @@ func runShell(ctx context.Context, p *project.Project, serviceName string, noTty } if lastShell == "" { - return fmt.Errorf("failed to find a shell") + return errors.New("failed to find a shell") } var tty bool @@ -114,7 +118,7 @@ func findContainerID(ctx context.Context, projectName, serviceName string) (stri return list.Items[0].ID, nil } -func containerExec(ctx context.Context, containerID string, cmd []string) ([]byte, []byte, error) { +func containerExec(ctx context.Context, containerID string, cmd []string) (stdoutBytes, stderrBytes []byte, err error) { execResp, err := dockerClient.ExecCreate(ctx, containerID, client.ExecCreateOptions{ Cmd: cmd, AttachStdout: true, @@ -143,7 +147,7 @@ func containerExec(ctx context.Context, containerID string, cmd []string) ([]byt case <-done: break case <-ctx.Done(): - return nil, nil, fmt.Errorf("context cancelled") + return nil, nil, errors.New("context cancelled") } return stdout.Bytes(), stderr.Bytes(), nil @@ -151,8 +155,8 @@ func containerExec(ctx context.Context, containerID string, cmd []string) ([]byt func filterLabels(projectName, serviceName string) client.Filters { return make(client.Filters). - Add("label", fmt.Sprintf("com.docker.compose.project=%s", projectName)). - Add("label", fmt.Sprintf("com.docker.compose.service=%s", serviceName)). + Add("label", "com.docker.compose.project="+projectName). + Add("label", "com.docker.compose.service="+serviceName). Add("label", "com.docker.compose.container-number=1") } diff --git a/cmd/devbox/umount.go b/cmd/devbox/umount.go index 86f4317..8f01ad3 100644 --- a/cmd/devbox/umount.go +++ b/cmd/devbox/umount.go @@ -4,9 +4,10 @@ import ( "context" "fmt" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/manager" "github.com/pilat/devbox/internal/project" - "github.com/spf13/cobra" ) func init() { @@ -17,28 +18,36 @@ func init() { Short: "Umount source code", Long: "That command will umount source code from the project", Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(ctx, projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } - - if sourceName == "" { - if result, _ := mgr.AutodetectSource(ctx, p, "", manager.AutodetectSourceForUmount); result != nil && len(result.Sources) > 0 { + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(ctx, projectName) + if err != nil { return []string{}, cobra.ShellCompDirectiveNoFileComp } - } - if sourceName == "" && !cmd.Flags().Changed("source") { - return []string{"--source"}, cobra.ShellCompDirectiveNoFileComp - } + if sourceName == "" { + if result, _ := mgr.AutodetectSource( + ctx, + p, + "", + manager.AutodetectSourceForUmount, + ); result != nil && + len(result.Sources) > 0 { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + } - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + if sourceName == "" && !cmd.Flags().Changed("source") { + return []string{"--source"}, cobra.ShellCompDirectiveNoFileComp + } + + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } result, err := mgr.AutodetectSource(ctx, p, sourceName, manager.AutodetectSourceForUmount) @@ -64,14 +73,17 @@ func init() { cmd.PersistentFlags().StringVarP(&sourceName, "source", "s", "", "Source name") - _ = cmd.RegisterFlagCompletionFunc("source", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(context.Background(), projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } + _ = cmd.RegisterFlagCompletionFunc( + "source", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(context.Background(), projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } - return mgr.GetLocalMounts(p, toComplete), cobra.ShellCompDirectiveNoFileComp - }) + return mgr.GetLocalMounts(p, toComplete), cobra.ShellCompDirectiveNoFileComp + }, + ) root.AddCommand(cmd) } diff --git a/cmd/devbox/up.go b/cmd/devbox/up.go index 880c104..5f5b4bb 100644 --- a/cmd/devbox/up.go +++ b/cmd/devbox/up.go @@ -7,11 +7,12 @@ import ( "strings" "time" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/app" "github.com/pilat/devbox/internal/cert" "github.com/pilat/devbox/internal/hosts" "github.com/pilat/devbox/internal/project" - "github.com/spf13/cobra" ) func init() { @@ -21,23 +22,25 @@ func init() { Use: "up", Short: "Start devbox project", Long: "That command will start devbox project", - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(ctx, projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } - - allProfiles := p.AllServices().GetProfiles() - if len(profiles) == 0 && len(allProfiles) > 0 { - return []string{"--profile"}, cobra.ShellCompDirectiveNoFileComp - } + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(ctx, projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } + + allProfiles := p.AllServices().GetProfiles() + if len(profiles) == 0 && len(allProfiles) > 0 { + return []string{"--profile"}, cobra.ShellCompDirectiveNoFileComp + } - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runProjectUpdate(ctx, p); err != nil { @@ -76,14 +79,17 @@ func init() { cmd.PersistentFlags().StringSliceVarP(&profiles, "profile", "p", []string{}, "Profile to use") - _ = cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - p, err := mgr.AutodetectProject(context.Background(), projectName) - if err != nil { - return []string{}, cobra.ShellCompDirectiveNoFileComp - } + _ = cmd.RegisterFlagCompletionFunc( + "profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + p, err := mgr.AutodetectProject(context.Background(), projectName) + if err != nil { + return []string{}, cobra.ShellCompDirectiveNoFileComp + } - return getProfileCompletions(p, toComplete) - }) + return getProfileCompletions(p, toComplete) + }, + ) root.AddCommand(cmd) } @@ -173,7 +179,7 @@ func runCertUpdate(p *project.Project, firstTime bool) error { err := cert.SetupCA(app.AppDir) if err != nil && firstTime { - args := []string{"--", "devbox", "install-ca", "--name", p.Name} + args := []string{"--", binName, "install-ca", nameFlag, p.Name} cmd := exec.Command("sudo", args...) if err := cmd.Run(); err != nil { @@ -201,7 +207,7 @@ func runHostsUpdate(p *project.Project, firstTime, cleanup bool) error { changed, err := hosts.Save(p.Name, entities) if err != nil && firstTime { // Permission denied - retry with sudo - args := []string{"--", "devbox", "update-hosts", "--name", p.Name} + args := []string{"--", binName, "update-hosts", nameFlag, p.Name} if cleanup { args = append(args, "--cleanup") } diff --git a/cmd/devbox/update.go b/cmd/devbox/update.go index 7f09324..40eb25c 100644 --- a/cmd/devbox/update.go +++ b/cmd/devbox/update.go @@ -9,10 +9,11 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/docker/compose/v5/pkg/api" + "github.com/spf13/cobra" + "github.com/pilat/devbox/internal/app" "github.com/pilat/devbox/internal/git" "github.com/pilat/devbox/internal/project" - "github.com/spf13/cobra" ) func init() { @@ -21,9 +22,11 @@ func init() { Short: "Update devbox project sources", Long: "That command will update sources in devbox project", Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { // We are attempting to update the project by its name before trying autodetection, // as autodetection may fail if the project manifest is damaged. diff --git a/cmd/devbox/update_hosts.go b/cmd/devbox/update_hosts.go index 188ccf7..49020e4 100644 --- a/cmd/devbox/update_hosts.go +++ b/cmd/devbox/update_hosts.go @@ -14,13 +14,15 @@ func init() { Use: "update-hosts", Hidden: true, Args: cobra.MinimumNArgs(0), - ValidArgsFunction: validArgsWrapper(func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp - }), + ValidArgsFunction: validArgsWrapper( + func(ctx context.Context, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp + }, + ), RunE: runWrapper(func(ctx context.Context, cmd *cobra.Command, args []string) error { p, err := mgr.AutodetectProject(ctx, projectName) if err != nil { - return err + return fmt.Errorf("failed to detect project: %w", err) } if err := runHostsUpdate(p, false, cleanup); err != nil { diff --git a/docs/installation.md b/docs/installation.md index e502f61..e603295 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -14,6 +14,14 @@ brew install devbox 1. Download the appropriate binary for your system from the [Releases Page](https://github.com/pilat/devbox/releases) 2. Make the binary executable and add it to your PATH +!!! note "macOS Gatekeeper" + The release binaries are unsigned, so macOS quarantines anything you download + and Gatekeeper refuses to run it. Clear the quarantine bit before the first run: + + ```bash + xattr -dr com.apple.quarantine ./devbox + ``` + ## System Requirements - Docker Engine 20.10.0 or later diff --git a/go.mod b/go.mod index 271327e..5debec3 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,18 @@ module github.com/pilat/devbox -go 1.25.5 +go 1.26.4 require ( - github.com/compose-spec/compose-go/v2 v2.11.0 - github.com/docker/cli v29.5.1+incompatible + github.com/compose-spec/compose-go/v2 v2.12.1 + github.com/docker/cli v29.6.0+incompatible github.com/docker/compose/v5 v5.1.4 - github.com/jedib0t/go-pretty/v6 v6.7.8 + github.com/jedib0t/go-pretty/v6 v6.8.1 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.51.0 - golang.org/x/term v0.41.0 + golang.org/x/net v0.56.0 + golang.org/x/term v0.44.0 ) require ( @@ -121,10 +121,10 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/go.sum b/go.sum index afb73d3..b4ade71 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM= -github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= +github.com/compose-spec/compose-go/v2 v2.12.1 h1:+xBZNxcgSus4atQJwXPEdhHRgCEyZmj/BuqN5m33Ou0= +github.com/compose-spec/compose-go/v2 v2.12.1/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= @@ -85,8 +85,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/buildx v0.33.0 h1:xuZeuQe/C/2tvLDgiIA6+Ynq3FFWSfsGNWIHM3q1hD8= github.com/docker/buildx v0.33.0/go.mod h1:7JVma62htERKE5iy5YD1q64PKiAHUzXuhSBd4oq3I74= -github.com/docker/cli v29.5.1+incompatible h1:NiufLAJoRcPauFoBNYthfuM4REFwM8H2h9xnLABNHGs= -github.com/docker/cli v29.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc= +github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/compose/v5 v5.1.4 h1:72mGZplTVlbq6JxxhCW/bX2o1h+tT8mwt7mc+QtmA6o= github.com/docker/compose/v5 v5.1.4/go.mod h1:ImZiWTwIFm7BziHjX2MZVzwOiIkClXRzv+rkU18lQoc= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -199,8 +199,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= -github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= -github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/jedib0t/go-pretty/v6 v6.8.1 h1:0fkCNhjrX0zPpwkWaDYU5VMrygg41Tu197mWILIJoqQ= +github.com/jedib0t/go-pretty/v6 v6.8.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -396,23 +396,23 @@ go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfP golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -420,14 +420,14 @@ golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/app/const.go b/internal/app/const.go index 277903c..b1076a7 100644 --- a/internal/app/const.go +++ b/internal/app/const.go @@ -5,9 +5,7 @@ import ( "path/filepath" ) -var ( - AppDir string = "/opt/devbox" -) +var AppDir = "/opt/devbox" const ( SourcesDir = "sources" diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 0619916..aa7adfa 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -36,7 +36,7 @@ func SetupCA(appDir string) error { return c.setupCA() } -func GeneratePair(appDir string, certFile, keyFile string, hosts []string) error { +func GeneratePair(appDir, certFile, keyFile string, hosts []string) error { c := &cert{ certFile: filepath.Join(appDir, "ca.crt"), keyFile: filepath.Join(appDir, "ca.key"), @@ -70,7 +70,7 @@ func (c *cert) setupCA() error { func (c *cert) generatePair(certFile, keyFile string, hosts []string) error { if len(hosts) == 0 { - return fmt.Errorf("no hosts provided") + return errors.New("no hosts provided") } err := c.loadCA() @@ -129,11 +129,11 @@ func (c *cert) generatePair(certFile, keyFile string, hosts []string) error { return fmt.Errorf("failed to generate client certificate: %w", err) } - if err := os.WriteFile(certFile, clientCertPEM, 0644); err != nil { + if err := os.WriteFile(certFile, clientCertPEM, 0o644); err != nil { return fmt.Errorf("failed to write client certificate: %w", err) } - if err := os.WriteFile(keyFile, clientKeyPEM, 0644); err != nil { + if err := os.WriteFile(keyFile, clientKeyPEM, 0o644); err != nil { return fmt.Errorf("failed to write client key: %w", err) } @@ -188,7 +188,7 @@ func (c *cert) generateCA() error { } // Write the certificate - if err := os.WriteFile(c.certFile, caCertPEM, 0644); err != nil { + if err := os.WriteFile(c.certFile, caCertPEM, 0o644); err != nil { return fmt.Errorf("failed to write CA certificate: %w", err) } @@ -199,7 +199,7 @@ func (c *cert) generateCA() error { } // Write the key - if err := os.WriteFile(c.keyFile, caKeyPEM, 0644); err != nil { + if err := os.WriteFile(c.keyFile, caKeyPEM, 0o644); err != nil { return fmt.Errorf("failed to write CA key: %w", err) } @@ -210,7 +210,7 @@ func (c *cert) generateCA() error { func decodePEM[T any](data []byte) (*T, error) { block, _ := pem.Decode(data) if block == nil { - return nil, fmt.Errorf("invalid PEM data: no block found") + return nil, errors.New("invalid PEM data: no block found") } var result any @@ -230,7 +230,7 @@ func decodePEM[T any](data []byte) (*T, error) { result, err = x509.ParsePKCS1PrivateKey(block.Bytes) default: - return nil, fmt.Errorf("unsupported type for decoding") + return nil, errors.New("unsupported type for decoding") } if err != nil { @@ -239,16 +239,22 @@ func decodePEM[T any](data []byte) (*T, error) { typedResult, ok := result.(*T) if !ok { - return nil, fmt.Errorf("unexpected type in PEM decoding") + return nil, errors.New("unexpected type in PEM decoding") } return typedResult, nil } -func generateCertificate(isCA bool, parentCert *x509.Certificate, parentKey *rsa.PrivateKey, commonName string, extra ...string) ([]byte, []byte, error) { +func generateCertificate( + isCA bool, + parentCert *x509.Certificate, + parentKey *rsa.PrivateKey, + commonName string, + extra ...string, +) (certPEM, keyPEM []byte, err error) { key, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("failed to generate key: %w", err) } certTemplate := &x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), @@ -277,9 +283,9 @@ func generateCertificate(isCA bool, parentCert *x509.Certificate, parentKey *rsa certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, parentCert, &key.PublicKey, parentKey) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("failed to create certificate: %w", err) } - certPEM := pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificate, Bytes: certDER}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: pemTypePrivateKey, Bytes: x509.MarshalPKCS1PrivateKey(key)}) + certPEM = pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificate, Bytes: certDER}) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: pemTypePrivateKey, Bytes: x509.MarshalPKCS1PrivateKey(key)}) return certPEM, keyPEM, nil } diff --git a/internal/cert/cert_darwin.go b/internal/cert/cert_darwin.go index f0c3297..49cc6ad 100644 --- a/internal/cert/cert_darwin.go +++ b/internal/cert/cert_darwin.go @@ -47,7 +47,16 @@ func (c *cert) isSynced() bool { } func (c *cert) installCA() error { - cmd := exec.Command("security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", c.certFile) + cmd := exec.Command( + "security", + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-k", + "/Library/Keychains/System.keychain", + c.certFile, + ) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to install certificate: %w", err) } diff --git a/internal/cert/cert_linux.go b/internal/cert/cert_linux.go index 7609fa9..5d461f3 100644 --- a/internal/cert/cert_linux.go +++ b/internal/cert/cert_linux.go @@ -6,6 +6,7 @@ package cert import ( "bytes" "encoding/pem" + "errors" "fmt" "os" "os/exec" @@ -45,14 +46,18 @@ func (c *cert) installCA() error { var lastErr error for _, distro := range distroPaths { // Ensure the directory exists - if err := os.MkdirAll(distro.certDir, 0755); err != nil { + if err := os.MkdirAll(distro.certDir, 0o755); err != nil { lastErr = err continue } // Write the CA certificate to the appropriate directory caFilePath := filepath.Join(distro.certDir, "devbox-ca.crt") - if err := os.WriteFile(caFilePath, pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificate, Bytes: c.cert.Raw}), 0644); err != nil { + if err := os.WriteFile( + caFilePath, + pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificate, Bytes: c.cert.Raw}), + 0o644, + ); err != nil { lastErr = err continue } @@ -72,5 +77,5 @@ func (c *cert) installCA() error { return fmt.Errorf("failed to install CA on Linux: %w", lastErr) } - return fmt.Errorf("could not find a supported Linux distribution for CA installation") + return errors.New("could not find a supported Linux distribution for CA installation") } diff --git a/internal/git/git.go b/internal/git/git.go index 12f25d7..9be5cc3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -47,8 +47,8 @@ func (s *svc) Clone(ctx context.Context, url, branch string) error { } func (s *svc) SetLocalExclude(patterns []string) error { - excludeFile := filepath.Join(s.targetPath, ".git/info/exclude") - file, err := os.OpenFile(excludeFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + excludeFile := filepath.Join(s.targetPath, ".git", "info", "exclude") + file, err := os.OpenFile(excludeFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return fmt.Errorf("failed to open exclude file: %w", err) } @@ -93,7 +93,10 @@ func (s *svc) Sync(ctx context.Context, url, branch string, sparseCheckout []str return fmt.Errorf("failed to init sparse-checkout: %s %w", out, err) } - out, err = s.runner.Run(ctx, "git", append([]string{"-C", s.targetPath, "sparse-checkout", "set"}, sparseCheckout...)...) + out, err = s.runner.Run( + ctx, + "git", + append([]string{"-C", s.targetPath, "sparse-checkout", "set"}, sparseCheckout...)...) if err != nil { return fmt.Errorf("failed to set sparse-checkout: %s %w", out, err) } @@ -163,7 +166,7 @@ func (s *svc) GetTopLevel(ctx context.Context) (string, error) { } func (s *svc) reset(ctx context.Context, removeIgnored bool) error { - _ = os.Remove(filepath.Join(s.targetPath, ".git/index.lock")) + _ = os.Remove(filepath.Join(s.targetPath, ".git", "index.lock")) out, err := s.runner.Run(ctx, "git", "-C", s.targetPath, "reset", "--hard") if err != nil { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 03e04b9..19ce032 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -122,7 +122,9 @@ func TestPull(t *testing.T) { { name: "reset fails", setupMock: func(m *MockCommandRunner) { - m.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "reset", "--hard").Return("error output", errors.New("reset failed")) + m.EXPECT(). + Run(mock.Anything, "git", "-C", "/tmp/test", "reset", "--hard"). + Return("error output", errors.New("reset failed")) }, wantErr: true, errContain: "failed to reset", @@ -131,7 +133,9 @@ func TestPull(t *testing.T) { name: "clean fails", setupMock: func(m *MockCommandRunner) { m.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "reset", "--hard").Return("", nil) - m.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "clean", "-fd").Return("error output", errors.New("clean failed")) + m.EXPECT(). + Run(mock.Anything, "git", "-C", "/tmp/test", "clean", "-fd"). + Return("error output", errors.New("clean failed")) }, wantErr: true, errContain: "failed to clean", @@ -141,7 +145,9 @@ func TestPull(t *testing.T) { setupMock: func(m *MockCommandRunner) { m.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "reset", "--hard").Return("", nil) m.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "clean", "-fd").Return("", nil) - m.EXPECT().RunWithTTY(mock.Anything, "git", "-C", "/tmp/test", "pull", "--rebase").Return("conflict", errors.New("pull failed")) + m.EXPECT(). + RunWithTTY(mock.Anything, "git", "-C", "/tmp/test", "pull", "--rebase"). + Return("conflict", errors.New("pull failed")) }, wantErr: true, errContain: "failed to pull", @@ -354,14 +360,14 @@ func TestSetLocalExclude(t *testing.T) { name: "writes patterns", patterns: []string{"*.log", "node_modules/", ".env"}, setup: func(t *testing.T, dir string) { - err := os.MkdirAll(filepath.Join(dir, ".git/info"), 0755) + err := os.MkdirAll(filepath.Join(dir, ".git", "info"), 0o755) if err != nil { t.Fatal(err) } }, wantErr: false, verify: func(t *testing.T, dir string) { - content, err := os.ReadFile(filepath.Join(dir, ".git/info/exclude")) + content, err := os.ReadFile(filepath.Join(dir, ".git", "info", "exclude")) if err != nil { t.Fatal(err) } @@ -375,18 +381,18 @@ func TestSetLocalExclude(t *testing.T) { name: "empty patterns", patterns: []string{}, setup: func(t *testing.T, dir string) { - err := os.MkdirAll(filepath.Join(dir, ".git/info"), 0755) + err := os.MkdirAll(filepath.Join(dir, ".git", "info"), 0o755) if err != nil { t.Fatal(err) } }, wantErr: false, verify: func(t *testing.T, dir string) { - content, err := os.ReadFile(filepath.Join(dir, ".git/info/exclude")) + content, err := os.ReadFile(filepath.Join(dir, ".git", "info", "exclude")) if err != nil { t.Fatal(err) } - if string(content) != "" { + if len(content) != 0 { t.Errorf("content should be empty, got %q", string(content)) } }, @@ -438,7 +444,9 @@ func TestSync(t *testing.T) { setupGit: false, sparseCheckout: nil, setupMock: func(m *MockCommandRunner, targetPath string) { - m.EXPECT().RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath).Return("", nil) + m.EXPECT(). + RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath). + Return("", nil) m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "disable").Return("", nil) m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "checkout", "main").Return("", nil) // Pull's reset (removeIgnored=false) @@ -454,9 +462,15 @@ func TestSync(t *testing.T) { setupGit: false, sparseCheckout: []string{"src", "docs"}, setupMock: func(m *MockCommandRunner, targetPath string) { - m.EXPECT().RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath).Return("", nil) - m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "init", "--cone").Return("", nil) - m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "set", "src", "docs").Return("", nil) + m.EXPECT(). + RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath). + Return("", nil) + m.EXPECT(). + Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "init", "--cone"). + Return("", nil) + m.EXPECT(). + Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "set", "src", "docs"). + Return("", nil) m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "checkout", "main").Return("", nil) // Pull's reset (removeIgnored=false) m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "reset", "--hard").Return("", nil) @@ -491,7 +505,9 @@ func TestSync(t *testing.T) { setupGit: false, sparseCheckout: nil, setupMock: func(m *MockCommandRunner, targetPath string) { - m.EXPECT().RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath).Return("auth failed", errors.New("exit status 128")) + m.EXPECT(). + RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath). + Return("auth failed", errors.New("exit status 128")) }, wantErr: true, errContain: "failed to clone", @@ -502,9 +518,13 @@ func TestSync(t *testing.T) { setupGit: false, sparseCheckout: nil, setupMock: func(m *MockCommandRunner, targetPath string) { - m.EXPECT().RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath).Return("", nil) + m.EXPECT(). + RunWithTTY(mock.Anything, "git", "clone", "--no-checkout", "--depth", "1", "https://github.com/org/repo.git", targetPath). + Return("", nil) m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "disable").Return("", nil) - m.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "checkout", "main").Return("branch not found", errors.New("exit status 1")) + m.EXPECT(). + Run(mock.Anything, "git", "-C", targetPath, "checkout", "main"). + Return("branch not found", errors.New("exit status 1")) }, wantErr: true, errContain: "failed to checkout", @@ -517,10 +537,10 @@ func TestSync(t *testing.T) { targetPath := filepath.Join(dir, "repo") if tt.setupDir { - _ = os.MkdirAll(targetPath, 0755) + _ = os.MkdirAll(targetPath, 0o755) } if tt.setupGit { - _ = os.MkdirAll(filepath.Join(targetPath, ".git"), 0755) + _ = os.MkdirAll(filepath.Join(targetPath, ".git"), 0o755) } runner := NewMockCommandRunner(t) @@ -636,12 +656,28 @@ func TestNormalizeURL(t *testing.T) { // Azure DevOps (different URL formats normalize to same value) {"Azure SSH", "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", "dev.azure.com/myorg/myproject/myrepo"}, {"Azure HTTPS", "https://dev.azure.com/myorg/myproject/_git/myrepo", "dev.azure.com/myorg/myproject/myrepo"}, - {"Azure HTTPS with user", "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo", "dev.azure.com/myorg/myproject/myrepo"}, - {"Azure old format", "https://myorg.visualstudio.com/myproject/_git/myrepo", "dev.azure.com/myorg/myproject/myrepo"}, + { + "Azure HTTPS with user", + "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo", + "dev.azure.com/myorg/myproject/myrepo", + }, + { + "Azure old format", + "https://myorg.visualstudio.com/myproject/_git/myrepo", + "dev.azure.com/myorg/myproject/myrepo", + }, // AWS CodeCommit - {"CodeCommit HTTPS", "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo", "git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo"}, - {"CodeCommit SSH", "ssh://git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo", "git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo"}, + { + "CodeCommit HTTPS", + "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo", + "git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo", + }, + { + "CodeCommit SSH", + "ssh://git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo", + "git-codecommit.us-east-1.amazonaws.com/v1/repos/myrepo", + }, } for _, tt := range tests { diff --git a/internal/git/normalizer.go b/internal/git/normalizer.go index e7978f3..4311932 100644 --- a/internal/git/normalizer.go +++ b/internal/git/normalizer.go @@ -8,12 +8,18 @@ import ( // URL normalization patterns var ( - sshPattern = regexp.MustCompile(`^git@([^:]+):(.+)/([^/]+?)(?:\.git)?$`) - sshExplicitPattern = regexp.MustCompile(`^(?:ssh|git\+ssh)://(?:[^@]+@)?([^/:]+)(?::\d+)?/(.+)/([^/]+?)(?:\.git)?$`) - gitProtocolPattern = regexp.MustCompile(`^git://([^/:]+)(?::\d+)?/(.+)/([^/]+?)(?:\.git)?$`) - azureSSHPattern = regexp.MustCompile(`^git@ssh\.dev\.azure\.com:v3/([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$`) - azureHTTPSPattern = regexp.MustCompile(`^https?://(?:[^@]+@)?dev\.azure\.com/([^/]+)/([^/]+)/_git/([^/]+?)(?:\.git)?$`) - azureOldHTTPSPattern = regexp.MustCompile(`^https?://(?:[^@]+@)?([^.]+)\.visualstudio\.com/([^/]+)/_git/([^/]+?)(?:\.git)?$`) + sshPattern = regexp.MustCompile(`^git@([^:]+):(.+)/([^/]+?)(?:\.git)?$`) + sshExplicitPattern = regexp.MustCompile( + `^(?:ssh|git\+ssh)://(?:[^@]+@)?([^/:]+)(?::\d+)?/(.+)/([^/]+?)(?:\.git)?$`, + ) + gitProtocolPattern = regexp.MustCompile(`^git://([^/:]+)(?::\d+)?/(.+)/([^/]+?)(?:\.git)?$`) + azureSSHPattern = regexp.MustCompile(`^git@ssh\.dev\.azure\.com:v3/([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$`) + azureHTTPSPattern = regexp.MustCompile( + `^https?://(?:[^@]+@)?dev\.azure\.com/([^/]+)/([^/]+)/_git/([^/]+?)(?:\.git)?$`, + ) + azureOldHTTPSPattern = regexp.MustCompile( + `^https?://(?:[^@]+@)?([^.]+)\.visualstudio\.com/([^/]+)/_git/([^/]+?)(?:\.git)?$`, + ) ) // NormalizeURL parses and normalizes a git URL for comparison. @@ -42,7 +48,7 @@ func NormalizeURL(rawURL string) string { return strings.ToLower(matches[1] + "/" + matches[2] + "/" + matches[3]) } - // git:// protocol + // git protocol scheme if matches := gitProtocolPattern.FindStringSubmatch(rawURL); matches != nil { return strings.ToLower(matches[1] + "/" + matches[2] + "/" + matches[3]) } diff --git a/internal/hosts/hosts.go b/internal/hosts/hosts.go index ace62b0..039d5cd 100644 --- a/internal/hosts/hosts.go +++ b/internal/hosts/hosts.go @@ -3,6 +3,7 @@ package hosts import ( "bufio" "bytes" + "errors" "fmt" "os" "strings" @@ -70,7 +71,7 @@ func save(hostFile, projectName string, entries []string) (bool, error) { } if lookupForEnd { - return false, fmt.Errorf("unexpected end of file") + return false, errors.New("unexpected end of file") } if !replaced && len(entries) > 0 { diff --git a/internal/hosts/hosts_test.go b/internal/hosts/hosts_test.go index 3f3bf46..42ed297 100644 --- a/internal/hosts/hosts_test.go +++ b/internal/hosts/hosts_test.go @@ -46,7 +46,7 @@ func TestSave(t *testing.T) { assert.NoError(t, err) defer func() { _ = os.Remove(tempFile.Name()) }() - err = os.WriteFile(tempFile.Name(), []byte(tc.initialContent), 0644) + err = os.WriteFile(tempFile.Name(), []byte(tc.initialContent), 0o644) assert.NoError(t, err) _, err = save(tempFile.Name(), tc.projectName, tc.entries) diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 6365364..d15f8ca 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -2,6 +2,7 @@ package manager import ( "context" + "errors" "fmt" "path/filepath" "regexp" @@ -80,7 +81,7 @@ func (m *Manager) AutodetectProject(ctx context.Context, name string) (*project. // 1. Check if the current dir is a local mount of one project if p, ambiguous := m.detectByLocalMount(curDir, projects); p != nil { if ambiguous { - return nil, fmt.Errorf("ambiguous project, please specify project name") + return nil, errors.New("ambiguous project, please specify project name") } return p, nil } @@ -89,13 +90,13 @@ func (m *Manager) AutodetectProject(ctx context.Context, name string) (*project. curDirGit := m.gitFactory(curDir) remoteURL, err := curDirGit.GetRemote(ctx) if err != nil { - return nil, fmt.Errorf("cannot detect project: not a git repository") + return nil, errors.New("cannot detect project: not a git repository") } remoteURL = git.NormalizeURL(remoteURL) if p, ambiguous := m.detectBySourceRemote(remoteURL, projects); p != nil { if ambiguous { - return nil, fmt.Errorf("ambiguous project, please specify project name") + return nil, errors.New("ambiguous project, please specify project name") } return p, nil } @@ -103,12 +104,12 @@ func (m *Manager) AutodetectProject(ctx context.Context, name string) (*project. // 3. Check if the current dir is the project's own manifest repository if p, ambiguous := m.detectByProjectRepo(ctx, remoteURL, projects); p != nil { if ambiguous { - return nil, fmt.Errorf("ambiguous project, please specify project name") + return nil, errors.New("ambiguous project, please specify project name") } return p, nil } - return nil, fmt.Errorf("cannot detect project: git remote does not match any known project") + return nil, errors.New("cannot detect project: git remote does not match any known project") } // loadAllProjects loads all available projects. @@ -173,7 +174,11 @@ func (m *Manager) detectBySourceRemote(remoteURL string, projects []*project.Pro } // detectByProjectRepo checks if the current git remote matches the project's manifest repository. -func (m *Manager) detectByProjectRepo(ctx context.Context, remoteURL string, projects []*project.Project) (*project.Project, bool) { +func (m *Manager) detectByProjectRepo( + ctx context.Context, + remoteURL string, + projects []*project.Project, +) (*project.Project, bool) { var found *project.Project ambiguous := false @@ -198,7 +203,12 @@ func (m *Manager) detectByProjectRepo(ctx context.Context, remoteURL string, pro } // AutodetectSource detects sources and affected services based on current directory or explicit selection. -func (m *Manager) AutodetectSource(ctx context.Context, proj *project.Project, sourceNameSel string, purpose AutodetectSourceType) (*SourceDetectionResult, error) { +func (m *Manager) AutodetectSource( + ctx context.Context, + proj *project.Project, + sourceNameSel string, + purpose AutodetectSourceType, +) (*SourceDetectionResult, error) { if sourceNameSel != "" { return m.detectExplicitSource(proj, sourceNameSel, purpose) } @@ -206,7 +216,11 @@ func (m *Manager) AutodetectSource(ctx context.Context, proj *project.Project, s } // detectExplicitSource handles the case when a source name is explicitly provided. -func (m *Manager) detectExplicitSource(proj *project.Project, sourceNameSel string, purpose AutodetectSourceType) (*SourceDetectionResult, error) { +func (m *Manager) detectExplicitSource( + proj *project.Project, + sourceNameSel string, + purpose AutodetectSourceType, +) (*SourceDetectionResult, error) { if !strings.HasPrefix(sourceNameSel, "./"+app.SourcesDir+"/") { return nil, fmt.Errorf("source '%s' not found", sourceNameSel) } @@ -235,7 +249,7 @@ func (m *Manager) detectExplicitSource(proj *project.Project, sourceNameSel stri } if len(affectedServices) == 0 { - return nil, fmt.Errorf("no services found using the detected source") + return nil, errors.New("no services found using the detected source") } return &SourceDetectionResult{ @@ -245,7 +259,11 @@ func (m *Manager) detectExplicitSource(proj *project.Project, sourceNameSel stri } // detectSourceByGitRemote autodetects sources by matching git remote URLs. -func (m *Manager) detectSourceByGitRemote(ctx context.Context, proj *project.Project, purpose AutodetectSourceType) (*SourceDetectionResult, error) { +func (m *Manager) detectSourceByGitRemote( + ctx context.Context, + proj *project.Project, + purpose AutodetectSourceType, +) (*SourceDetectionResult, error) { curDir, err := m.fs.Getwd() if err != nil { return nil, fmt.Errorf("failed to get current directory: %w", err) @@ -283,21 +301,47 @@ func (m *Manager) detectSourceByGitRemote(ctx context.Context, proj *project.Pro for _, service := range proj.Services { if service.Build != nil { - if relSource, lp, ok := m.matchSourcePath(sourcePrefix, service.Build.Context, relativePath, toplevelDir, proj, purpose); ok { + if relSource, lp, ok := m.matchSourcePath( + sourcePrefix, + service.Build.Context, + relativePath, + toplevelDir, + proj, + purpose, + ); ok { sources[relSource] = true affectedServices[service.Name] = true localPath = lp - } else if m.isSourceInWrongState(sourcePrefix, service.Build.Context, relativePath, proj, purpose) { + } else if m.isSourceInWrongState( + sourcePrefix, + service.Build.Context, + relativePath, + proj, + purpose, + ) { foundButWrongState = true } } for _, volume := range service.Volumes { - if relSource, lp, ok := m.matchSourcePath(sourcePrefix, volume.Source, relativePath, toplevelDir, proj, purpose); ok { + if relSource, lp, ok := m.matchSourcePath( + sourcePrefix, + volume.Source, + relativePath, + toplevelDir, + proj, + purpose, + ); ok { sources[relSource] = true affectedServices[service.Name] = true localPath = lp - } else if m.isSourceInWrongState(sourcePrefix, volume.Source, relativePath, proj, purpose) { + } else if m.isSourceInWrongState( + sourcePrefix, + volume.Source, + relativePath, + proj, + purpose, + ) { foundButWrongState = true } } @@ -307,11 +351,11 @@ func (m *Manager) detectSourceByGitRemote(ctx context.Context, proj *project.Pro if len(sources) == 0 { if foundButWrongState { if purpose == AutodetectSourceForMount { - return nil, fmt.Errorf("source is already mounted") + return nil, errors.New("source is already mounted") } - return nil, fmt.Errorf("source is not mounted") + return nil, errors.New("source is not mounted") } - return nil, fmt.Errorf("no services found using the detected source") + return nil, errors.New("no services found using the detected source") } return &SourceDetectionResult{ @@ -322,7 +366,11 @@ func (m *Manager) detectSourceByGitRemote(ctx context.Context, proj *project.Pro } // isSourceInWrongState checks if a source path matches but is in the wrong mount state. -func (m *Manager) isSourceInWrongState(sourcePrefix, servicePath, cwdRelPath string, proj *project.Project, purpose AutodetectSourceType) bool { +func (m *Manager) isSourceInWrongState( + sourcePrefix, servicePath, cwdRelPath string, + proj *project.Project, + purpose AutodetectSourceType, +) bool { if purpose == AutodetectSourceForMount { // For mount: check if servicePath is a mounted local path for origPath, localPath := range proj.LocalMounts { @@ -337,20 +385,18 @@ func (m *Manager) isSourceInWrongState(sourcePrefix, servicePath, cwdRelPath str } } } - } else { + } else if strings.HasPrefix(servicePath, sourcePrefix) { // For unmount: check if servicePath is an unmounted source path - if strings.HasPrefix(servicePath, sourcePrefix) { - serviceSubpath := strings.TrimPrefix(servicePath, sourcePrefix) - serviceSubpath = strings.TrimPrefix(serviceSubpath, string(filepath.Separator)) - if cwdMatchesServiceSubpath(cwdRelPath, serviceSubpath) { - relSourcePath, err := filepath.Rel(proj.WorkingDir, servicePath) - if err != nil { - return false - } - relSourcePath = "./" + relSourcePath - if _, ok := proj.LocalMounts[relSourcePath]; !ok { - return true - } + serviceSubpath := strings.TrimPrefix(servicePath, sourcePrefix) + serviceSubpath = strings.TrimPrefix(serviceSubpath, string(filepath.Separator)) + if cwdMatchesServiceSubpath(cwdRelPath, serviceSubpath) { + relSourcePath, err := filepath.Rel(proj.WorkingDir, servicePath) + if err != nil { + return false + } + relSourcePath = "./" + relSourcePath + if _, ok := proj.LocalMounts[relSourcePath]; !ok { + return true } } } @@ -359,8 +405,11 @@ func (m *Manager) isSourceInWrongState(sourcePrefix, servicePath, cwdRelPath str // matchSourcePath checks if the cwd's relative path is inside the service's source path. // Returns the relative source path, the local path to mount, and true if matched. -func (m *Manager) matchSourcePath(sourcePrefix, servicePath, cwdRelPath, toplevelDir string, proj *project.Project, purpose AutodetectSourceType) (string, string, bool) { - var relSourcePath string +func (m *Manager) matchSourcePath( + sourcePrefix, servicePath, cwdRelPath, toplevelDir string, + proj *project.Project, + purpose AutodetectSourceType, +) (relSourcePath, localPath string, matched bool) { var serviceSubpath string if strings.HasPrefix(servicePath, sourcePrefix) { @@ -376,8 +425,8 @@ func (m *Manager) matchSourcePath(sourcePrefix, servicePath, cwdRelPath, topleve relSourcePath = "./" + relSourcePath } else if purpose == AutodetectSourceForUmount { // For unmount: servicePath might be a local mount path, reverse lookup original source - for origPath, localPath := range proj.LocalMounts { - if servicePath == localPath && strings.HasPrefix(origPath, "./"+app.SourcesDir+"/") { + for origPath, mountPath := range proj.LocalMounts { + if servicePath == mountPath && strings.HasPrefix(origPath, "./"+app.SourcesDir+"/") { // Check if this mount belongs to the current source fullOrigPath := filepath.Join(proj.WorkingDir, origPath) if strings.HasPrefix(fullOrigPath, sourcePrefix) { @@ -409,7 +458,7 @@ func (m *Manager) matchSourcePath(sourcePrefix, servicePath, cwdRelPath, topleve } // Calculate the local path: / - localPath := toplevelDir + localPath = toplevelDir if serviceSubpath != "" { localPath = filepath.Join(toplevelDir, serviceSubpath) } @@ -479,7 +528,11 @@ func (m *Manager) list(filter string) ([]string, error) { } func (m *Manager) load(ctx context.Context, name string, profiles []string) (*project.Project, error) { - return project.New(ctx, name, profiles) + p, err := project.New(ctx, name, profiles) + if err != nil { + return nil, fmt.Errorf("failed to load project: %w", err) + } + return p, nil } func (m *Manager) Init(ctx context.Context, name, url, branch string) error { @@ -490,7 +543,7 @@ func (m *Manager) Init(ctx context.Context, name, url, branch string) error { projectFolder := filepath.Join(app.AppDir, name) if _, err := m.fs.Stat(projectFolder); err == nil { - return fmt.Errorf("project already exists") + return errors.New("project already exists") } cleanup := func() { @@ -504,9 +557,9 @@ func (m *Manager) Init(ctx context.Context, name, url, branch string) error { } patterns := []string{ - fmt.Sprintf("/%s/", app.SourcesDir), - fmt.Sprintf("/%s", app.StateFile), - fmt.Sprintf("/%s", app.EnvFile), + "/" + app.SourcesDir + "/", + "/" + app.StateFile, + "/" + app.EnvFile, } if err := g.SetLocalExclude(patterns); err != nil { @@ -518,7 +571,7 @@ func (m *Manager) Init(ctx context.Context, name, url, branch string) error { app.EnvFile: "", app.StateFile: "{}", } { - if err := m.fs.WriteFile(filepath.Join(projectFolder, k), []byte(content), 0644); err != nil { + if err := m.fs.WriteFile(filepath.Join(projectFolder, k), []byte(content), 0o644); err != nil { cleanup() return fmt.Errorf("failed to create file: %w", err) } diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go index 2f64049..0392588 100644 --- a/internal/manager/manager_test.go +++ b/internal/manager/manager_test.go @@ -7,12 +7,13 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/types" - "github.com/pilat/devbox/internal/git" - "github.com/pilat/devbox/internal/pkg/fs" - "github.com/pilat/devbox/internal/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/pilat/devbox/internal/git" + "github.com/pilat/devbox/internal/pkg/fs" + "github.com/pilat/devbox/internal/project" ) const testProjectName = "myproject" @@ -1534,7 +1535,14 @@ func TestMatchSourcePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New() - relSource, localPath, ok := m.matchSourcePath(tt.sourcePrefix, tt.servicePath, tt.cwdRelPath, tt.toplevelDir, tt.proj, tt.purpose) + relSource, localPath, ok := m.matchSourcePath( + tt.sourcePrefix, + tt.servicePath, + tt.cwdRelPath, + tt.toplevelDir, + tt.proj, + tt.purpose, + ) if ok != tt.wantOk { t.Errorf("matchSourcePath() ok = %v, want %v", ok, tt.wantOk) @@ -1554,7 +1562,7 @@ func TestMatchSourcePath(t *testing.T) { // ============================================================================ func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) + return len(s) >= len(substr) && (s == substr || s != "" && containsHelper(s, substr)) } func containsHelper(s, substr string) bool { diff --git a/internal/pkg/fs/fs.go b/internal/pkg/fs/fs.go index 7aafb16..e5cba97 100644 --- a/internal/pkg/fs/fs.go +++ b/internal/pkg/fs/fs.go @@ -1,6 +1,7 @@ package fs import ( + "fmt" "io/fs" "os" "time" @@ -41,8 +42,11 @@ func (e osDirEntry) Name() string { return e.entry.Name() } func (e osDirEntry) IsDir() bool { return e.entry.IsDir() } func (e osDirEntry) Type() fs.FileMode { return e.entry.Type() } func (e osDirEntry) Info() (FileInfo, error) { - // os.FileInfo satisfies our FileInfo interface via structural typing - return e.entry.Info() + info, err := e.entry.Info() + if err != nil { + return nil, fmt.Errorf("failed to get dir entry info: %w", err) + } + return info, nil } var _ FileSystem = (*OSFileSystem)(nil) @@ -54,17 +58,25 @@ func New() *OSFileSystem { } func (f *OSFileSystem) Getwd() (string, error) { - return os.Getwd() + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } + return dir, nil } func (f *OSFileSystem) Stat(path string) (FileInfo, error) { - return os.Stat(path) + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", path, err) + } + return info, nil } func (f *OSFileSystem) ReadDir(path string) ([]DirEntry, error) { entries, err := os.ReadDir(path) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read dir %s: %w", path, err) } result := make([]DirEntry, len(entries)) for i, e := range entries { @@ -74,9 +86,15 @@ func (f *OSFileSystem) ReadDir(path string) ([]DirEntry, error) { } func (f *OSFileSystem) WriteFile(path string, data []byte, perm os.FileMode) error { - return os.WriteFile(path, data, perm) + if err := os.WriteFile(path, data, perm); err != nil { + return fmt.Errorf("failed to write file %s: %w", path, err) + } + return nil } func (f *OSFileSystem) RemoveAll(path string) error { - return os.RemoveAll(path) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + return nil } diff --git a/internal/project/config.go b/internal/project/config.go index eefe770..e9b507b 100644 --- a/internal/project/config.go +++ b/internal/project/config.go @@ -1,30 +1,36 @@ package project -type SourceConfigs map[string]SourceConfig -type SourceConfig struct { - URL string `yaml:"url"` - Branch string `yaml:"branch"` - SparseCheckout []string `yaml:"sparseCheckout"` - Environment []string `yaml:"environment"` -} +type ( + SourceConfigs map[string]SourceConfig + SourceConfig struct { + URL string `yaml:"url"` + Branch string `yaml:"branch"` + SparseCheckout []string `yaml:"sparseCheckout"` + Environment []string `yaml:"environment"` + } +) -type ScenarioConfigs map[string]ScenarioConfig -type ScenarioConfig struct { - Service string `yaml:"service"` - Description string `yaml:"description"` - Command []string `yaml:"command"` - Entrypoint []string `yaml:"entrypoint"` - Tty *bool `yaml:"tty"` // default: auto-detect - Interactive *bool `yaml:"stdin_open"` // default: true - WorkingDir string `yaml:"working_dir"` - User string `yaml:"user"` -} +type ( + ScenarioConfigs map[string]ScenarioConfig + ScenarioConfig struct { + Service string `yaml:"service"` + Description string `yaml:"description"` + Command []string `yaml:"command"` + Entrypoint []string `yaml:"entrypoint"` + Tty *bool `yaml:"tty"` // default: auto-detect + Interactive *bool `yaml:"stdin_open"` // default: true + WorkingDir string `yaml:"working_dir"` + User string `yaml:"user"` + } +) -type HostConfigs []HostConfig -type HostConfig struct { - IP string `yaml:"ip"` - Hosts []string `yaml:"hosts"` -} +type ( + HostConfigs []HostConfig + HostConfig struct { + IP string `yaml:"ip"` + Hosts []string `yaml:"hosts"` + } +) type CertConfig struct { Domains []string `yaml:"domains"` diff --git a/internal/project/export.go b/internal/project/export.go index 3ae2496..e89a1f9 100644 --- a/internal/project/export.go +++ b/internal/project/export.go @@ -5,15 +5,21 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -type Networks = types.Networks -type Duration = types.Duration +type ( + Networks = types.Networks + Duration = types.Duration +) -var IncludeDependents = types.IncludeDependents -var IgnoreDependencies = types.IgnoreDependencies +var ( + IncludeDependents = types.IncludeDependents + IgnoreDependencies = types.IgnoreDependencies +) -type UpOptions = api.UpOptions -type CreateOptions = api.CreateOptions -type StartOptions = api.StartOptions +type ( + UpOptions = api.UpOptions + CreateOptions = api.CreateOptions + StartOptions = api.StartOptions +) type RunOptions = api.RunOptions @@ -25,6 +31,8 @@ type PsOptions = api.PsOptions type LogOptions = api.LogOptions -var ServiceLabel = api.ServiceLabel -var ProjectLabel = api.ProjectLabel -var WorkingDirLabel = api.WorkingDirLabel +var ( + ServiceLabel = api.ServiceLabel + ProjectLabel = api.ProjectLabel + WorkingDirLabel = api.WorkingDirLabel +) diff --git a/internal/project/project.go b/internal/project/project.go index 53f5ff8..2c658b3 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -13,14 +13,14 @@ import ( "github.com/compose-spec/compose-go/v2/cli" "github.com/compose-spec/compose-go/v2/consts" - "github.com/pilat/devbox/internal/app" - "golang.org/x/net/idna" - "github.com/compose-spec/compose-go/v2/types" "github.com/docker/compose/v5/pkg/api" + "golang.org/x/net/idna" + + "github.com/pilat/devbox/internal/app" ) -// Replacement of composer service with our state keeper. Another extended service (with client inside) will be used. +// Project wraps a compose-go project with devbox extensions (sources, scenarios, hosts, cert) and mount state. type Project struct { *types.Project @@ -123,13 +123,13 @@ func (p *Project) SaveState() error { Mounts: p.LocalMounts, } - json, err := json.Marshal(state) + data, err := json.Marshal(state) if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } filename := filepath.Join(p.WorkingDir, app.StateFile) - err = os.WriteFile(filename, json, 0644) + err = os.WriteFile(filename, data, 0o644) if err != nil { return fmt.Errorf("failed to write state file: %w", err) } @@ -192,73 +192,103 @@ func loadState(p *Project) error { } func applySources(p *Project) error { - if s, ok := p.Extensions["x-devbox-sources"]; ok { - p.Sources = s.(SourceConfigs) //nolint: forcetypeassert + s, ok := p.Extensions["x-devbox-sources"] + if !ok { + return nil } + sources, ok := s.(SourceConfigs) + if !ok { + return fmt.Errorf("unexpected type %T for x-devbox-sources extension", s) + } + p.Sources = sources + return nil } func applyScenarios(p *Project) error { - if s, ok := p.Extensions["x-devbox-scenarios"]; ok { - p.Scenarios = s.(ScenarioConfigs) //nolint: forcetypeassert + s, ok := p.Extensions["x-devbox-scenarios"] + if !ok { + return nil + } + + scenarios, ok := s.(ScenarioConfigs) + if !ok { + return fmt.Errorf("unexpected type %T for x-devbox-scenarios extension", s) } + p.Scenarios = scenarios return nil } func applyHosts(p *Project) error { - if s, ok := p.Extensions["x-devbox-hosts"]; ok { - hostConfigs := s.(HostConfigs) //nolint: forcetypeassert + s, ok := p.Extensions["x-devbox-hosts"] + if !ok { + return nil + } - ipToHosts := make(map[string][]string) - for _, item := range hostConfigs { - ip := net.ParseIP(item.IP) - item.IP = ip.String() + hostConfigs, ok := s.(HostConfigs) + if !ok { + return fmt.Errorf("unexpected type %T for x-devbox-hosts extension", s) + } - for _, hostname := range item.Hosts { - hostname, err := idna.Lookup.ToASCII(hostname) - if err != nil { - return fmt.Errorf("failed to convert hostname to ASCII: %w", err) - } + ipToHosts := make(map[string][]string) + for _, item := range hostConfigs { + ip := net.ParseIP(item.IP) + if ip == nil { + return fmt.Errorf("invalid IP %q for x-devbox-hosts extension", item.IP) + } + item.IP = ip.String() - if _, ok := ipToHosts[item.IP]; !ok { - ipToHosts[item.IP] = []string{} - } + for _, hostname := range item.Hosts { + asciiHost, err := idna.Lookup.ToASCII(hostname) + if err != nil { + return fmt.Errorf("failed to convert hostname to ASCII: %w", err) + } - ipToHosts[item.IP] = append(ipToHosts[item.IP], hostname) + if _, exists := ipToHosts[item.IP]; !exists { + ipToHosts[item.IP] = []string{} } - } - // Sort IPs to ensure deterministic output order - ips := make([]string, 0, len(ipToHosts)) - for ip := range ipToHosts { - ips = append(ips, ip) + ipToHosts[item.IP] = append(ipToHosts[item.IP], asciiHost) } - slices.Sort(ips) + } - entities := make([]string, 0, len(ips)) - for _, ip := range ips { - slices.Sort(ipToHosts[ip]) - entities = append(entities, fmt.Sprintf("%s %s", ip, strings.Join(ipToHosts[ip], " "))) - } + // Sort IPs to ensure deterministic output order + ips := make([]string, 0, len(ipToHosts)) + for ip := range ipToHosts { + ips = append(ips, ip) + } + slices.Sort(ips) - p.HostEntities = entities + entities := make([]string, 0, len(ips)) + for _, ip := range ips { + slices.Sort(ipToHosts[ip]) + entities = append(entities, fmt.Sprintf("%s %s", ip, strings.Join(ipToHosts[ip], " "))) } + p.HostEntities = entities + return nil } func applyCert(p *Project) error { - if s, ok := p.Extensions["x-devbox-cert"]; ok { - p.CertConfig = s.(CertConfig) //nolint: forcetypeassert + s, ok := p.Extensions["x-devbox-cert"] + if !ok { + return nil + } - if p.CertConfig.CertFile != "" { - p.CertConfig.CertFile = p.absPath(p.CertConfig.CertFile) - } - if p.CertConfig.KeyFile != "" { - p.CertConfig.KeyFile = p.absPath(p.CertConfig.KeyFile) - } + cfg, ok := s.(CertConfig) + if !ok { + return fmt.Errorf("unexpected type %T for x-devbox-cert extension", s) + } + p.CertConfig = cfg + + if p.CertConfig.CertFile != "" { + p.CertConfig.CertFile = p.absPath(p.CertConfig.CertFile) + } + if p.CertConfig.KeyFile != "" { + p.CertConfig.KeyFile = p.absPath(p.CertConfig.KeyFile) } return nil @@ -267,8 +297,11 @@ func applyCert(p *Project) error { func setupGracePeriod(p *Project) error { var defaultStopGracePeriod *Duration - if s, ok := p.Extensions["x-devbox-default-stop-grace-period"]; ok { - v := s.(Duration) //nolint: forcetypeassert + if s, found := p.Extensions["x-devbox-default-stop-grace-period"]; found { + v, ok := s.(Duration) + if !ok { + return fmt.Errorf("unexpected type %T for x-devbox-default-stop-grace-period extension", s) + } defaultStopGracePeriod = &v } @@ -385,7 +418,7 @@ func convertToEnvName(name string) string { // Ensure the name starts with a letter or underscore finalName := result.String() - if len(finalName) > 0 && !unicode.IsLetter(rune(finalName[0])) { + if finalName != "" && !unicode.IsLetter(rune(finalName[0])) { finalName = "_" + finalName } diff --git a/internal/table/export.go b/internal/table/export.go index b2147b9..bee658b 100644 --- a/internal/table/export.go +++ b/internal/table/export.go @@ -4,6 +4,8 @@ import "github.com/jedib0t/go-pretty/v6/table" type SortBy = table.SortBy -var Asc = table.Asc -var AscAlphaNumeric = table.AscAlphaNumeric -var Dsc = table.Dsc +var ( + Asc = table.Asc + AscAlphaNumeric = table.AscAlphaNumeric + Dsc = table.Dsc +) diff --git a/internal/table/table.go b/internal/table/table.go index 42f1a86..c64a82b 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -6,7 +6,6 @@ import ( "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" - "golang.org/x/term" ) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 294b330..2c23bfe 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -1,6 +1,7 @@ package e2e import ( + "errors" "io" "net/http" "os" @@ -65,7 +66,7 @@ func (s *E2ESuite) SetupSuite() { // Setup manifest repo s.runGit("", "init", "--bare", s.manifestRepo) - s.Require().NoError(os.Mkdir(s.manifestDir, 0755)) + s.Require().NoError(os.Mkdir(s.manifestDir, 0o755)) s.runGit(s.manifestDir, "init") // Copy and configure manifest files @@ -76,7 +77,7 @@ func (s *E2ESuite) SetupSuite() { content, err := os.ReadFile(composeFile) s.Require().NoError(err) content = []byte(strings.ReplaceAll(string(content), "SOURCE_1_REPO", s.source1Repo)) - s.Require().NoError(os.WriteFile(composeFile, content, 0644)) + s.Require().NoError(os.WriteFile(composeFile, content, 0o644)) // Push manifest s.runGit(s.manifestDir, "remote", "add", "origin", s.manifestRepo) @@ -88,7 +89,7 @@ func (s *E2ESuite) SetupSuite() { // Setup source 1 repo s.runGit("", "init", "--bare", s.source1Repo) - s.Require().NoError(os.Mkdir(s.source1Dir, 0755)) + s.Require().NoError(os.Mkdir(s.source1Dir, 0o755)) // Copy service-1 files s.copyDir(filepath.Join(s.fixturesDir, "service-1"), s.source1Dir) @@ -149,39 +150,41 @@ func (s *E2ESuite) copyDir(src, dst string) { dstPath := filepath.Join(dst, entry.Name()) if entry.IsDir() { - s.Require().NoError(os.MkdirAll(dstPath, 0755)) + s.Require().NoError(os.MkdirAll(dstPath, 0o755)) s.copyDir(srcPath, dstPath) } else { data, err := os.ReadFile(srcPath) s.Require().NoError(err) - s.Require().NoError(os.WriteFile(dstPath, data, 0644)) + s.Require().NoError(os.WriteFile(dstPath, data, 0o644)) } } } -func (s *E2ESuite) devbox(args ...string) (string, string, error) { +func (s *E2ESuite) devbox(args ...string) (stdout, stderr string, err error) { cmd := exec.Command("devbox", args...) cmd.Env = append(os.Environ(), "DEVBOX_TEST_HOSTS_FILE="+s.hostsFile) - stdout, err := cmd.Output() - var stderr []byte - if exitErr, ok := err.(*exec.ExitError); ok { - stderr = exitErr.Stderr + outBytes, err := cmd.Output() + var errBytes []byte + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + errBytes = exitErr.Stderr } - return string(stdout), string(stderr), err + return string(outBytes), string(errBytes), err } -func (s *E2ESuite) devboxInDir(dir string, args ...string) (string, string, error) { +func (s *E2ESuite) devboxInDir(dir string, args ...string) (stdout, stderr string, err error) { cmd := exec.Command("devbox", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "DEVBOX_TEST_HOSTS_FILE="+s.hostsFile) - stdout, err := cmd.Output() - var stderr []byte - if exitErr, ok := err.(*exec.ExitError); ok { - stderr = exitErr.Stderr + outBytes, err := cmd.Output() + var errBytes []byte + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + errBytes = exitErr.Stderr } - return string(stdout), string(stderr), err + return string(outBytes), string(errBytes), err } // devboxRun executes devbox command ignoring output (for setup/teardown) @@ -240,7 +243,7 @@ func (s *E2ESuite) removeVolumeFromDockerCompose() { newContent := strings.ReplaceAll(string(content), ` volumes: - ./sources/service-1:/app`, "") - s.Require().NoError(os.WriteFile(composeFile, []byte(newContent), 0644)) + s.Require().NoError(os.WriteFile(composeFile, []byte(newContent), 0o644)) } func (s *E2ESuite) removeBuildContextFromDockerCompose() { @@ -252,7 +255,7 @@ func (s *E2ESuite) removeBuildContextFromDockerCompose() { context: ./sources/service-1 dockerfile: Dockerfile`, "") newContent = strings.ReplaceAll(newContent, `image: local/service-1:latest`, `image: golang:1.21-alpine`) - s.Require().NoError(os.WriteFile(composeFile, []byte(newContent), 0644)) + s.Require().NoError(os.WriteFile(composeFile, []byte(newContent), 0o644)) } func (s *E2ESuite) cleanupProject() { @@ -278,7 +281,7 @@ func main() { http.ListenAndServe(":80", nil) } ` - _ = os.WriteFile(mainGo, []byte(originalContent), 0644) + _ = os.WriteFile(mainGo, []byte(originalContent), 0o644) } // ============================================================================ @@ -458,7 +461,7 @@ func (s *E2ESuite) Test55_MountForBuildOperations() { newContent := strings.ReplaceAll(string(content), "Hello, World from service 1", "Hello, World from service 1 updated") - s.Require().NoError(os.WriteFile(mainGo, []byte(newContent), 0644)) + s.Require().NoError(os.WriteFile(mainGo, []byte(newContent), 0o644)) // Restart service s.devboxRun("restart", "--name", "test-app", "service-1") @@ -509,7 +512,7 @@ func (s *E2ESuite) Test57_MountForVolumeOperations() { newContent := strings.ReplaceAll(string(content), "Hello, World from service 1", "Hello, World from service 1 updated") - s.Require().NoError(os.WriteFile(mainGo, []byte(newContent), 0644)) + s.Require().NoError(os.WriteFile(mainGo, []byte(newContent), 0o644)) // Restart service s.devboxRun("restart", "--name", "test-app", "service-1")